feat(5): overleveringspakke for eksterne — git archive HEAD + DEPLOY.md

Én zip en mottakende organisasjon deployer uten å klone repoet eller ha konto her.
Arkivet er git archive HEAD (tracked files only), som er SAMME tre den målte
docker-build-konteksten bruker — og grunnen til at STATE.md/*.local.md/.env ikke kan
komme inn: de er gitignorert, ikke filtrert bort av et filter vi må vedlikeholde.

DEPLOY.md svarer mottakerens tre første spørsmål: hvem gjør hva (plattform-operatør,
bestiller, fagperson), prosessen ende-til-ende, og hvorfor det ikke finnes et
chat-grensesnitt. Den navngir også deploy-kravet 4e målte men aldri skrev ned:
pakket model_map.json bærer REPLACE-WITH-*, så uten PORTFOLIO_MODEL_MAP starter
containeren, svarer på /readiness og feiler hver invocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeW1LhH5TtXxKZPe9JkqL1
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 10:43:28 +02:00
commit a3300ab0f6
5 changed files with 385 additions and 0 deletions

View file

@ -531,6 +531,25 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
import), eksempelet er gyldig pipeline-input inkl. `FeedbackContract` (RØD på skjema-/kontrakt-drift,
på en throwaway-kopi — aldri den git-tracked fixturen), og sim-ens markør følger artefakt-fila (RØD i
det øyeblikk personaen re-inlines).
- **Overleverings-pakka ER `git archive HEAD`, aldri en kuratert kopi (Fase 5):**
`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
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
finnes et chat-grensesnitt** (flaten er `POST /invocations`, og `as_agent()` er bevisst vraket
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
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).
- **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.

189
DEPLOY.md Normal file
View file

@ -0,0 +1,189 @@
# Deploying and using this package
This archive is a complete, self-contained copy of `portfolio-optimiser`. You do not need to clone
anything or have an account anywhere to deploy it. Unzip it and follow this document.
It is written in English because its readers are implementing developers and platform operators.
The two documents that describe the *human* side of the loop are written in Norwegian and ship in
this same archive: [`docs/bestille-en-kjoring.md`](docs/bestille-en-kjoring.md) (ordering a run) and
[`docs/ekspert-svar.md`](docs/ekspert-svar.md) (giving the verdict).
---
## 1. What this is — and what it is not
It finds cost savings **inside** a single project: agents propose candidate measures, a
**deterministic validator** decides whether the numbers hold, a second agent (the "checker") gates
the *reasoning*, and a domain expert gives the final verdict. The system learns from those verdicts.
**There is no chat interface, and that is a design decision rather than a missing feature.**
The hosted surface is a task API — `POST /invocations` with a JSON body, `GET /readiness` — not a
conversation. The system is deliberately **not** exposed as a Microsoft Foundry agent via
`as_agent()`, because the validator, the cost-baseline anchoring, the checker gate and the savings
ledger all live *outside* the agent graph. Wrapping it as a chat agent would route requests around
precisely the components that make an answer trustworthy, and you would get a fluent assistant
instead of a validated result.
The dialogue with humans happens in two places instead, both file-shaped and both auditable:
- **before** a run, through a mandate file (what should be considered), and
- **after** a run, through a verdict file (what a qualified person decided).
If you want an interactive front end, build it on top of `POST /invocations` — but keep the verdict
step human. An approval loop that a model can close by itself is not an approval loop.
---
## 2. Who does what
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 35. |
| **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` |
**The ordering expert steers what is *considered*, never what is *approved*.** If you ask for
something the numbers do not support, the deterministic validator rejects it and the rejection comes
back with its reason. That property is the point of the whole design; do not build around it.
---
## 3. The process, end to end
1. **Prepare a knowledge base** for the project — cost documents, technical sources, prior
experience — as a document bundle. A working five-document example ships in
`shared/examples/bygg-energi-mikro`, and `docs/knowledge-base-recipe.md` describes how to build
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).
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
answer, not to the transport, and the API returns HTTP 200 for it.
4. **A domain expert judges the outcome** and writes a verdict — either inline during the run, or as
a JSON file dropped into an inbox directory afterwards, possibly days later. See
`docs/ekspert-svar.md`.
5. **Approved verdicts can be promoted into the knowledge base**, so the next run reads them as
context and does not re-propose something the organisation has already rejected. Promotion is
gated: only an approved verdict can be promoted, and raw agent output never enters the knowledge
base on its own.
Steps 4 and 5 are what make this a learning loop rather than a report generator.
---
## 4. Prerequisites in your own tenant
You need, in Microsoft Foundry:
1. A **Foundry resource** with project management enabled and a **project** inside it. A Foundry
project is required — an Azure OpenAI resource (`kind: OpenAI`) is a different client surface and
will not work.
2. At least one **model deployment** (any current chat model; a small one is enough).
3. The **Foundry User** role (role definition id `53ca6127-db72-4b80-b1b0-d745d6d5456d`) for the
identity that will run it, assigned at project scope. Azure `Owner` and `Contributor` grant
management permissions only, **not** the data-plane permissions this needs.
---
## 5. Configuration — the two variables that matter
**5.1 The project endpoint.** Resolved as the first non-empty of:
```
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
like `https://<resource>.services.ai.azure.com/api/projects/<project>`.
**5.2 The model map — this is the one that catches people.** The packaged
`src/portfolio_optimiser/data/model_map.json` ships **placeholders**:
```json
"azure": { "default": "REPLACE-WITH-FOUNDRY-DEPLOYMENT", ... }
```
Deployment names are tenant-specific, so they are deliberately never committed. The code
**fail-fasts** on a `REPLACE-WITH-` value rather than guessing. Point `PORTFOLIO_MODEL_MAP` at your
own file:
```json
{
"local": { "default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b" },
"azure": { "default": "my-deployment", "proposer": "my-deployment", "checker": "my-deployment" }
}
```
```bash
export PORTFOLIO_MODEL_MAP=/path/to/model_map.json
```
If you skip this, the container 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:**
```bash
uv run python -m portfolio_optimiser.preflight --profile azure
```
Exit code 0 means every offline-checkable precondition holds: endpoint shape, model-map structure,
no placeholders left. It is **necessary but not sufficient** — RBAC (403), token or tenant problems
(401) and a well-formed but non-existent deployment (404) can only surface at the live call.
---
## 6. Deploying
The image must be built for x86_64 regardless of your workstation's architecture:
```bash
git archive HEAD | docker build --platform linux/amd64 -t portfolio-optimiser -
```
If you unzipped this package rather than cloning it, build from the directory instead:
```bash
docker build --platform linux/amd64 -t portfolio-optimiser .
```
`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:
| Field | Required | Notes |
|---|---|---|
| `project_id` | yes | The project to analyse. |
| `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. |
| `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. |
HTTP 400 means the request was malformed; 500 means the run itself failed, with `error_type` and
`error` in the body. A rejected proposal is **200** — see section 3.
---
## 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*
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`.
- Readiness does not respond during CPU-bound stretches of a run (the optimisation solve).
- Chunked request bodies are not supported.
- Verdict promotion writes to the knowledge base non-atomically; it assumes a single writing process.

View file

@ -490,6 +490,21 @@ and `top_k` optional. Unknown fields are refused by name (400), never silently d
credential from the environment at call time, so the same image runs hosted (managed identity)
and locally (`az login`) without rewiring.
### Handing it to someone else
`scripts/make-handover-package.sh` builds one archive a receiving organisation can deploy without
cloning this repository or having an account here:
```bash
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
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`.
## Docs
- [Bestille en kjøring](docs/bestille-en-kjoring.md) *(norsk)* — for the domain expert who

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Build the external handover package (Fase 5): one archive a receiver deploys into their own
# Microsoft Foundry, without cloning this repository or having an account on our Forgejo.
#
# The archive is git's view of HEAD -- TRACKED FILES ONLY. That is deliberate and it is the whole
# exposure control: STATE.md is gitignored, *.local.md is gitignored, .env is gitignored, so they
# 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.
#
# Usage: scripts/make-handover-package.sh [dest-dir] (default: dist/)
# Gated by tests/test_handover_package_loadbearing.py.
set -euo pipefail
DEST="${1:-dist}"
mkdir -p "$DEST"
# Version from pyproject.toml -- the one place the build stamps it. Read, never hardcoded: a second
# copy here would go stale at the next bump exactly like the README wheel filename did (Fase 3).
VERSION=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
if [ -z "$VERSION" ]; then
echo "make-handover-package: could not read version from pyproject.toml" >&2
exit 1
fi
OUT="$DEST/portfolio-optimiser-foundry-$VERSION.zip"
git archive --format=zip --output "$OUT" HEAD
echo "$OUT"

View file

@ -0,0 +1,129 @@
"""Fase 5 — the external handover package: one archive a stranger can deploy into their own
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 -(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
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.
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.
3. **The deploy contract is written down** the packaged ``model_map.json`` ships
``REPLACE-WITH-*`` placeholders and ``backends.py`` fail-fasts on them, so a receiver who sets
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.
"""
from __future__ import annotations
import subprocess
import zipfile
from pathlib import Path
import pytest
_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.
_REQUIRED_MEMBERS = (
"Dockerfile",
"azure.yaml",
"main.py",
"pyproject.toml",
"uv.lock",
"DEPLOY.md",
"src/portfolio_optimiser/hosting.py",
"src/portfolio_optimiser/backends.py",
"src/portfolio_optimiser/data/model_map.json",
"shared/examples/bygg-energi-mikro/index.md",
)
# Names that must NEVER reach a stranger. Local-only continuity, operator config, secrets.
_FORBIDDEN_SUFFIXES = (".local.md", ".env")
_FORBIDDEN_NAMES = ("STATE.md",)
@pytest.fixture(scope="module")
def package(tmp_path_factory: pytest.TempPathFactory) -> zipfile.ZipFile:
"""Build the real package by running the real script — the packaging config is itself a seam
(the 4a precedent: an ``uv build`` in the fixture, never a simulated one)."""
dest = tmp_path_factory.mktemp("handover")
result = subprocess.run(
[str(_SCRIPT), str(dest)],
cwd=_REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, f"builder failed: {result.stderr}"
archives = sorted(dest.glob("*.zip"))
assert len(archives) == 1, f"expected exactly one archive, got {archives}"
return zipfile.ZipFile(archives[0])
def test_package_carries_every_deploy_critical_file(package: zipfile.ZipFile) -> None:
"""Detach point: drop a member from the archive → RED. A receiver cannot supply what we omit."""
names = set(package.namelist())
missing = [m for m in _REQUIRED_MEMBERS if m not in names]
assert not missing, f"handover package is missing {missing}"
def test_package_leaks_no_local_or_secret_files(package: zipfile.ZipFile) -> None:
"""Detach point: build from the working tree instead of tracked files → RED (STATE.md appears).
The control is the point: a filter that matched nothing would make this gate green forever, so
we first prove the archive is populated and that the suffixes we forbid are ones the repo
actually produces (``STATE.md`` exists on this machine, untracked-by-design)."""
names = package.namelist()
assert len(names) > 50, "archive suspiciously small — the check below would be vacuous"
assert (_REPO_ROOT / "STATE.md").exists(), (
"control: STATE.md must exist locally, else this gate cannot discriminate"
)
leaked = [
n
for n in names
if Path(n).name in _FORBIDDEN_NAMES or n.endswith(_FORBIDDEN_SUFFIXES)
]
assert not leaked, f"handover package leaks local-only files: {leaked}"
def test_deploy_doc_names_both_required_env_vars(package: zipfile.ZipFile) -> None:
"""Detach point: remove either variable from DEPLOY.md → RED.
Line-anchored, not substring: ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` CONTAINS
``FOUNDRY_PROJECT_ENDPOINT``, so a naive substring assert on the platform-injected name is
satisfied by our own (the 08-09 defect class, measured twice before in this repo)."""
doc = package.read("DEPLOY.md").decode("utf-8")
lines = doc.splitlines()
assert any("PORTFOLIO_MODEL_MAP" in line for line in lines), (
"DEPLOY.md must name PORTFOLIO_MODEL_MAP — without it the container fail-fasts on the "
"REPLACE-WITH-* placeholders"
)
# The injected name must appear on a line that is NOT merely our own name.
injected_lines = [
line for line in lines if "FOUNDRY_PROJECT_ENDPOINT" in line.replace("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "")
]
assert injected_lines, "DEPLOY.md must name the platform-injected FOUNDRY_PROJECT_ENDPOINT"
def test_deploy_doc_states_the_placeholder_requirement(package: zipfile.ZipFile) -> None:
"""Detach point: drop the placeholder warning → RED. The packaged map ships REPLACE-WITH-*, so a
receiver who is not told will deploy a container that fails every invocation."""
doc = package.read("DEPLOY.md").decode("utf-8")
packaged_map = package.read("src/portfolio_optimiser/data/model_map.json").decode("utf-8")
# Control: the requirement is only real while the packaged map actually ships placeholders.
assert "REPLACE-WITH-" in packaged_map, (
"control: packaged model_map no longer has placeholders — this gate would be vacuous"
)
assert "REPLACE-WITH-" in doc, "DEPLOY.md must state that the packaged deployment ids are placeholders"