foundry-implementation-actor 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. foundry_implementation_actor-0.6.0/.github/workflows/release.yml +210 -0
  2. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/CLAUDE.md +23 -5
  3. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/PKG-INFO +56 -3
  4. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/README.md +55 -2
  5. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/adr/ADR-FIA-0004-the-three-amigos-round.md +12 -3
  6. foundry_implementation_actor-0.6.0/adr/ADR-FIA-0006-the-image-is-published-to-two-registries.md +104 -0
  7. foundry_implementation_actor-0.6.0/adr/ADR-FIA-0007-a-sessions-budget-is-an-environment-setting.md +105 -0
  8. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/adr/README.md +2 -0
  9. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/docker/Dockerfile +6 -0
  10. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/examples/ACME.PARTS.CAP.SUP.007.WID-implementation/Dockerfile +8 -1
  11. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/examples/README.md +18 -1
  12. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/pyproject.toml +1 -1
  13. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/__init__.py +7 -0
  14. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/cli.py +6 -1
  15. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/engine.py +54 -16
  16. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/serve.py +15 -2
  17. foundry_implementation_actor-0.6.0/src/foundry_implementation_actor/settings.py +102 -0
  18. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_cli.py +18 -0
  19. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_engine.py +129 -2
  20. foundry_implementation_actor-0.6.0/tests/test_settings.py +107 -0
  21. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/uv.lock +1 -1
  22. foundry_implementation_actor-0.5.0/.github/workflows/release.yml +0 -106
  23. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/.github/workflows/ci.yml +0 -0
  24. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/.gitignore +0 -0
  25. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/adr/ADR-FIA-0001-the-machinery-leaves-the-capability.md +0 -0
  26. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/adr/ADR-FIA-0002-testing-leaves-this-actors-contract.md +0 -0
  27. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/adr/ADR-FIA-0003-the-dev-test-agreement-has-no-home-yet.md +0 -0
  28. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/adr/ADR-FIA-0005-the-actor-ships-an-image.md +0 -0
  29. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/adr/template.md +0 -0
  30. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/examples/ACME.PARTS.CAP.SUP.007.WID-implementation/actor-agentic-context.yaml +0 -0
  31. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/scripts/probe_grounding.py +0 -0
  32. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/cards/actor-data.yaml +0 -0
  33. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/cards/actor-message.yaml +0 -0
  34. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/cards/actor-synchronous-messaging.yaml +0 -0
  35. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/cards/actor.yaml +0 -0
  36. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/config.py +0 -0
  37. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/conformance.py +0 -0
  38. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/correlation.py +0 -0
  39. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/grounding.py +0 -0
  40. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/handler.py +0 -0
  41. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/instance.py +0 -0
  42. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/src/foundry_implementation_actor/schemas/agentic-context.schema.yaml +0 -0
  43. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/conftest.py +0 -0
  44. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/fixtures/broken/actor-agentic-context.yaml +0 -0
  45. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_cards.py +0 -0
  46. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_config.py +0 -0
  47. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_conformance.py +0 -0
  48. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_grounding.py +0 -0
  49. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_handler.py +0 -0
  50. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_instance.py +0 -0
  51. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.6.0}/tests/test_portability.py +0 -0
@@ -0,0 +1,210 @@
1
+ name: release
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ # A REGISTRY ADDED AFTER A TAG WAS ALREADY CUT. The image job below publishes to two registries,
7
+ # and the second one was not always there. Running this workflow by hand backfills the image for
8
+ # a version whose wheel is already on PyPI — without inventing a version number whose only
9
+ # content is "the last release missed a registry".
10
+ #
11
+ # THE TAG IS AN INPUT, NOT THE REF THIS RUNS FROM. GitHub takes the workflow FILE from the ref it
12
+ # is dispatched on, and the tag needing a backfill is by definition older than the workflow that
13
+ # can do it — dispatching on that tag would run the very file that lacks this trigger. So run it
14
+ # from the default branch and name the tag here; the checkout below takes the CONTENT from the
15
+ # tag, and the version gate still refuses a tag that disagrees with the `pyproject.toml` beside
16
+ # it.
17
+ workflow_dispatch:
18
+ inputs:
19
+ tag:
20
+ description: "Existing tag to publish an image for, e.g. v0.5.0"
21
+ required: true
22
+
23
+ # PyPI Trusted Publishing (OIDC) — no token is stored anywhere. The one-time setup is a pending
24
+ # publisher on pypi.org naming this repo and this workflow; after the first release it becomes a
25
+ # normal publisher. See README.
26
+ permissions:
27
+ id-token: write # PyPI Trusted Publishing
28
+ contents: read
29
+ packages: write # the image, to this repo's own container registry
30
+
31
+ jobs:
32
+ publish:
33
+ # ONLY ON A TAG PUSH. A manual run exists to backfill an IMAGE (see `on:` above), and PyPI
34
+ # refuses a version it already holds — so re-uploading is not merely redundant, it fails, and
35
+ # would take the image job down with it.
36
+ if: github.event_name == 'push'
37
+ runs-on: ubuntu-latest
38
+ environment: pypi
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: astral-sh/setup-uv@v5
42
+
43
+ - name: build
44
+ # No fetch step and no external token: the contract is committed here. A release depends
45
+ # on nothing but this checkout and PyPI.
46
+ run: uv build
47
+
48
+ - name: the wheel must carry its contract
49
+ run: |
50
+ uv venv /tmp/probe
51
+ uv pip install --python /tmp/probe/bin/python -q dist/*.whl
52
+ /tmp/probe/bin/foundry-implementation-actor lint examples/ACME.PARTS.CAP.SUP.007.WID-implementation
53
+
54
+ - name: the wheel must carry the actor's own cards
55
+ # Same reason as the line above, for the other half of what this package ships. The
56
+ # `-actor` suffix asserts a papeete-actor underneath (ADR-ECO-0022); a wheel published
57
+ # without its cards makes the name a claim the artifact cannot honour, and a published
58
+ # version is not something to discover that from.
59
+ run: |
60
+ /tmp/probe/bin/papeete-actor-synchronous-messaging lint-card \
61
+ "$(/tmp/probe/bin/python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
62
+
63
+ - name: grounding renders a CLAUDE.md whose imports resolve
64
+ # BEFORE the upload, not after. A wheel that ships without its schema, or whose grounding
65
+ # emits an @-import to a file it never wrote, produces sessions grounded in nothing that
66
+ # look identical to correct ones. That is not something to discover from a published
67
+ # version.
68
+ run: /tmp/probe/bin/python scripts/probe_grounding.py examples/ACME.PARTS.CAP.SUP.007.WID-implementation
69
+
70
+ - name: publish to PyPI
71
+ run: uv publish --trusted-publishing always
72
+
73
+ # ── the image, from the same tag and the same checkout ────────────────────────────────────────
74
+ #
75
+ # TWO ARTIFACTS, ONE RELEASE (ADR-FIA-0005). A use pins an image; an embedder pins a wheel; both
76
+ # are this package at this version. A tag that produced one and not the other leaves a consumer
77
+ # unable to take the release at all — so this job builds the wheel again from the same checkout
78
+ # rather than installing the one just uploaded: the image must not depend on PyPI having already
79
+ # indexed it, and must not be able to pick up a different build of the same version.
80
+ #
81
+ # It runs AFTER the wheel is published, not beside it. A failed upload should not leave an image
82
+ # published for a version that does not exist on PyPI; the reverse — a wheel with the image still
83
+ # to come — is recoverable by re-running this job alone.
84
+ image:
85
+ needs: publish
86
+ # `needs` still orders this after the wheel on a tag push. On a manual backfill `publish` is
87
+ # skipped, and a skipped dependency skips its dependents too unless `always()` says otherwise —
88
+ # so the condition has to name both cases rather than relying on `needs` alone.
89
+ if: always() && (needs.publish.result == 'success' || github.event_name == 'workflow_dispatch')
90
+ runs-on: ubuntu-latest
91
+ steps:
92
+ - uses: actions/checkout@v4
93
+ with:
94
+ # Empty on a tag push, which is `checkout`'s own default — the ref that triggered the
95
+ # run. On a manual backfill it is the tag being republished, so every step below reads
96
+ # that tag's own tree rather than whatever the default branch has moved on to.
97
+ ref: ${{ inputs.tag }}
98
+
99
+ - uses: astral-sh/setup-uv@v5
100
+
101
+ - name: the version is the tag
102
+ # The tag is what a consumer writes in a FROM line. A tag that disagrees with
103
+ # pyproject.toml would publish an image whose own `version()` contradicts its name, which
104
+ # is exactly the confusion the CI gate above exists to prevent for the wheel. On a backfill
105
+ # this compares the named tag against the `pyproject.toml` checked out FROM that tag, so a
106
+ # typo in the input is refused rather than published under the wrong name.
107
+ id: version
108
+ env:
109
+ TAG: ${{ inputs.tag || github.ref_name }}
110
+ run: |
111
+ tag="${TAG#v}"
112
+ declared="$(uv run --quiet python -c 'import tomllib,pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
113
+ [ "$tag" = "$declared" ] || { echo "::error::tag $TAG does not match pyproject version $declared"; exit 1; }
114
+ echo "version=$tag" >> "$GITHUB_OUTPUT"
115
+
116
+ - name: build
117
+ run: uv build
118
+
119
+ - uses: docker/login-action@v3
120
+ with:
121
+ registry: ghcr.io
122
+ username: ${{ github.actor }}
123
+ password: ${{ secrets.GITHUB_TOKEN }}
124
+
125
+ - name: the image, tagged and latest
126
+ env:
127
+ VERSION: ${{ steps.version.outputs.version }}
128
+ # `latest` FOLLOWS THE RELEASE STREAM, NOT A BACKFILL. A manual run exists to give an
129
+ # already-released version an image in a registry it missed; run against an older tag it
130
+ # would otherwise drag `latest` backwards onto it, silently, in whichever registry a
131
+ # consumer happens to pull from. The version tag is the whole point of a backfill —
132
+ # `latest` already names something newer, and stays there.
133
+ MOVE_LATEST: ${{ github.event_name == 'push' }}
134
+ run: |
135
+ image="ghcr.io/${{ github.repository }}"
136
+ docker build -f docker/Dockerfile -t "$image:$VERSION" -t "$image:latest" .
137
+ docker push "$image:$VERSION"
138
+ if [ "$MOVE_LATEST" = "true" ]; then docker push "$image:latest"; fi
139
+
140
+ # ── the product's own registry ────────────────────────────────────────────────────────────
141
+ #
142
+ # TWO REGISTRIES, ONE IMAGE. GHCR is where this package publishes; a product that RUNS uses
143
+ # of it pulls from its own registry, which its cluster already authenticates to and which a
144
+ # `FROM` line inside that cluster's builder can reach without a second credential. The base
145
+ # image has to exist in both, because the two are reached by different things: an embedder
146
+ # writing a Dockerfile reads the README and pins GHCR, while a use built by an in-cluster
147
+ # builder resolves its `FROM` against the registry that builder has a push token for.
148
+ #
149
+ # THE SAME IMAGE, NOT ANOTHER BUILD. `docker tag` re-points the build the step above already
150
+ # pushed, so both registries hold one digest. Building a second time would let them diverge —
151
+ # a base-image refresh between two steps of one job is enough — and nothing downstream would
152
+ # ever notice that `:0.5.0` meant two different things depending on where you pulled it.
153
+ #
154
+ # ONE VARIABLE NAMES THE WHOLE TARGET. `vars.PRODUCT_IMAGE` is the full repository path, e.g.
155
+ # `<name>.azurecr.io/<product>/foundry-implementation-actor`; the login server is its first
156
+ # segment. This package is generic across capabilities and names no product of its own — the
157
+ # same rule `tests/test_portability.py` enforces over `src/` — so the product that hosts a
158
+ # registry names itself in a repository variable rather than in this file.
159
+ #
160
+ # UNSET IS A VALID STATE, AND IT SAYS SO. A fork, or this repo before its credentials were
161
+ # added, publishes to GHCR alone; that is a `::notice`, not a failure. Configured-but-broken
162
+ # IS a failure — the push below is not best-effort, because an image the product's cluster
163
+ # cannot pull is the whole reason this block exists.
164
+ - name: is a product registry configured?
165
+ id: product
166
+ env:
167
+ PRODUCT_IMAGE: ${{ vars.PRODUCT_IMAGE }}
168
+ run: |
169
+ if [ -z "$PRODUCT_IMAGE" ]; then
170
+ echo "::notice::PRODUCT_IMAGE is unset — publishing to GHCR only"
171
+ echo "configured=false" >> "$GITHUB_OUTPUT"
172
+ exit 0
173
+ fi
174
+ case "$PRODUCT_IMAGE" in
175
+ */*/*) : ;;
176
+ *) echo "::error::PRODUCT_IMAGE must be <registry>/<path>/<repository>, got $PRODUCT_IMAGE"; exit 1 ;;
177
+ esac
178
+ echo "server=${PRODUCT_IMAGE%%/*}" >> "$GITHUB_OUTPUT"
179
+ echo "configured=true" >> "$GITHUB_OUTPUT"
180
+
181
+ - uses: docker/login-action@v3
182
+ if: steps.product.outputs.configured == 'true'
183
+ with:
184
+ registry: ${{ steps.product.outputs.server }}
185
+ username: ${{ secrets.ACR_PUSH_USERNAME }}
186
+ password: ${{ secrets.ACR_PUSH_PASSWORD }}
187
+
188
+ - name: the same image, in the product's registry
189
+ if: steps.product.outputs.configured == 'true'
190
+ env:
191
+ PRODUCT_IMAGE: ${{ vars.PRODUCT_IMAGE }}
192
+ VERSION: ${{ steps.version.outputs.version }}
193
+ MOVE_LATEST: ${{ github.event_name == 'push' }}
194
+ run: |
195
+ ghcr="ghcr.io/${{ github.repository }}"
196
+ docker tag "$ghcr:$VERSION" "$PRODUCT_IMAGE:$VERSION"
197
+ docker push "$PRODUCT_IMAGE:$VERSION"
198
+ if [ "$MOVE_LATEST" = "true" ]; then
199
+ docker tag "$ghcr:latest" "$PRODUCT_IMAGE:latest"
200
+ docker push "$PRODUCT_IMAGE:latest"
201
+ fi
202
+
203
+ - name: a use built on it is still an actor
204
+ # BEFORE the image is something anyone can pin — the same reason the grounding probe runs
205
+ # before the upload above. An image that builds but produces a use that does not conform is
206
+ # not something to discover from a published tag.
207
+ run: |
208
+ docker build --build-arg ACTOR_IMAGE=ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} \
209
+ -t use:release examples/ACME.PARTS.CAP.SUP.007.WID-implementation
210
+ docker run --rm use:release foundry-implementation-actor lint /actor
@@ -29,7 +29,7 @@ There is no separate lint/format command configured in this repo.
29
29
 
30
30
  ## Architecture
31
31
 
32
- Seven modules under `src/foundry_implementation_actor/`:
32
+ Eight modules under `src/foundry_implementation_actor/`:
33
33
 
34
34
  - **`config.py`** — `CapabilityConfig`. The heart. Loads the sidecar and derives **every**
35
35
  rendering of the capability id from two declared fields (`capability`, `source_repo`). Also
@@ -48,6 +48,10 @@ Seven modules under `src/foundry_implementation_actor/`:
48
48
  the derived wire contract only (door ids, each door's request/completion schema and engine).
49
49
  Prose and `actor.yaml`'s `name:` are deliberately not compared — a use should name its own
50
50
  capability. `lint` runs it beside the sidecar gate.
51
+ - **`settings.py`** — `Settings`. How much a session may spend: turns and seconds, per door, as
52
+ one `field → environment variable` table with a `from_env` that refuses a value it cannot read.
53
+ `serve` fills the engine from it (ADR-FIA-0007). The defaults live here, not in `engine.py`,
54
+ because `engine.py` names the variable when a budget runs out.
51
55
  - **`cli.py`** — argparse wiring only, no logic of its own.
52
56
 
53
57
  Beside them, two folders of committed contract, both shipped in the wheel:
@@ -88,6 +92,15 @@ Beside them, two folders of committed contract, both shipped in the wheel:
88
92
  - **The clone is full, never `--depth 1`.** `papeete_version.compute()` runs `git describe --tags`
89
93
  against it and needs the matching tag's commit reachable.
90
94
  - **The generated `CLAUDE.md` appends** to one the repo already commits. Never overwrite.
95
+ - **The image is one build in two registries.** GHCR is where this package publishes; a product's
96
+ own registry is where the in-cluster builder that resolves a use's `FROM` line can actually
97
+ authenticate (ADR-FIA-0006). `release.yml` `docker tag`s one build into both so they hold one
98
+ digest — never add a second `docker build`, and never hardcode a registry: the product names
99
+ itself in `vars.PRODUCT_IMAGE`, the same reason `src/` names no capability.
100
+ - **A budget is an environment setting, and a door that runs out says what to change.** Turns and
101
+ timeouts are `Settings` fields with an `ENV` entry each, not constructor defaults `serve` never
102
+ passes (ADR-FIA-0007). Never add a knob without an entry in `ENV` and `ENGINE_KWARGS` — and never
103
+ put one in the sidecar: a capability declares what it is, not how long its actor may think.
91
104
  - **Never set `ANTHROPIC_API_KEY` in a container running this.** In `claude -p` non-interactive
92
105
  mode an API key in the environment is always preferred over `CLAUDE_CODE_OAUTH_TOKEN`, silently
93
106
  routing every session through metered billing. There is no warning; the only symptom is the bill.
@@ -104,7 +117,12 @@ similar weight rather than only writing it into code comments.
104
117
  ## Releasing
105
118
 
106
119
  Tag-triggered (`v*`) via `.github/workflows/release.yml`, publishing to PyPI through Trusted
107
- Publishing (OIDC) — no stored token. The release job builds the wheel, installs it into a throwaway
108
- venv, and renders a `CLAUDE.md` from a fixture sidecar, asserting its `@`-imports resolve, before
109
- publishing. `ci.yml` runs the suite plus two gates — *the wheel must carry its contract* and *the
110
- gate must run* — and reaches nothing outside its own checkout.
120
+ Publishing (OIDC) — no stored token. The image goes to GHCR and, when `vars.PRODUCT_IMAGE` names
121
+ one, to a product's own registry as well (ADR-FIA-0006). The workflow also accepts a manual run
122
+ that takes a tag as an INPUT — dispatched from the default branch, because the file comes from the
123
+ dispatched ref and a tag needing a backfill predates the workflow that can do it — to give an
124
+ already-released version an image in a registry it missed; that run skips PyPI and does not move
125
+ `latest`. The release job builds the wheel, installs it into
126
+ a throwaway venv, and renders a `CLAUDE.md` from a fixture sidecar, asserting its `@`-imports
127
+ resolve, before publishing. `ci.yml` runs the suite plus two gates — *the wheel must carry its
128
+ contract* and *the gate must run* — and reaches nothing outside its own checkout.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: foundry-implementation-actor
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Runs a headless Claude Code implementation session against one capability's own repo — a papeete-actor for one use, with the capability supplied by a sidecar.
5
5
  Project-URL: Homepage, https://github.com/papeete-hub/foundry-implementation-actor
6
6
  Author-email: Papeete Consulting <yoann.remy@outlook.com>
@@ -70,7 +70,7 @@ ground_in:
70
70
  and, beside it, a four-line Dockerfile:
71
71
 
72
72
  ```dockerfile
73
- FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.0
73
+ FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.1
74
74
  RUN pip install --no-cache-dir kpack==2.0.1 kontract==0.1.0 # what this sidecar's ground_in names
75
75
  COPY actor-agentic-context.yaml /actor/
76
76
  RUN foundry-implementation-actor render-cards /actor && foundry-implementation-actor lint /actor
@@ -193,7 +193,9 @@ task does not determine this"* is a legitimate answer, and it is the most useful
193
193
 
194
194
  `assess-task` is a **query**, not an action — *"a promise to answer, from this actor's own state
195
195
  and nothing invented."* It clones read-only, grounds itself exactly as the implement door does, and
196
- is invoked with `Read,Glob,Grep` and no `Write`, `Edit` or `Bash`. A door that **cannot** write
196
+ is invoked with `--tools Read,Glob,Grep`, so `Write`, `Edit` and `Bash` are absent from the session
197
+ rather than merely unapproved (0.5.0 passed only `--allowedTools`, which removes nothing — see
198
+ ADR-FIA-0004's amendment). A door that **cannot** write
197
199
  beats a door asked not to. It registers no handler either: with an engine and no handler,
198
200
  `Actor.receive()` returns the judged dict as the reply, and there is nothing to contain because
199
201
  nothing is produced.
@@ -285,6 +287,31 @@ Publishing additionally needs `IMAGE_REGISTRY` and `BUILDKIT_HOST`. There is no
285
287
  no docker socket anywhere in this design — `buildctl` is a client, which is why an actor running
286
288
  this can be an ordinary Pod.
287
289
 
290
+ ## What a session may spend
291
+
292
+ Environment, read once at boot by `serve`; constructor keywords on `Settings` for an embedder. The
293
+ budget is **not** a sidecar field: a capability declares what it is, not how long its actor may
294
+ think (ADR-FIA-0007).
295
+
296
+ | variable | default | what it costs to raise |
297
+ |---|---|---|
298
+ | `MAX_TURNS` | `60` | `implement-task`'s turns. A turn is a model call plus a tool call; raising it buys a slower-to-navigate repo more room, and buys a session that has lost the plot more room to keep losing it |
299
+ | `SESSION_TIMEOUT_S` | `1800` | `implement-task`'s wall clock. The caller's own door timeout has to exceed it, or a slow success arrives as "did not answer" |
300
+ | `ASSESS_MAX_TURNS` | `15` | `assess-task`'s turns. It reads and answers; it cannot write |
301
+ | `ASSESS_TIMEOUT_S` | `600` | `assess-task`'s wall clock. Round 0 blocks on it, before anything is built |
302
+ | `CLONE_TIMEOUT_S` | `120` | the full clone, per door call |
303
+ | `FETCH_TIMEOUT_S` | `120` | each `ground_in` fetch, per door call |
304
+
305
+ **The defaults have not moved** since these became reachable; they are what every use was already
306
+ running. A value that is not a positive integer is refused at boot, naming itself, rather than
307
+ silently falling back — so a raised budget that was misspelt crash-loops with the reason on stdout
308
+ instead of changing nothing. The four session knobs are on the `actor-started` record too, so a run
309
+ that ran out of budget can be read against the budget it actually had.
310
+
311
+ A door that runs out says so in those terms: `implement-task ran out of turns (max_turns=60) and
312
+ was stopped mid-work, so nothing it produced is kept — raise MAX_TURNS on this actor's Deployment,
313
+ or narrow the task.`
314
+
288
315
  ## CLI
289
316
 
290
317
  ```bash
@@ -329,6 +356,32 @@ move, not a rewrite."*
329
356
 
330
357
  `adr/` records the decisions. Design rationale belongs there, not in commit messages.
331
358
 
359
+ ## Releasing, and which registry to pin
360
+
361
+ A tag (`v*`) publishes two artifacts at one version — the wheel to PyPI, and the image to **two
362
+ registries** holding the same digest (ADR-FIA-0006):
363
+
364
+ | Registry | Pin it when |
365
+ |---|---|
366
+ | `ghcr.io/papeete-hub/foundry-implementation-actor` | you are writing a use's Dockerfile yourself, and build it with your own Docker |
367
+ | 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 |
368
+
369
+ The second exists because `buildctl` resolves a `FROM` line client-side against the single registry
370
+ credential it was handed — so a use built by the cluster's shared builder can only reach the
371
+ product's own registry, whatever the README says. The push is skipped, with a notice, when
372
+ `vars.PRODUCT_IMAGE` is unset.
373
+
374
+ To give an already-released version an image in a registry it missed, run the workflow by hand from
375
+ the default branch with the tag as its input — it checks that tag out, and refuses it if it
376
+ disagrees with the `pyproject.toml` beside it:
377
+
378
+ ```bash
379
+ gh workflow run release.yml --ref main -f tag=v0.5.0
380
+ ```
381
+
382
+ That run skips PyPI (which refuses a version it already holds) and does not move `latest` in either
383
+ registry.
384
+
332
385
  ## Development
333
386
 
334
387
  ```bash
@@ -47,7 +47,7 @@ ground_in:
47
47
  and, beside it, a four-line Dockerfile:
48
48
 
49
49
  ```dockerfile
50
- FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.0
50
+ FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.1
51
51
  RUN pip install --no-cache-dir kpack==2.0.1 kontract==0.1.0 # what this sidecar's ground_in names
52
52
  COPY actor-agentic-context.yaml /actor/
53
53
  RUN foundry-implementation-actor render-cards /actor && foundry-implementation-actor lint /actor
@@ -170,7 +170,9 @@ task does not determine this"* is a legitimate answer, and it is the most useful
170
170
 
171
171
  `assess-task` is a **query**, not an action — *"a promise to answer, from this actor's own state
172
172
  and nothing invented."* It clones read-only, grounds itself exactly as the implement door does, and
173
- is invoked with `Read,Glob,Grep` and no `Write`, `Edit` or `Bash`. A door that **cannot** write
173
+ is invoked with `--tools Read,Glob,Grep`, so `Write`, `Edit` and `Bash` are absent from the session
174
+ rather than merely unapproved (0.5.0 passed only `--allowedTools`, which removes nothing — see
175
+ ADR-FIA-0004's amendment). A door that **cannot** write
174
176
  beats a door asked not to. It registers no handler either: with an engine and no handler,
175
177
  `Actor.receive()` returns the judged dict as the reply, and there is nothing to contain because
176
178
  nothing is produced.
@@ -262,6 +264,31 @@ Publishing additionally needs `IMAGE_REGISTRY` and `BUILDKIT_HOST`. There is no
262
264
  no docker socket anywhere in this design — `buildctl` is a client, which is why an actor running
263
265
  this can be an ordinary Pod.
264
266
 
267
+ ## What a session may spend
268
+
269
+ Environment, read once at boot by `serve`; constructor keywords on `Settings` for an embedder. The
270
+ budget is **not** a sidecar field: a capability declares what it is, not how long its actor may
271
+ think (ADR-FIA-0007).
272
+
273
+ | variable | default | what it costs to raise |
274
+ |---|---|---|
275
+ | `MAX_TURNS` | `60` | `implement-task`'s turns. A turn is a model call plus a tool call; raising it buys a slower-to-navigate repo more room, and buys a session that has lost the plot more room to keep losing it |
276
+ | `SESSION_TIMEOUT_S` | `1800` | `implement-task`'s wall clock. The caller's own door timeout has to exceed it, or a slow success arrives as "did not answer" |
277
+ | `ASSESS_MAX_TURNS` | `15` | `assess-task`'s turns. It reads and answers; it cannot write |
278
+ | `ASSESS_TIMEOUT_S` | `600` | `assess-task`'s wall clock. Round 0 blocks on it, before anything is built |
279
+ | `CLONE_TIMEOUT_S` | `120` | the full clone, per door call |
280
+ | `FETCH_TIMEOUT_S` | `120` | each `ground_in` fetch, per door call |
281
+
282
+ **The defaults have not moved** since these became reachable; they are what every use was already
283
+ running. A value that is not a positive integer is refused at boot, naming itself, rather than
284
+ silently falling back — so a raised budget that was misspelt crash-loops with the reason on stdout
285
+ instead of changing nothing. The four session knobs are on the `actor-started` record too, so a run
286
+ that ran out of budget can be read against the budget it actually had.
287
+
288
+ A door that runs out says so in those terms: `implement-task ran out of turns (max_turns=60) and
289
+ was stopped mid-work, so nothing it produced is kept — raise MAX_TURNS on this actor's Deployment,
290
+ or narrow the task.`
291
+
265
292
  ## CLI
266
293
 
267
294
  ```bash
@@ -306,6 +333,32 @@ move, not a rewrite."*
306
333
 
307
334
  `adr/` records the decisions. Design rationale belongs there, not in commit messages.
308
335
 
336
+ ## Releasing, and which registry to pin
337
+
338
+ A tag (`v*`) publishes two artifacts at one version — the wheel to PyPI, and the image to **two
339
+ registries** holding the same digest (ADR-FIA-0006):
340
+
341
+ | Registry | Pin it when |
342
+ |---|---|
343
+ | `ghcr.io/papeete-hub/foundry-implementation-actor` | you are writing a use's Dockerfile yourself, and build it with your own Docker |
344
+ | 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 |
345
+
346
+ The second exists because `buildctl` resolves a `FROM` line client-side against the single registry
347
+ credential it was handed — so a use built by the cluster's shared builder can only reach the
348
+ product's own registry, whatever the README says. The push is skipped, with a notice, when
349
+ `vars.PRODUCT_IMAGE` is unset.
350
+
351
+ To give an already-released version an image in a registry it missed, run the workflow by hand from
352
+ the default branch with the tag as its input — it checks that tag out, and refuses it if it
353
+ disagrees with the `pyproject.toml` beside it:
354
+
355
+ ```bash
356
+ gh workflow run release.yml --ref main -f tag=v0.5.0
357
+ ```
358
+
359
+ That run skips PyPI (which refuses a version it already holds) and does not move `latest` in either
360
+ registry.
361
+
309
362
  ## Development
310
363
 
311
364
  ```bash
@@ -69,6 +69,13 @@ with `Read,Glob,Grep` and no `Write`, `Edit` or `Bash`. **A door that cannot wri
69
69
  asked not to** — the same discipline as `handler.py`'s containment check standing behind the
70
70
  implement door's write boundary.
71
71
 
72
+ *Amended in 0.5.1, after the first live run.* Up to 0.5.0 that list was passed only as
73
+ `--allowedTools`, which PRE-APPROVES the tools it names and removes nothing: a live assess session
74
+ ran Bash three times. The door was asked not to write after all. It is now also passed as
75
+ `--tools`, which is what takes every other built-in out of the session, and a test pins both flags.
76
+ The same run showed commitments coming back as prose strings ("E1: …", "E4/E5: …") that nothing
77
+ could attach to an expectation, so the prompt now requires one `{id, commitment}` object per entry.
78
+
72
79
  **4. `acceptance_surface` is an OPTIONAL payload field on `implement-task`.** The door works
73
80
  exactly as before without it. The DoD is what the caller asked for; the surface is what dev and
74
81
  test agreed it means, and where the surface is more specific, it wins.
@@ -124,9 +131,11 @@ tuning, and it is a constructor keyword.
124
131
  - **Expectation ids are worth more than this round.** Because each carries a stable `id`, a failing
125
132
  verdict can name agreed expectations instead of scraped pytest lines, and `remediation_context`
126
133
  can stop being free prose. Neither is done here.
127
- - **The other two halves are specified, not built.** The testing actor's `propose-acceptance` and
128
- the orchestrating actor's round 0 live in instance repos that are not yet `foundry-*` packages.
129
- The round belongs after that actor unpacks its payload and before its attempt loop begins.
134
+ - **The other two halves are specified here and built elsewhere.** The testing actor's
135
+ `propose-acceptance` is `foundry-testing-actor` (ADR-FTA-0002), and the orchestrating actor's
136
+ round 0 is `foundry-task-orchestration-actor` (ADR-FTOA-0002), both extracted from their
137
+ instance repos the way ADR-FIA-0005 extracted this one. The round runs after that actor unpacks
138
+ its payload and before its attempt loop begins.
130
139
  - **Not decided here:** whether the agreed surface should also be committed somewhere durable
131
140
  rather than only passed between doors, and whether `definition_of_done` should eventually be
132
141
  absorbed into it rather than sitting beside it.
@@ -0,0 +1,104 @@
1
+ ---
2
+ id: ADR-FIA-0006
3
+ title: "The image is published to two registries, and names neither in its source"
4
+ status: Proposed
5
+ date: 2026-09-11
6
+ supersedes: []
7
+ references:
8
+ - ../.github/workflows/release.yml
9
+ - ADR-FIA-0005-the-actor-ships-an-image.md
10
+ ---
11
+
12
+ # ADR-FIA-0006 — The image is published to two registries, and names neither in its source
13
+
14
+ ## Context
15
+
16
+ ADR-FIA-0005 made the base image half of what a release is: a use is one sidecar and a `FROM`
17
+ line, so the image is not a convenience beside the wheel, it is the thing a use is built out of.
18
+ The first release to carry one (0.5.0) published it to GHCR, because that is the registry a
19
+ GitHub-hosted package gets for free and the only one the workflow had a credential for.
20
+
21
+ That is the wrong registry for the consumer that actually exists. A use is built **inside a
22
+ cluster**, by the shared rootless builder `papeete-platform`'s `modules/buildkit` installs, which
23
+ resolves a `FROM` line client-side against the registry it holds a push token for — an Azure
24
+ Container Registry, provisioned by that repo's `modules/acr` with scope maps over the product's own
25
+ repository prefixes. `buildctl` has exactly one registry credential, mounted as its
26
+ `$DOCKER_CONFIG/config.json`, and it is not a GHCR one. So a `FROM ghcr.io/...` line inside that
27
+ builder resolves against a registry it cannot authenticate to, and the failure is a pull error at
28
+ build time rather than anything a gate here would catch.
29
+
30
+ Meanwhile GHCR is the right registry for the other consumer: someone reading this README and
31
+ writing a Dockerfile on their laptop, who has a GitHub account and no relationship with any
32
+ product's Azure subscription.
33
+
34
+ Two consumers, reached by two different credentials. The image has to be in both.
35
+
36
+ ## Decision
37
+
38
+ The release workflow's `image` job pushes **one build to two registries**:
39
+
40
+ - **GHCR**, unconditionally, at `ghcr.io/<this repo>` — unchanged.
41
+ - **A product's own registry**, when `vars.PRODUCT_IMAGE` names one. The variable is the full
42
+ repository path (`<name>.azurecr.io/<product>/foundry-implementation-actor`); the login server
43
+ is its first segment; `secrets.ACR_PUSH_USERNAME` / `ACR_PUSH_PASSWORD` are the scope-mapped push
44
+ token `modules/acr` already emits as outputs.
45
+
46
+ `docker tag` re-points the build GHCR already holds, so both registries carry **one digest**. The
47
+ job does not build twice.
48
+
49
+ `vars.PRODUCT_IMAGE` unset is a valid state: a fork, or this repo before its credentials existed,
50
+ publishes to GHCR alone and emits a `::notice` saying so. Set-but-broken is a hard failure.
51
+
52
+ The workflow also gains `workflow_dispatch`, so a version whose wheel is already on PyPI can be
53
+ given an image in a registry it missed without inventing a version number. It **takes the tag as an
54
+ input** and is run from the default branch: GitHub takes the workflow file from the ref it is
55
+ dispatched on, and a tag old enough to need a backfill is by definition older than the workflow
56
+ that can perform one — so the checkout takes its content from the named tag while the file comes
57
+ from the branch. On a manual run the `publish` job is skipped — PyPI refuses a version it already
58
+ holds — and `latest` is **not** moved in either registry, so backfilling an old tag cannot drag it
59
+ backwards.
60
+
61
+ ## Rationale
62
+
63
+ **Why a variable and not a literal.** This package is generic across capabilities and carries no
64
+ product's name; `tests/test_portability.py` enforces exactly that over `src/`, and a product name
65
+ hardcoded in `release.yml` would be the same mistake one directory over. A product that hosts a
66
+ registry names itself in its own repository variable. It also makes "no product registry
67
+ configured" expressible rather than a broken default.
68
+
69
+ **Why one build rather than two.** Two `docker build` invocations in one job can produce two
70
+ different images — a base-image tag refreshed between them is enough — and nothing downstream
71
+ would ever notice that `:0.5.0` meant different bytes depending on where it was pulled from. That
72
+ is the same failure the existing gate *the image and the wheel are one release* exists to prevent,
73
+ one level up.
74
+
75
+ **Why not mirror instead.** ACR can import from another registry (`az acr import`), which would
76
+ keep one publishing path. It needs a credential for the source, which means making the GHCR package
77
+ public or minting a PAT for it, and it puts a second system between the tag and the image. Pushing
78
+ the build we already have in hand is fewer moving parts.
79
+
80
+ **Why this repo decides this at all.** Where an ecosystem's artifacts live is an `ADR-ECO-*`
81
+ question, and there is no ecosystem ADR about registries today. What this ADR owns is narrower and
82
+ squarely this repo's: that a release publishes to more than one place, and that this package names
83
+ none of them in its own source.
84
+
85
+ ## Consequences
86
+
87
+ - **Two secrets and one variable must exist** on this repo before the product push does anything:
88
+ `PRODUCT_IMAGE`, `ACR_PUSH_USERNAME`, `ACR_PUSH_PASSWORD`. The credentials come from
89
+ `terraform output` on `papeete-platform`'s `examples/acr-local`, and they are set by a human —
90
+ `papeete-foundry-product/GetSecrets.sh` is TTY-only by design so no credential passes through an
91
+ assistant's context, and that convention holds here.
92
+ - **0.5.0 shipped to GHCR only.** Its image exists at
93
+ `ghcr.io/papeete-hub/foundry-implementation-actor:0.5.0` and nowhere else until the manual run
94
+ above is used to backfill it.
95
+ - **The token's scope map must admit the repository.** `modules/acr`'s `repository_patterns` is the
96
+ caller's input, and a path outside it is refused at push time with a permissions error rather
97
+ than at configuration time. Widening it is a `papeete-platform` change, not one here.
98
+ - **A rotated push token silently stops releases.** The token password is created by Terraform with
99
+ no expiry by default; when that changes, the secret here has to change with it. Nothing in this
100
+ repo can detect the drift — the first symptom is a failed release job.
101
+ - **Still to realize:** a use's `FROM` line points at GHCR today
102
+ (`examples/…/Dockerfile`, and the real use in `papeete-foundry`). Pointing it at the product
103
+ registry is what makes the in-cluster builder work, and it belongs in those repos rather than
104
+ here — this ADR only makes the image available to be pointed at.