feat(5): overleveringspakka er KUN kjørbar Python — container-innpakningen fjernet

Operatørdirektiv 14.08 etter ekstern test: mottakeren skal få kjørbar Python, ikke en
Docker-innpakking. `Dockerfile` og `azure.yaml` er SLETTET fra treet.

Sømmen er valgt av den eksisterende invarianten, ikke av smak: pakka ER `git archive HEAD`,
så å ekskludere filene fra arkivet ville krevd en kurerings-mekanisme — den andre kopien av
«hva mottakeren får», fri til å drifte fra HEAD (kø-(p)). Fjerning holder arkivet ukurert og
gjør fraværet til en egenskap ved HEAD, som er det eneste en gate kan måle.

De to gatene som pinnet flaten er håndtert bevisst:
- 4e-rå-tekst-gaten (`--platform linux/amd64` + ÉN kopi av startkommandoen) er SLETTET, med
  et notat der den sto. En gate som pinner en fjernet flate kan bare bli grønn.
- handover-gatens `_REQUIRED_MEMBERS` er ikke bare fratatt de to navnene, men erstattet av en
  POSITIV fraværs-assert pluss en dokument-gate. Å kun slutte å KREVE dem ville gitt en gate
  som ikke kan skille «fjernet» fra «shippes fortsatt».

Startkommandoen har nå ÉN kopi igjen: DEPLOY.md-ens `python main.py`, som navngir inngangen
subprosess-testen faktisk kjører.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 14:09:35 +02:00
commit 605c02aa62
10 changed files with 215 additions and 205 deletions

View file

@ -191,15 +191,12 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
subklasser den) → 400, alt annet → 500 `{error_type, error}` (speiler `RunFailure`), og en subklasser den) → 400, alt annet → 500 `{error_type, error}` (speiler `RunFailure`), og en
`Rejection` er en VELLYKKET kjøring → 200 — det negative utfallet tilhører payloaden, aldri `Rejection` er en VELLYKKET kjøring → 200 — det negative utfallet tilhører payloaden, aldri
transporten. `outbox.outcome_payload` er den ENE kopien av validated/rejected-forgreningen transporten. `outbox.outcome_payload` er den ENE kopien av validated/rejected-forgreningen
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). `azure.yaml` (delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). **Container-innpakningen
validert GRØNN mot begge autoritative skjemaer; ingen `env:` (redeklarer aldri (`Dockerfile`/`azure.yaml`) ER FJERNET 14.08** — se python-only-invarianten under; resten av
`FOUNDRY_PROJECT_ENDPOINT`), ingen `startupCommand` (imagets `CMD` er den ene kopien av denne raden står, for `main.py` startes nå direkte (`python main.py`). Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), seks mutasjoner alle
startkommandoen). `git archive <tree> | docker build --platform linux/amd64 -` grønn på
indeks-treet. Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), seks mutasjoner alle
røde mot hele suiten på riktig test: detach felt-mappingen · dropp ukjente felt stille · flipp røde mot hele suiten på riktig test: detach felt-mappingen · dropp ukjente felt stille · flipp
400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach main.py-shimen (de to 400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach main.py-shimen (de to
siste fanges KUN av subprosess-testen — P4-presedensen). Deploy er IKKE utført (azd-steget er siste fanges KUN av subprosess-testen — P4-presedensen). Chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
operatørens); chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
står readiness — uttalt, ikke skjult. står readiness — uttalt, ikke skjult.
- **Whitelisten må komponere med den EKTE `run_project`, og artefaktene gates som RÅ TEKST - **Whitelisten må komponere med den EKTE `run_project`, og artefaktene gates som RÅ TEKST
(Fase 4e):** alle 4d-testene ga `invoke` en stand-in som sluker `**kwargs`, så whitelisten kunne (Fase 4e):** alle 4d-testene ga `invoke` en stand-in som sluker `**kwargs`, så whitelisten kunne
@ -213,16 +210,12 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
et Foundry-deployment-navn i modell-mappet FØR noen klient bygges (`run.py` stempler provenance et Foundry-deployment-navn i modell-mappet FØR noen klient bygges (`run.py` stempler provenance
med det), så den kan ikke fullføre offline — **containeren trenger altså `PORTFOLIO_MODEL_MAP` med det), så den kan ikke fullføre offline — **containeren trenger altså `PORTFOLIO_MODEL_MAP`
eller et utfylt `data/model_map.json`, ikke bare et endepunkt** (målt her, ikke antatt). eller et utfylt `data/model_map.json`, ikke bare et endepunkt** (målt her, ikke antatt).
`Dockerfile`/`azure.yaml` KJØRES av ingen test (`docker build`/`azd deploy` er operatør-gatet), så **Artefakt-halvdelen av denne raden er PENSJONERT 14.08** sammen med `Dockerfile`/`azure.yaml`
rå-tekst er eneste tilgjengelige gate: `--platform linux/amd64` (målt påkrevd, spike §1.4 — uten (rå-tekst-gaten pinnet `--platform linux/amd64` + ÉN kopi av startkommandoen; to av radens fem
det arver imaget byggerens arkitektur og bygger grønt lokalt mens det ikke kan starte i skyen) + mutasjoner traff nettopp den). Whitelist-halvdelen står URØRT. Load-bearing MÅLT
ÉN kopi av startkommandoen (imagets `CMD` navngir `main.py`, `azure.yaml` har ingen (`tests/test_hosting_loadbearing.py`), de tre gjenværende mutasjonene alle røde på riktig test og
`startupCommand`). **Nøkkel-sjekkene er LINJEFORANKRET, ikke delstreng:** `azure.yaml`s egen på INGEN annen: send `project_id` to ganger · whitelist et felt `run_project` ikke tar · fjern
kommentar NAVNGIR `startupCommand` og `env` for å begrunne fraværet, så en substring-gate ville `bundle_dir` fra whitelisten.
vært rød på prosaen den beskytter. Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), fem
mutasjoner alle røde på riktig test og på INGEN annen (836 øvrige grønne hver gang): send
`project_id` to ganger · whitelist et felt `run_project` ikke tar · fjern `bundle_dir` fra
whitelisten · fjern `--platform linux/amd64` · gi `azure.yaml` en `startupCommand`-nøkkel.
- **Påstander flaten gjør om SEG SELV gates som rå tekst, linjeforankret (Fase 3, A5):** to påstander - **Påstander flaten gjør om SEG SELV gates som rå tekst, linjeforankret (Fase 3, A5):** to påstander
bodde i prosa der ingen test kunne se dem, og begge drev. (1) `env.template` sa at credential bodde i prosa der ingen test kunne se dem, og begge drev. (1) `env.template` sa at credential
resolves via `DefaultAzureCredential` — den har ALDRI gjort det; gaten leser de klassene resolves via `DefaultAzureCredential` — den har ALDRI gjort det; gaten leser de klassene
@ -597,9 +590,8 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
`scripts/make-handover-package.sh` bygger én zip en ekstern organisasjon deployer uten å klone `scripts/make-handover-package.sh` bygger én zip en ekstern organisasjon deployer uten å klone
repoet. **Tracked files only er hele eksponerings-kontrollen**`STATE.md`, `*.local.md` og repoet. **Tracked files only er hele eksponerings-kontrollen**`STATE.md`, `*.local.md` og
`.env` er gitignorert, så de KAN ikke komme inn; et filter vedlikeholdt i skriptet ville vært den `.env` er gitignorert, så de KAN ikke komme inn; et filter vedlikeholdt i skriptet ville vært den
andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Det er dessuten andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Mottakeren får altså
SAMME tre som den målte docker-build-konteksten (Dockerfile-headeren), så mottakeren deployer det HEAD selv. Versjonen LESES fra `pyproject.toml` — et hardkodet tall her ville råtnet ved neste bump
vi målte. Versjonen LESES fra `pyproject.toml` — et hardkodet tall her ville råtnet ved neste bump
nøyaktig som README-ens wheel-filnavn gjorde (Fase 3). `DEPLOY.md` ligger i treet og blir dermed nøyaktig som README-ens wheel-filnavn gjorde (Fase 3). `DEPLOY.md` ligger i treet og blir dermed
med i arkivet av seg selv; den bærer mottakerens tre første spørsmål — hvem gjør hva med i arkivet av seg selv; den bærer mottakerens tre første spørsmål — hvem gjør hva
(plattform-operatør / bestiller / fagperson), prosessen ende-til-ende, og **hvorfor det ikke (plattform-operatør / bestiller / fagperson), prosessen ende-til-ende, og **hvorfor det ikke
@ -607,11 +599,31 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
fordi validator, baseline-forankring, checker-gate og ledger ligger UTENFOR grafen — et chat-lag fordi validator, baseline-forankring, checker-gate og ledger ligger UTENFOR grafen — et chat-lag
ville rutet forespørsler rundt nøyaktig det som gjør svaret etterprøvbart). Den navngir også det ville rutet forespørsler rundt nøyaktig det som gjør svaret etterprøvbart). Den navngir også det
4e målte deploy-kravet som ingen rad hadde skrevet ned: pakket `model_map.json` bærer 4e målte deploy-kravet som ingen rad hadde skrevet ned: pakket `model_map.json` bærer
`REPLACE-WITH-*`, så uten `PORTFOLIO_MODEL_MAP` starter containeren, svarer på `/readiness` og `REPLACE-WITH-*`, så uten `PORTFOLIO_MODEL_MAP` starter tjenesten, svarer på `/readiness` og
feiler HVER invocation. Gaten er `tests/test_handover_package_loadbearing.py`, og feiler HVER invocation. Gaten er `tests/test_handover_package_loadbearing.py`, og
DEPLOY.md-asserten er LINJEFORANKRET: `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER DEPLOY.md-asserten er LINJEFORANKRET: `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER
`FOUNDRY_PROJECT_ENDPOINT`, så en delstreng-assert på det injiserte navnet ville vært oppfylt av `FOUNDRY_PROJECT_ENDPOINT`, så en delstreng-assert på det injiserte navnet ville vært oppfylt av
vårt eget (repoets 08-09-klasse, femte gang). vårt eget (repoets 08-09-klasse, femte gang).
- **Overleveringen er KUN kjørbar Python, og fraværet er FJERNING — ikke filtrering (14.08,
operatørdirektiv etter ekstern test):** `Dockerfile` og `azure.yaml` er slettet fra TREET.
**Sømmen er valgt av den eksisterende invarianten, ikke av smak:** pakka ER `git archive HEAD`, så
å ekskludere filene fra arkivet ville krevd en kurerings-mekanisme (skript-filter eller
`export-ignore`) — den andre kopien av «hva mottakeren får», fri til å drifte fra HEAD, altså
nøyaktig kø-(p)-regelen raden over finnes for. Å beholde dem som «opt-in» ville ikke oppfylt
direktivet i det hele tatt. Fjerning holder arkivet ukurert OG gjør fraværet til en egenskap ved
HEAD, som er det eneste en gate kan måle. **De to gatene som pinnet flaten er håndtert BEVISST,
aldri stille svekket:** 4e-rå-tekst-gaten (`--platform linux/amd64` + ÉN kopi av startkommandoen)
er SLETTET med et notat der den sto — en gate som pinner en fjernet flate kan bare bli grønn — og
handover-gatens `_REQUIRED_MEMBERS` er ikke bare fratatt de to navnene, men erstattet av en
POSITIV fraværs-assert; å kun slutte å KREVE dem ville gitt en gate som ikke kan skille «fjernet»
fra «shippes fortsatt». Matchingen skjer på arkiv-MEDLEMSNAVN, ikke på prosa (dokumentene må
kunne forklare at ingen image shippes — repoets 08-09-klasse, sjette gang), og dokument-gaten
forbyr kommando-FRAGMENTER (`docker build`, `azd deploy`), ikke ordet. **Startkommandoen har nå
ÉN kopi igjen — DEPLOY.md-ens `python main.py`** — og den navngir inngangen subprosess-testen
faktisk kjører. **Ærlighets-grense, uttalt:** azd/hosted-agent-stien finnes ikke lenger i pakka;
hvordan prosessen driftes er mottakerens valg. `git archive HEAD` leser HEAD, ikke arbeidstreet,
så gaten er ekte men forsinket med én commit (funn 35). Load-bearing MÅLT
(`tests/test_handover_package_loadbearing.py`).
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet. - **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase. - Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.

View file

@ -41,7 +41,7 @@ The package has three distinct audiences. Only the first one is technical.
| Role | What they do | Where it is documented | | Role | What they do | Where it is documented |
|---|---|---| |---|---|---|
| **Platform operator** | Deploys the container into Microsoft Foundry, sets two environment variables, verifies with the offline preflight. One-time work. | This document, sections 35. | | **Platform operator** | Installs the package, sets two environment variables, starts the service, verifies with the offline preflight. One-time work. | This document, sections 36. |
| **Ordering domain expert** (*bestiller*) | Writes the mandate file: the objective, which approaches are worth trying, and why. The *why* is the part the system cannot read out of cost figures. | `docs/bestille-en-kjoring.md` | | **Ordering domain expert** (*bestiller*) | Writes the mandate file: the objective, which approaches are worth trying, and why. The *why* is the part the system cannot read out of cost figures. | `docs/bestille-en-kjoring.md` |
| **Reviewing domain expert** (*fagperson*) | Receives a validated proposal and answers `approved` or `rejected` with a written rationale. That verdict is what the system learns from. | `docs/ekspert-svar.md` | | **Reviewing domain expert** (*fagperson*) | Receives a validated proposal and answers `approved` or `rejected` with a written rationale. That verdict is what the system learns from. | `docs/ekspert-svar.md` |
@ -59,7 +59,7 @@ back with its reason. That property is the point of the whole design; do not bui
your own. your own.
2. **Order a run** with a mandate file (optional but recommended). See 2. **Order a run** with a mandate file (optional but recommended). See
`docs/bestille-en-kjoring.md`. `docs/bestille-en-kjoring.md`.
3. **Run it** — from the CLI, or by `POST /invocations` against the deployed container (section 5). 3. **Run it** — from the CLI, or by `POST /invocations` against the running service (section 6).
The run navigates the knowledge base, debates candidate measures, validates the numbers against The run navigates the knowledge base, debates candidate measures, validates the numbers against
the project's actual cost baseline, and returns either a **validated proposal** or a the project's actual cost baseline, and returns either a **validated proposal** or a
**rejection with a reason**. A rejection is a successful run: the negative outcome belongs to the **rejection with a reason**. A rejection is a successful run: the negative outcome belongs to the
@ -99,8 +99,8 @@ PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT # yours; wins if set
FOUNDRY_PROJECT_ENDPOINT # injected by the Foundry hosting platform FOUNDRY_PROJECT_ENDPOINT # injected by the Foundry hosting platform
``` ```
Inside a hosted Foundry container the platform injects the second one and you need to set nothing. Where something injects the second one for you — the Foundry hosting platform does — you need to
Outside it — a laptop, a pipeline, a container you run yourself — set the first. The value looks set nothing. Everywhere else (a laptop, a pipeline, your own runtime) set the first. The value looks
like `https://<resource>.services.ai.azure.com/api/projects/<project>`. like `https://<resource>.services.ai.azure.com/api/projects/<project>`.
**5.2 The model map — this is the one that catches people.** The packaged **5.2 The model map — this is the one that catches people.** The packaged
@ -125,7 +125,7 @@ own file:
export PORTFOLIO_MODEL_MAP=/path/to/model_map.json export PORTFOLIO_MODEL_MAP=/path/to/model_map.json
``` ```
If you skip this, the container starts and answers `/readiness` — and fails every invocation. That If you skip this, the service starts and answers `/readiness` — and fails every invocation. That
is the single most likely first-deployment failure, which is why it has its own section. is the single most likely first-deployment failure, which is why it has its own section.
**5.3 Verify before you pay for a model call:** **5.3 Verify before you pay for a model call:**
@ -140,24 +140,32 @@ no placeholders left. It is **necessary but not sufficient** — RBAC (403), tok
--- ---
## 6. Deploying ## 6. Running it
The image must be built for x86_64 regardless of your workstation's architecture: **This package is Python and nothing else.** There is no image to build and no platform-specific
deployment manifest — that was removed deliberately after an external trial, because it forced a
container toolchain on receivers who only needed to run the code. How the process is supervised,
packaged or placed in your environment is your decision, and this package does not pre-empt it.
Install the locked environment and start the service:
```bash ```bash
git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser - uv sync --frozen --no-dev
uv run python main.py
``` ```
If you unzipped this package rather than cloning it, build from the directory instead: `uv sync --frozen` installs the exact resolution every measurement in this repository ran against —
never a fresh resolve, which picks other versions. Two dependencies are git-tag-pinned direct
references, so **`git` must be on PATH** for the install to work.
`main.py` serves port 8088 (override with `PORT`), answers `GET /readiness`, accepts
`POST /invocations`, and exits cleanly on SIGTERM. For a single run without the service, the CLI is
the same code path:
```bash ```bash
docker build --platform linux/amd64 -t portfolio-optimiser . uv run portfolio-optimiser --help
``` ```
`azure.yaml` describes the hosted-agent deployment (`azd`). The container serves port 8088 (override
with `PORT`), answers `GET /readiness`, accepts `POST /invocations`, and shuts down cleanly on
SIGTERM.
**The invocations payload** is whitelisted against the run signature — unknown fields are refused by **The invocations payload** is whitelisted against the run signature — unknown fields are refused by
name with HTTP 400, never silently ignored: name with HTTP 400, never silently ignored:
@ -167,7 +175,7 @@ name with HTTP 400, never silently ignored:
| `docs_dir` | yes | Source documents. | | `docs_dir` | yes | Source documents. |
| `verdict_input` | yes | The expert verdict for this run. | | `verdict_input` | yes | The expert verdict for this run. |
| `bundle_dir` | no | Knowledge base to navigate. | | `bundle_dir` | no | Knowledge base to navigate. |
| `profile` | no | Defaults to `azure` in the hosted container. | | `profile` | no | Defaults to `azure` on this HTTP surface. |
| `max_rounds` | no | Debate round cap. | | `max_rounds` | no | Debate round cap. |
| `max_tokens` | no | **Per-invocation token ceiling.** Set it. The built-in default is 100 000 tokens per run, which is a ceiling nobody chose for your workload. | | `max_tokens` | no | **Per-invocation token ceiling.** Set it. The built-in default is 100 000 tokens per run, which is a ceiling nobody chose for your workload. |
| `top_k` | no | Retrieval breadth. | | `top_k` | no | Retrieval breadth. |
@ -180,7 +188,7 @@ HTTP 400 means the request was malformed; 500 means the run itself failed, with
## 7. Honest limits ## 7. Honest limits
- **This framework has never been run against a live model in our environment.** Everything above is - **This framework has never been run against a live model in our environment.** Everything above is
measured against tests, an offline simulation and container builds. The first live run in *your* measured against tests and an offline simulation. The first live run in *your*
tenant is the first live run, full stop. tenant is the first live run, full stop.
- The system is a **technical framework**. Data protection assessments, lawful basis and risk - The system is a **technical framework**. Data protection assessments, lawful basis and risk
ownership belong to whoever deploys it — see `README.md` and `SECURITY.md`. ownership belong to whoever deploys it — see `README.md` and `SECURITY.md`.

View file

@ -1,35 +0,0 @@
# Hosted-agent container (Fase 4d). Build context is `git archive HEAD` — what a stranger
# actually downloads, never the working tree — and the hosting platform requires x86_64
# (spike §1.4), so the measured build command is:
#
# git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -
#
# Base: the version pinned by .python-version (3.12), mid-band of the resolution matrix
# (3.103.13 have numpy wheels, 3.14 does not — spike §4), and the same base Microsoft's
# own MAF sample uses. Runs as root like that sample: no non-root requirement is
# documented (spike, Ikke verifisert pkt. 7).
FROM python:3.12-slim-bookworm
# git: the two security components are git-tag-pinned direct references, and
# [tool.uv.sources] never reaches wheel metadata (spike §3/§7.2) — uv needs the git CLI
# to fetch them, and -slim ships without it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
# Pinned to the uv the tree was measured with (13.08) — an unpinned installer would let
# the container's resolver drift from the working tree's.
RUN pip install --no-cache-dir uv==0.9.8
WORKDIR /app
COPY . .
# The LOCKED, dev-free environment — the same resolution the working tree was measured
# with (agent-framework-core 1.9.0). Never a fresh resolve from pyproject.toml: that
# picks other versions (1.13.0) than every measurement in this repo ran against (§3).
RUN uv sync --frozen --no-dev
# The hosted-agent runtime contract: port 8088, GET /readiness, POST /invocations,
# SIGTERM shutdown (spike §1.1) — served by main.py (portfolio_optimiser.hosting).
EXPOSE 8088
CMD ["/app/.venv/bin/python", "main.py"]

View file

@ -459,35 +459,38 @@ only an expert has:
- **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching - **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching
frameworks does not orphan the organization's curated knowledge. frameworks does not orphan the organization's curated knowledge.
## Hosted on Foundry Agent Service ## The task API — runnable Python, no wrapper
The repo ships the three files a hosted-agent deployment needs. All three are measured — `main.py` is the ONE process entry. It serves the hosted-agent runtime contract (port 8088 /
schema-validated and container-built — but an actual `azd` deploy has **not** been run; that `PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from
step is the operator's. `portfolio_optimiser.hosting`, wrapping `run_project` — never `Workflow.as_agent()`, which
would bypass the deterministic validator and the rest of the gate chain. No protocol library
is used: the prerelease `agent-framework-foundry-hosting` requires
`agent-framework-core>=1.13.0` while this tree locks 1.9.0, so the contract is a small
stdlib-asyncio server instead (single event loop, no threads — the same concurrency model as
the portfolio waves).
- `main.py` — the ONE process entry. It serves the hosted-agent runtime contract (port 8088 / ```bash
`PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from uv sync --frozen --no-dev # the exact locked resolution every measurement ran against
`portfolio_optimiser.hosting`, wrapping `run_project` — never `Workflow.as_agent()`, which uv run python main.py # serves the task API
would bypass the deterministic validator and the rest of the gate chain. No protocol library ```
is used: the prerelease `agent-framework-foundry-hosting` requires
`agent-framework-core>=1.13.0` while this tree locks 1.9.0, so the contract is a small `git` must be on PATH for the install: two dependencies are git-tag-pinned direct references,
stdlib-asyncio server instead (single event loop, no threads — the same concurrency model as and wheel metadata alone cannot fetch them.
the portfolio waves).
- `Dockerfile``python:3.12-slim-bookworm` + git (the two security components are A `Dockerfile` and an `azure.yaml` shipped here until **14 August 2026** and were removed on an
git-tag-pinned direct references; wheel metadata alone cannot install them) + operator directive after an external trial: what is delivered is runnable Python, and how the
`uv sync --frozen --no-dev` (the exact locked resolution every measurement ran against). process is containerised, supervised or deployed belongs to whoever runs it. Git history keeps
Build it the way a stranger would: both files. The raw-text gate that pinned them (`--platform linux/amd64`, one copy of the start
`git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -` command) was **deleted with them** rather than weakened into a check that could only pass — the
- `azure.yaml` — closed against the two authoritative JSON schemas (see start command now has exactly one copy, in [`DEPLOY.md`](DEPLOY.md), and
`docs/2026-08-13-fase4-azure-yaml-valg.md`): `language: docker`, invocations protocol pinned `tests/test_handover_package_loadbearing.py` is what keeps it there.
to `2.0.0`, no `env:` block (the platform injects `FOUNDRY_PROJECT_ENDPOINT`; model choice is
config, not environment), no `startupCommand` (the image `CMD` is the one copy).
An invocation is a JSON object whitelisted onto `run_project`'s signature — `project_id`, An invocation is a JSON object whitelisted onto `run_project`'s signature — `project_id`,
`docs_dir` and `verdict_input` required; `bundle_dir`, `profile`, `max_rounds`, `max_tokens` `docs_dir` and `verdict_input` required; `bundle_dir`, `profile`, `max_rounds`, `max_tokens`
and `top_k` optional. Unknown fields are refused by name (400), never silently dropped. and `top_k` optional. Unknown fields are refused by name (400), never silently dropped.
`profile` defaults to `azure` on this surface: the AZURE profile reads its endpoint and `profile` defaults to `azure` on this surface: the AZURE profile reads its endpoint and
credential from the environment at call time, so the same image runs hosted (managed identity) credential from the environment at call time, so the same process runs hosted (managed identity)
and locally (`az login`) without rewiring. and locally (`az login`) without rewiring.
### Handing it to someone else ### Handing it to someone else
@ -499,8 +502,8 @@ cloning this repository or having an account here:
scripts/make-handover-package.sh # → dist/portfolio-optimiser-foundry-<version>.zip scripts/make-handover-package.sh # → dist/portfolio-optimiser-foundry-<version>.zip
``` ```
The archive is `git archive HEAD` — tracked files only, which is the same tree the measured docker The archive is `git archive HEAD` — tracked files only, which is why local-only files cannot enter
build context uses, and the reason local-only files cannot enter it. [`DEPLOY.md`](DEPLOY.md) rides it and why nothing curates what a receiver sees. [`DEPLOY.md`](DEPLOY.md) rides
along inside it and answers the receiver's first questions: what the three roles do, what the along inside it and answers the receiver's first questions: what the three roles do, what the
process is end to end, why there is no chat interface, and the two environment variables that decide process is end to end, why there is no chat interface, and the two environment variables that decide
whether the first deployment works. Gated by `tests/test_handover_package_loadbearing.py`. whether the first deployment works. Gated by `tests/test_handover_package_loadbearing.py`.

View file

@ -1,28 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
#
# Fase 4d. The four open choices were closed against the two authoritative JSON schemas
# (docs/2026-08-13-fase4-azure-yaml-valg.md), not against Learn prose:
#
# * `language: docker`, NO `codeConfiguration` — codeConfiguration is ZIP-source mode,
# and the git-pinned dependencies need our own Dockerfile (git in the base image).
# * `protocols` pinned to invocations 2.0.0, stated outright: the header contract in
# spike §1.5 (x-agent-user-id / x-agent-foundry-call-id, gateway allowlist) is a
# 2.0.0 property, and 1.0.0 is deprecated with a blocking deadline.
# * `project` is REQUIRED for host azure.ai.agent (the $ref'd azure.ai.agent.json);
# `config:` is deprecated there and not used.
# * NO `env:` block, deliberately: FOUNDRY_PROJECT_ENDPOINT must never be redeclared
# (it would risk shadowing the platform's injected value — spike pkt. 2), and model
# selection is config (the packaged data/model_map.json, override via
# PORTFOLIO_MODEL_MAP), not environment variables.
# * NO `startupCommand`, deliberately: the image's CMD (Dockerfile) is the ONE copy of
# the start command — a second copy here could drift from it.
name: portfolio-optimiser
services:
portfolio-optimiser:
host: azure.ai.agent
kind: hosted
project: .
language: docker
protocols:
- protocol: invocations
version: "2.0.0"

View file

@ -1,9 +1,10 @@
"""Hosted-agent process entry (Fase 4d): the ONE file the container starts. """Hosted-agent process entry (Fase 4d): the ONE file that starts the service.
Thin by design the runtime contract lives in ``portfolio_optimiser.hosting`` (packaged Thin by design the runtime contract lives in ``portfolio_optimiser.hosting`` (packaged
and tested); this file is the scaffold-conventional ``main.py`` that the Dockerfile ``CMD`` and tested); this file is the scaffold-conventional ``main.py`` that ``python main.py``
and ``azure.yaml`` point at. It is part of the git archive (the build context), not of the starts, which is the single start command DEPLOY.md prints (14.08: the delivery is runnable
wheel: a wheel consumer starts the same server with Python, and no image ``CMD`` exists to be a second copy of it). It is part of the git archive,
not of the wheel: a wheel consumer starts the same server with
``python -c "from portfolio_optimiser.hosting import main; main()"``. ``python -c "from portfolio_optimiser.hosting import main; main()"``.
""" """

View file

@ -7,9 +7,11 @@
# cannot enter the archive. A filter maintained here would be a second copy of that rule, and the # cannot enter the archive. A filter maintained here would be a second copy of that rule, and the
# second copy is the one that drifts (kø-(p)). # second copy is the one that drifts (kø-(p)).
# #
# It is also the SAME tree the measured docker build context uses (see the Dockerfile header: # What the receiver gets is therefore HEAD itself -- never a hand-curated selection. That also
# `git archive HEAD | docker build --platform linux/amd64 -`), so what the receiver deploys is what # decided how the 14.08 directive ("runnable Python, no container wrapper") was carried out: the
# we measured -- never a hand-curated selection. # Dockerfile and azure.yaml were removed from the TREE, not filtered out here. A filter would have
# been a curation step deciding what a receiver sees, i.e. exactly the second copy this comment
# exists to forbid. Absence is asserted in tests/test_handover_package_loadbearing.py.
# #
# Usage: scripts/make-handover-package.sh [dest-dir] (default: dist/) # Usage: scripts/make-handover-package.sh [dest-dir] (default: dist/)
# Gated by tests/test_handover_package_loadbearing.py. # Gated by tests/test_handover_package_loadbearing.py.

View file

@ -2,15 +2,24 @@
Microsoft Foundry with minimal friction. Microsoft Foundry with minimal friction.
**Why an archive and not "clone the repo".** The receiving party is not a contributor: they get a **Why an archive and not "clone the repo".** The receiving party is not a contributor: they get a
tree, set two environment variables and deploy it. The measured docker build context has always been tree, set two environment variables and run it. The package is ``git archive HEAD`` itself never a
``git archive HEAD`` (Dockerfile header), so the package is that SAME tree never a hand-curated hand-curated copy, which would be the second copy that drifts (the -(p) rule applied to a
copy, which would be the second copy that drifts (the -(p) rule applied to a deliverable). deliverable).
Three seams are gated here, and each one is a way the handover fails in the receiver's hands rather **Runnable Python, no container wrapper (operator directive 14.08, from an external trial).** What a
receiver gets is a Python tree they install and start themselves ``uv sync --frozen`` +
``python main.py`` and the ``Dockerfile``/``azure.yaml`` pair that used to ride along was removed
from the tree rather than filtered out of the archive. Filtering would have meant a curation
mechanism deciding what a receiver sees, i.e. a SECOND copy of "what is delivered" free to drift
from HEAD (the -(p) rule, which is the very reason this package is ``git archive HEAD``). Removing
the files keeps the archive uncurated and makes the absence a property of HEAD which is what the
gate below can actually measure.
Four seams are gated here, and each one is a way the handover fails in the receiver's hands rather
than in ours: than in ours:
1. **Completeness** a tree missing ``uv.lock`` resolves different versions than every measurement 1. **Completeness** a tree missing ``uv.lock`` resolves different versions than every measurement
in this repo ran against; missing ``shared/`` gives a container with no example knowledge base. in this repo ran against; missing ``shared/`` gives a receiver with no example knowledge base.
2. **Exposure** ``STATE.md``, ``*.local.md`` and ``.env`` must never leave this machine. The 2. **Exposure** ``STATE.md``, ``*.local.md`` and ``.env`` must never leave this machine. The
archive is built from tracked files only, so this is a property of the BUILDER; the control below archive is built from tracked files only, so this is a property of the BUILDER; the control below
proves the check looks for names that could actually appear. proves the check looks for names that could actually appear.
@ -19,6 +28,10 @@ than in ours:
only an endpoint gets a container that answers ``/readiness`` and fails every ``/invocations``. only an endpoint gets a container that answers ``/readiness`` and fails every ``/invocations``.
4e measured that requirement and called it "et deploy-krav ingen rad hadde skrevet ned"; DEPLOY.md 4e measured that requirement and called it "et deploy-krav ingen rad hadde skrevet ned"; DEPLOY.md
is that row, and this test is what keeps it written. is that row, and this test is what keeps it written.
4. **Python-only delivery** the archive carries no container/azd wrapper, and DEPLOY.md starts the
service the way the tree actually supports: as a Python process. A gate that merely stopped
REQUIRING ``Dockerfile`` could not tell "removed" from "still shipped", so the check is positive
(absence, asserted) rather than an omission.
""" """
from __future__ import annotations from __future__ import annotations
@ -33,10 +46,10 @@ _REPO_ROOT = Path(__file__).resolve().parents[1]
_SCRIPT = _REPO_ROOT / "scripts" / "make-handover-package.sh" _SCRIPT = _REPO_ROOT / "scripts" / "make-handover-package.sh"
# Deploy-critical members. Each one is load-bearing for a receiver, not decoration: # Deploy-critical members. Each one is load-bearing for a receiver, not decoration:
# the two manifests, the entry point, the locked resolution, the packaged data and the example base. # the entry point, the locked resolution, the packaged data and the example base. `Dockerfile` and
# `azure.yaml` were REQUIRED here until 14.08; they are gone from the tree, and their absence is now
# asserted below instead of their presence.
_REQUIRED_MEMBERS = ( _REQUIRED_MEMBERS = (
"Dockerfile",
"azure.yaml",
"main.py", "main.py",
"pyproject.toml", "pyproject.toml",
"uv.lock", "uv.lock",
@ -51,6 +64,25 @@ _REQUIRED_MEMBERS = (
_FORBIDDEN_SUFFIXES = (".local.md", ".env") _FORBIDDEN_SUFFIXES = (".local.md", ".env")
_FORBIDDEN_NAMES = ("STATE.md",) _FORBIDDEN_NAMES = ("STATE.md",)
# The container/azd wrapper. Matched on archive MEMBER NAMES, never on prose: the documents may
# explain that no image is shipped, and a gate that read the word out of a sentence would be red on
# exactly the prose it protects (this repo's 08-09 defect class).
_CONTAINER_WRAPPER_NAMES = ("Dockerfile", ".dockerignore", "docker-compose.yml", "azure.yaml")
# The start command a receiver is told to run. With no image CMD left, the document IS the one copy —
# and it names the entry point whose serve/SIGTERM behaviour test_hosting_loadbearing measures.
_PYTHON_START = "python main.py"
# Command invocations that would put a container step back into the documented path. Fragments, not
# the bare word: "no container image is shipped" must stay sayable.
_WRAPPER_COMMANDS = ("docker build", "docker run", "azd up", "azd deploy", "azd provision")
def _members_named(names: list[str], wanted: tuple[str, ...]) -> list[str]:
"""Archive members whose basename is one of ``wanted`` — the matcher both the assertion and its
control run through, so a matcher that silently matches nothing cannot pass unnoticed."""
return [n for n in names if Path(n).name in wanted]
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def package(tmp_path_factory: pytest.TempPathFactory) -> zipfile.ZipFile: def package(tmp_path_factory: pytest.TempPathFactory) -> zipfile.ZipFile:
@ -129,3 +161,63 @@ def test_deploy_doc_states_the_placeholder_requirement(package: zipfile.ZipFile)
assert "REPLACE-WITH-" in doc, ( assert "REPLACE-WITH-" in doc, (
"DEPLOY.md must state that the packaged deployment ids are placeholders" "DEPLOY.md must state that the packaged deployment ids are placeholders"
) )
def test_package_ships_runnable_python_and_no_container_wrapper(package: zipfile.ZipFile) -> None:
"""Detach point: put ``Dockerfile`` or ``azure.yaml`` back into HEAD → RED.
The 14.08 operator directive is that the deliverable is runnable Python. Removing the wrapper
from the TREE (rather than filtering it out of the archive) is what makes that measurable here:
the package is ``git archive HEAD``, so absence in the archive IS absence in what we ship.
Two controls, because a name-matcher that matches nothing would make this green forever:
the run path a receiver actually needs must be present, and the matcher must be shown to match
a member this archive really has."""
names = package.namelist()
# Control 1 — the Python run path is what replaces the image. If these are missing, "no
# container" would just mean "nothing to run".
for member in ("main.py", "pyproject.toml", "uv.lock"):
assert member in names, f"the runnable-Python path is incomplete: {member} is not packaged"
# Control 2 — the matcher matches by basename against THIS archive, proven on a member we know
# is there. Without it, a matcher comparing full paths would find nothing and pass silently.
assert _members_named(names, ("main.py",)) == ["main.py"], (
"control: the member matcher found nothing for a member the archive demonstrably has"
)
wrappers = _members_named(names, _CONTAINER_WRAPPER_NAMES)
assert not wrappers, (
f"the handover package ships a container/azd wrapper: {wrappers}. The delivery is runnable "
"Python (operator directive 14.08); a wrapper here is a second, unmeasured way to start it."
)
def test_receiver_documents_start_the_service_as_a_python_process(package: zipfile.ZipFile) -> None:
"""Detach point: tell the receiver to build an image again in either document → RED.
Both documents ride inside the archive, so both are the receiver's instructions. Each is checked
LINE-ANCHORED with its own positive control first: a negative assertion on a document the
extractor failed to read is a gate that can only be green."""
deploy = package.read("DEPLOY.md").decode("utf-8").splitlines()
readme = package.read("README.md").decode("utf-8").splitlines()
# Positive control on DEPLOY.md: with no image CMD left, this document carries the ONE copy of
# the start command, and it must name the entry point the hosting tests actually exercise.
assert [line for line in deploy if _PYTHON_START in line], (
f"DEPLOY.md no longer prints the start command ({_PYTHON_START!r}) — the receiver has "
"nothing to run, and the one copy of the start command is gone"
)
# Positive control on README.md: proves this document was read and searched at all.
assert [line for line in readme if "uv sync" in line], (
"control: README.md has no install line — the check below would be searching nothing"
)
for label, lines in (("DEPLOY.md", deploy), ("README.md", readme)):
offenders = [
line for line in lines if any(fragment in line for fragment in _WRAPPER_COMMANDS)
]
assert not offenders, (
f"{label} instructs a container/azd build step that this package no longer ships: "
f"{offenders}"
)

View file

@ -18,11 +18,11 @@ Load-bearing surface pinned here:
* The server is asyncio on the ONE loop (NG1: ``test_no_thread_or_process_path_exists_under_src`` * The server is asyncio on the ONE loop (NG1: ``test_no_thread_or_process_path_exists_under_src``
ratchets src/ thread-free) these tests run client and server as coroutines on the SAME loop, ratchets src/ thread-free) these tests run client and server as coroutines on the SAME loop,
which only works because nothing in the server blocks it. which only works because nothing in the server blocks it.
* Root ``main.py`` is the ONE process entry (Dockerfile CMD + azure.yaml point at it): the * Root ``main.py`` is the ONE process entry (``python main.py``, the command DEPLOY.md prints): the
subprocess test is the ONLY test that catches a detached shim or a detached SIGTERM handler subprocess test is the ONLY test that catches a detached shim or a detached SIGTERM handler
(P4-presedensen: entry-point-mutasjoner fanges aldri av in-process-tester). (P4-presedensen: entry-point-mutasjoner fanges aldri av in-process-tester).
Fase 4e closes two gaps the above leaves open, and both are about things a stand-in cannot see: Fase 4e closed two gaps the above leaves open, and both are about things a stand-in cannot see:
* **The whitelist composes with the REAL ``run_project``.** Every test above hands ``invoke`` a * **The whitelist composes with the REAL ``run_project``.** Every test above hands ``invoke`` a
stand-in that swallows ``**kwargs``, so the whitelist could name a field ``run_project`` does not stand-in that swallows ``**kwargs``, so the whitelist could name a field ``run_project`` does not
@ -33,14 +33,11 @@ Fase 4e closes two gaps the above leaves open, and both are about things a stand
PURPOSE (a caller must never choose the server's model client), so patching the factory the run PURPOSE (a caller must never choose the server's model client), so patching the factory the run
falls back to is the only injection point this surface leaves the same argument falls back to is the only injection point this surface leaves the same argument
``test_run_cli_loadbearing`` makes for ``main()``. ``test_run_cli_loadbearing`` makes for ``main()``.
* **The deployment artifacts are raw-text-gated.** ``Dockerfile`` and ``azure.yaml`` are the two * **The deployment artifacts were raw-text-gated** until 14.08, when the operator directive
files that decide whether the image the platform pulls can run at all, and NO test executes after an external trial made the delivery runnable Python and the two artifacts were removed
them here (``docker build``/``azd deploy`` are operator-gated). A raw-text gate is therefore the from the tree. The gate is deleted, not weakened; see the note where it stood, below the
only mechanism available: it pins ``--platform linux/amd64`` (measured required spike §1.4; end-to-end test. The start command now has exactly one copy left, in DEPLOY.md, and
an arm64 image built on this Intel-free-of-charge assumption would fail only in the cloud) and ``tests/test_handover_package_loadbearing.py`` is what keeps it there.
the ONE-copy rule for the start command (the image's ``CMD``; ``azure.yaml`` carries no
``startupCommand`` to drift from it). Guard-tester leser kildefiler som TEKST reformulate
the prose around them, never the strings they pin.
""" """
from __future__ import annotations from __future__ import annotations
@ -381,8 +378,8 @@ def _blocking_get(url: str) -> tuple[int, str]:
def test_main_entrypoint_serves_and_stops_on_sigterm() -> None: def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
"""Root main.py is the ONE process entry (Dockerfile CMD + azure.yaml point at it): started """Root main.py is the ONE process entry (``python main.py``, the command DEPLOY.md prints):
as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that started as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that
catches a shim that stops calling hosting.main() or a detached SIGTERM handler.""" catches a shim that stops calling hosting.main() or a detached SIGTERM handler."""
with socket.socket() as probe: with socket.socket() as probe:
probe.bind(("127.0.0.1", 0)) probe.bind(("127.0.0.1", 0))
@ -415,7 +412,7 @@ def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
proc.wait() proc.wait()
# --- Fase 4e: the whitelist against the REAL run_project, and the deployment artifacts ---------- # --- Fase 4e: the whitelist against the REAL run_project -----------------------------------------
@pytest.fixture() @pytest.fixture()
@ -491,51 +488,9 @@ async def test_invocations_answers_through_the_real_run_project(
assert _scripted_backend, "the scripted backend was never called — no real run happened" assert _scripted_backend, "the scripted backend was never called — no real run happened"
def test_deployment_artifacts_pin_the_measured_platform_and_one_start_command() -> None: # The 4e raw-text gate on ``Dockerfile``/``azure.yaml`` lived here until 14.08. It pinned
"""RAW-TEXT GATE on the two files that decide whether the hosted image runs at all. # ``--platform linux/amd64`` and the one-copy rule for the image's ``CMD``. Both files were removed
# from the tree that day (operator directive: the delivery is runnable Python), and a gate that pins
Neither is executed by any test: ``docker build`` and ``azd deploy`` are operator-gated, so a # a surface we no longer ship is deleted with it rather than weakened into something that can only
regression in them is invisible to the whole suite until it fails in the cloud. What the gate # be green. What replaced it lives in ``tests/test_handover_package_loadbearing.py``: the package
pins is exactly what was MEASURED, and nothing about the prose around it: # must carry NO container/azd wrapper and must document the Python start command.
* ``--platform linux/amd64`` the platform requires x86_64 (spike §1.4). Dropping it makes the
image inherit the builder's architecture, which on an arm64 laptop yields an image that
builds green locally and cannot start in the cloud. The flag lives in the documented build
command (a Dockerfile cannot set the build platform for its own invocation), so pinning the
documented string is the only gate available and it is worth having precisely because
nothing else re-derives it.
* ONE copy of the start command: the image's ``CMD`` names ``main.py``, and ``azure.yaml``
declares NO ``startupCommand``. Two copies drift (-(p)-regelen); this is the pair that
keeps there being one.
``env:`` is checked for the same reason: ``FOUNDRY_PROJECT_ENDPOINT`` is injected by the
platform, and redeclaring it here could shadow the injected value which is the failure mode
Fase 4b's endpoint precedence exists to avoid, undone from the config side."""
dockerfile = _REPO_ROOT / "Dockerfile"
azure_yaml = _REPO_ROOT / "azure.yaml"
assert dockerfile.is_file(), "the hosted image has no build definition"
assert azure_yaml.is_file(), "azd has no project definition to deploy"
docker_text = dockerfile.read_text(encoding="utf-8")
azure_text = azure_yaml.read_text(encoding="utf-8")
assert "--platform linux/amd64" in docker_text, (
"the Dockerfile no longer names the measured build platform; an image built without it "
"inherits the builder's architecture and cannot start on the hosting platform"
)
assert "CMD" in docker_text and "main.py" in docker_text, (
"the image's CMD is the ONE copy of the start command and must name main.py"
)
# Line-anchored: a mention inside a comment is prose, a top-level key is a declaration.
azure_keys = [line.split(":")[0].strip() for line in azure_text.splitlines()]
assert "startupCommand" not in azure_keys, (
"azure.yaml declares a startupCommand — a SECOND copy of the start command, free to drift "
"from the image's CMD"
)
assert "env" not in azure_keys, (
"azure.yaml declares an env block — FOUNDRY_PROJECT_ENDPOINT is injected by the platform "
"and must never be redeclared here"
)
assert "host: azure.ai.agent" in azure_text and "kind: hosted" in azure_text, (
"azure.yaml no longer declares the hosted-agent host this whole entrypoint targets"
)

View file

@ -14,8 +14,8 @@ made in prose that no test could see, and both drift silently:
Both gates read the source artefacts as RAW TEXT, because that is the only thing that can see prose. Both gates read the source artefacts as RAW TEXT, because that is the only thing that can see prose.
Both are LINE-ANCHORED rather than substring-matched: ``backends.py`` NAMES ``DefaultAzureCredential`` Both are LINE-ANCHORED rather than substring-matched: ``backends.py`` NAMES ``DefaultAzureCredential``
four times in the comments that explain why it is not used, so a whole-file substring check would be four times in the comments that explain why it is not used, so a whole-file substring check would be
red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the 4e red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the handover
``azure.yaml`` gate is line-anchored too). package's python-only gate matches archive member NAMES rather than document prose).
Each positive assertion is paired with a CONTROL that the thing being searched for is actually Each positive assertion is paired with a CONTROL that the thing being searched for is actually
present. An extractor that silently finds nothing makes a gate that can only ever be green, which present. An extractor that silently finds nothing makes a gate that can only ever be green, which