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:
parent
642ce8ae9a
commit
605c02aa62
10 changed files with 215 additions and 205 deletions
54
CLAUDE.md
54
CLAUDE.md
|
|
@ -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
|
||||
`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
|
||||
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). `azure.yaml`
|
||||
validert GRØNN mot begge autoritative skjemaer; ingen `env:` (redeklarer aldri
|
||||
`FOUNDRY_PROJECT_ENDPOINT`), ingen `startupCommand` (imagets `CMD` er den ene kopien av
|
||||
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
|
||||
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). **Container-innpakningen
|
||||
(`Dockerfile`/`azure.yaml`) ER FJERNET 14.08** — se python-only-invarianten under; resten 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
|
||||
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
|
||||
siste fanges KUN av subprosess-testen — P4-presedensen). Deploy er IKKE utført (azd-steget er
|
||||
operatørens); chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
|
||||
siste fanges KUN av subprosess-testen — P4-presedensen). Chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
|
||||
står readiness — uttalt, ikke skjult.
|
||||
- **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
|
||||
|
|
@ -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
|
||||
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).
|
||||
`Dockerfile`/`azure.yaml` KJØRES av ingen test (`docker build`/`azd deploy` er operatør-gatet), så
|
||||
rå-tekst er eneste tilgjengelige gate: `--platform linux/amd64` (målt påkrevd, spike §1.4 — uten
|
||||
det arver imaget byggerens arkitektur og bygger grønt lokalt mens det ikke kan starte i skyen) +
|
||||
ÉN kopi av startkommandoen (imagets `CMD` navngir `main.py`, `azure.yaml` har ingen
|
||||
`startupCommand`). **Nøkkel-sjekkene er LINJEFORANKRET, ikke delstreng:** `azure.yaml`s egen
|
||||
kommentar NAVNGIR `startupCommand` og `env` for å begrunne fraværet, så en substring-gate ville
|
||||
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.
|
||||
**Artefakt-halvdelen av denne raden er PENSJONERT 14.08** sammen med `Dockerfile`/`azure.yaml`
|
||||
(rå-tekst-gaten pinnet `--platform linux/amd64` + ÉN kopi av startkommandoen; to av radens fem
|
||||
mutasjoner traff nettopp den). Whitelist-halvdelen står URØRT. Load-bearing MÅLT
|
||||
(`tests/test_hosting_loadbearing.py`), de tre gjenværende mutasjonene alle røde på riktig test og
|
||||
på INGEN annen: send `project_id` to ganger · whitelist et felt `run_project` ikke tar · fjern
|
||||
`bundle_dir` fra whitelisten.
|
||||
- **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
|
||||
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
|
||||
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
|
||||
andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Det er dessuten
|
||||
SAMME tre som den målte docker-build-konteksten (Dockerfile-headeren), så mottakeren deployer det
|
||||
vi målte. Versjonen LESES fra `pyproject.toml` — et hardkodet tall her ville råtnet ved neste bump
|
||||
andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Mottakeren får altså
|
||||
HEAD selv. 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
|
||||
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
|
||||
|
|
@ -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
|
||||
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
|
||||
`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
|
||||
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
|
||||
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.
|
||||
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.
|
||||
|
||||
|
|
|
|||
40
DEPLOY.md
40
DEPLOY.md
|
|
@ -41,7 +41,7 @@ The package has three distinct audiences. Only the first one is technical.
|
|||
|
||||
| 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 3–5. |
|
||||
| **Platform operator** | Installs the package, sets two environment variables, starts the service, verifies with the offline preflight. One-time work. | This document, sections 3–6. |
|
||||
| **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` |
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ back with its reason. That property is the point of the whole design; do not bui
|
|||
your own.
|
||||
2. **Order a run** with a mandate file (optional but recommended). See
|
||||
`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 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
|
||||
|
|
@ -99,8 +99,8 @@ PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT # yours; wins if set
|
|||
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.
|
||||
Outside it — a laptop, a pipeline, a container you run yourself — set the first. The value looks
|
||||
Where something injects the second one for you — the Foundry hosting platform does — you need to
|
||||
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>`.
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
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
|
||||
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
|
||||
name with HTTP 400, never silently ignored:
|
||||
|
||||
|
|
@ -167,7 +175,7 @@ name with HTTP 400, never silently ignored:
|
|||
| `docs_dir` | yes | Source documents. |
|
||||
| `verdict_input` | yes | The expert verdict for this run. |
|
||||
| `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_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. |
|
||||
|
|
@ -180,7 +188,7 @@ HTTP 400 means the request was malformed; 500 means the run itself failed, with
|
|||
## 7. Honest limits
|
||||
|
||||
- **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.
|
||||
- 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`.
|
||||
|
|
|
|||
35
Dockerfile
35
Dockerfile
|
|
@ -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.10–3.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"]
|
||||
51
README.md
51
README.md
|
|
@ -459,35 +459,38 @@ only an expert has:
|
|||
- **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching
|
||||
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 —
|
||||
schema-validated and container-built — but an actual `azd` deploy has **not** been run; that
|
||||
step is the operator's.
|
||||
`main.py` is the ONE process entry. It serves the hosted-agent runtime contract (port 8088 /
|
||||
`PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from
|
||||
`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 /
|
||||
`PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from
|
||||
`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).
|
||||
- `Dockerfile` — `python:3.12-slim-bookworm` + git (the two security components are
|
||||
git-tag-pinned direct references; wheel metadata alone cannot install them) +
|
||||
`uv sync --frozen --no-dev` (the exact locked resolution every measurement ran against).
|
||||
Build it the way a stranger would:
|
||||
`git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -`
|
||||
- `azure.yaml` — closed against the two authoritative JSON schemas (see
|
||||
`docs/2026-08-13-fase4-azure-yaml-valg.md`): `language: docker`, invocations protocol pinned
|
||||
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).
|
||||
```bash
|
||||
uv sync --frozen --no-dev # the exact locked resolution every measurement ran against
|
||||
uv run python main.py # serves the task API
|
||||
```
|
||||
|
||||
`git` must be on PATH for the install: two dependencies are git-tag-pinned direct references,
|
||||
and wheel metadata alone cannot fetch them.
|
||||
|
||||
A `Dockerfile` and an `azure.yaml` shipped here until **14 August 2026** and were removed on an
|
||||
operator directive after an external trial: what is delivered is runnable Python, and how the
|
||||
process is containerised, supervised or deployed belongs to whoever runs it. Git history keeps
|
||||
both files. The raw-text gate that pinned them (`--platform linux/amd64`, one copy of the start
|
||||
command) was **deleted with them** rather than weakened into a check that could only pass — the
|
||||
start command now has exactly one copy, in [`DEPLOY.md`](DEPLOY.md), and
|
||||
`tests/test_handover_package_loadbearing.py` is what keeps it there.
|
||||
|
||||
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`
|
||||
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
|
||||
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.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
The archive is `git archive HEAD` — tracked files only, which is the same tree the measured docker
|
||||
build context uses, and the reason local-only files cannot enter it. [`DEPLOY.md`](DEPLOY.md) rides
|
||||
The archive is `git archive HEAD` — tracked files only, which is why local-only files cannot enter
|
||||
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
|
||||
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`.
|
||||
|
|
|
|||
28
azure.yaml
28
azure.yaml
|
|
@ -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"
|
||||
9
main.py
9
main.py
|
|
@ -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
|
||||
and tested); this file is the scaffold-conventional ``main.py`` that the Dockerfile ``CMD``
|
||||
and ``azure.yaml`` point at. It is part of the git archive (the build context), not of the
|
||||
wheel: a wheel consumer starts the same server with
|
||||
and tested); this file is the scaffold-conventional ``main.py`` that ``python main.py``
|
||||
starts, which is the single start command DEPLOY.md prints (14.08: the delivery is runnable
|
||||
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()"``.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@
|
|||
# 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)).
|
||||
#
|
||||
# It is also the SAME tree the measured docker build context uses (see the Dockerfile header:
|
||||
# `git archive HEAD | docker build --platform linux/amd64 -`), so what the receiver deploys is what
|
||||
# we measured -- never a hand-curated selection.
|
||||
# What the receiver gets is therefore HEAD itself -- never a hand-curated selection. That also
|
||||
# decided how the 14.08 directive ("runnable Python, no container wrapper") was carried out: the
|
||||
# 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/)
|
||||
# Gated by tests/test_handover_package_loadbearing.py.
|
||||
|
|
|
|||
|
|
@ -2,15 +2,24 @@
|
|||
Microsoft Foundry with minimal friction.
|
||||
|
||||
**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
|
||||
``git archive HEAD`` (Dockerfile header), so the package is that SAME tree — never a hand-curated
|
||||
copy, which would be the second copy that drifts (the kø-(p) rule applied to a deliverable).
|
||||
tree, set two environment variables and run it. The package is ``git archive HEAD`` itself — never a
|
||||
hand-curated copy, which would be the second copy that drifts (the kø-(p) rule applied to a
|
||||
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 kø-(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:
|
||||
|
||||
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
|
||||
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.
|
||||
|
|
@ -19,6 +28,10 @@ than in ours:
|
|||
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
|
||||
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
|
||||
|
|
@ -33,10 +46,10 @@ _REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||
_SCRIPT = _REPO_ROOT / "scripts" / "make-handover-package.sh"
|
||||
|
||||
# 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 = (
|
||||
"Dockerfile",
|
||||
"azure.yaml",
|
||||
"main.py",
|
||||
"pyproject.toml",
|
||||
"uv.lock",
|
||||
|
|
@ -51,6 +64,25 @@ _REQUIRED_MEMBERS = (
|
|||
_FORBIDDEN_SUFFIXES = (".local.md", ".env")
|
||||
_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")
|
||||
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, (
|
||||
"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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
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.
|
||||
* 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
|
||||
(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
|
||||
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
|
||||
falls back to is the only injection point this surface leaves — the same argument
|
||||
``test_run_cli_loadbearing`` makes for ``main()``.
|
||||
* **The deployment artifacts are raw-text-gated.** ``Dockerfile`` and ``azure.yaml`` are the two
|
||||
files that decide whether the image the platform pulls can run at all, and NO test executes
|
||||
them here (``docker build``/``azd deploy`` are operator-gated). A raw-text gate is therefore the
|
||||
only mechanism available: it pins ``--platform linux/amd64`` (measured required — spike §1.4;
|
||||
an arm64 image built on this Intel-free-of-charge assumption would fail only in the cloud) and
|
||||
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 RÅ TEKST — reformulate
|
||||
the prose around them, never the strings they pin.
|
||||
* **The deployment artifacts were raw-text-gated** — until 14.08, when the operator directive
|
||||
after an external trial made the delivery runnable Python and the two artifacts were removed
|
||||
from the tree. The gate is deleted, not weakened; see the note where it stood, below the
|
||||
end-to-end test. The start command now has exactly one copy left, in DEPLOY.md, and
|
||||
``tests/test_handover_package_loadbearing.py`` is what keeps it there.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Root main.py is the ONE process entry (Dockerfile CMD + azure.yaml point at it): started
|
||||
as a subprocess it must serve /readiness and exit 0 on SIGTERM. This is the only test that
|
||||
"""Root main.py is the ONE process entry (``python main.py``, the command DEPLOY.md prints):
|
||||
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."""
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
|
|
@ -415,7 +412,7 @@ def test_main_entrypoint_serves_and_stops_on_sigterm() -> None:
|
|||
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()
|
||||
|
|
@ -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"
|
||||
|
||||
|
||||
def test_deployment_artifacts_pin_the_measured_platform_and_one_start_command() -> None:
|
||||
"""RAW-TEXT GATE on the two files that decide whether the hosted image runs at all.
|
||||
|
||||
Neither is executed by any test: ``docker build`` and ``azd deploy`` are operator-gated, so a
|
||||
regression in them is invisible to the whole suite until it fails in the cloud. What the gate
|
||||
pins is exactly what was MEASURED, and nothing about the prose around it:
|
||||
|
||||
* ``--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 (kø-(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"
|
||||
)
|
||||
# The 4e raw-text gate on ``Dockerfile``/``azure.yaml`` lived here until 14.08. It pinned
|
||||
# ``--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
|
||||
# a surface we no longer ship is deleted with it rather than weakened into something that can only
|
||||
# be green. What replaced it lives in ``tests/test_handover_package_loadbearing.py``: the package
|
||||
# must carry NO container/azd wrapper and must document the Python start command.
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the 4e
|
||||
``azure.yaml`` gate is line-anchored too).
|
||||
red on exactly the prose it protects (this repo's 08-09 defect class, and the reason the handover
|
||||
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
|
||||
present. An extractor that silently finds nothing makes a gate that can only ever be green, which
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue