feat(3): flaten sier sant om seg selv, og to gater måler at den fortsetter å gjøre det

Fase 3 (AAA+ på publisert flate). Tre av planens premisser falt på måling og er
rettet FØR handling, ikke etterpå:

* GOVERNANCE-raden hadde feil tiltak. Planen sa «skriv den»; org-ops D11 sier én
  kanonisk fil som hvert repo LENKER, og filen er nå publisert (målt: HTTP 200 på
  open/repo-standard). Å skrive vår egen ville gjort oss til kopi nr. 12 av en
  fil D11-bølgen holder på å rydde vekk. README lenker den, i samme form som
  repo-mailbox bruker, og bus-faktor 1 står uttalt i den kanoniske teksten.
* Release-objektet for v1.0.0 FINNES allerede på open/ (id 155, CHANGELOG-kropp,
  siden rendrer) — det som mangler er vedlegg, ikke objektet.
* WARN RELEASE-STALE fyrer ikke, og kan ikke: regelen sammenligner utgivelse mot
  tagg og er strukturelt blind for repo med null utgivelser (org-ops hovedbok
  #18). Gaten var OK/20 sjekker FØR arbeidet startet, så den kan ikke tjene som
  verifikasjon for denne fasen. Bevisene er Forgejo-APIet, filinnholdet og
  ren-klon-kjøringen.

A5-defekten rettet: env.template:21 sa at credential resolves via
DefaultAzureCredential. Den har aldri gjort det — backends.py:149 konstruerer
ManagedIdentityCredential eller AzureCliCredential, og Learns MAF-veiledning
navngir den spesifikke credentialen NETTOPP for å unngå probing. En operatør som
kopierte templaten ble fortalt at feil identitet ville bli brukt.

To load-bearing gater (Iron Law: begge røde før fiksen, 2 failed / 7 passed):

1. env.template navngir de credentials backends.py faktisk konstruerer, og ingen
   linje utgir DefaultAzureCredential for å være mekanismen. LINJEFORANKRET, ikke
   delstreng: backends.py NAVNGIR klassen fire ganger i kommentarene som
   begrunner hvorfor den ikke brukes, så en fil-bred substring-gate ville vært
   rød på nøyaktig den prosaen den beskytter (repoets 08-09-klasse, fjerde gang).
2. README-ens wheel-filnavn bærer versjonen bygget stempler på fila. Uten den
   ville en versjonsbump stille etterlatt en publisert install-kommando som peker
   på en fil som ikke finnes.

Hver positiv assert er paret med en KONTROLL på at det søkes etter noe som
finnes — en ekstraktor som stille finner null lager en gate som bare kan bli
grønn.

MUTASJONER MÅLT MOT HELE SUITEN, begge røde på riktig test og på INGEN annen:
gjeninnfør den usanne credential-påstanden (2 røde, 844 grønne) · la
wheel-filnavnet drifte til 1.0.0 (1 rød, 845 grønne). Restaurert fra scratchpad
+ shasum -c mellom hver. Bumpen selv var den andre mutasjonen: pyproject 1.0.0 →
1.1.0 gjorde README-gaten rød alene, før README ble rettet.

SECURITY.md: varslingsfrist (minst én minor-release og aldri under 30 dager
mellom kunngjøring og fjerning, med sikkerhetskritisk fjerning som uttalt
unntak). Støttetabellen er bevisst VERSJONSFRI — et release-nummer skrevet der
ville drevet ved neste tagg, altså samme defektklasse som gate 2 fanger.

CLAUDE.md beholdt på flaten med en engelsk innramming øverst (operatørvalg): den
sier hva fila er for en fremmed. Innholdet er repoets sterkeste bevis på at hver
beslutning er målt; å fjerne det ville fjernet bevis, ikke friksjon.

Versjon 1.1.0 — synket i pyproject, __init__, test_smoke og README-kommandoen.
1.0.0-treet kan ikke produsere en kjørbar wheel (force-include kom etter taggen,
målt: git show v1.0.0:pyproject.toml har den ikke), så en wheel hengt på den
utgivelsen ville vært nøyaktig den usanne påstanden denne fasen finnes for å
fjerne. Operatøren valgte bumpen framfor et vedlegg som ikke virker.

846 passed / 4 skipped (fra 837). ruff + format + mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ckyg3Pc6k7FRuR6fDGQLJ
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 06:57:25 +02:00
commit 28a420ab97
10 changed files with 246 additions and 10 deletions

View file

@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2026-08-14
The release that makes the distribution stand on its own. `1.0.0` shipped a framework that could
only run from a checkout; this one runs from an installed wheel and inside a Foundry-hosted
container, and it is the first release whose artefacts are published alongside it.
### Added
- `shared/` now travels **as packaged data**. The wheel carries a byte-identical mirror of the tree
under `portfolio_optimiser/_shared/`, and `shared_root()` resolves at call time in a fixed order:
`PORTFOLIO_SHARED_ROOT`, then the working tree's `shared/` when one exists, then the packaged
copy. The working tree stays authoritative in a checkout — that is what keeps the pull-only
subtree contract and the byte-exact goldens untouched. Measured before and after: the `1.0.0`
wheel carried 58 files and none under `shared/`; this one carries 122, of which 64 are the mirror.
- A **hosted entry point**: `main.py` wraps `run_project` on a single asyncio loop and serves the
Foundry hosting contract (`GET /readiness`, `POST /invocations`, SIGTERM → exit 0), with a
`Dockerfile` and `azure.yaml` beside it. The invocation payload is whitelisted against
`run_project`'s own signature — an unknown field is refused by name with a 400 rather than
silently dropped — and a rejected proposal is a successful run (200), because the negative outcome
belongs to the payload and never to the transport.
- Wheel-install instructions in the README. A wheel is not installable on its own: two dependencies
are pinned to git tags, and `[tool.uv.sources]` does not travel with wheel metadata, so both must
be supplied as requirements alongside the wheel. The published command is the one that was
measured (65 packages, exit 0), not one composed afterwards.
- Two gates on claims the **published surface** makes about itself: that `env.template` names the
credentials `backends.py` actually constructs, and that the README's wheel-install command spells
the version the build stamps on the file. Both read raw text and are line-anchored, because prose
is the only place these claims live.
### Changed
- The AZURE profile now reads **its own environment** rather than the operator's laptop. The
endpoint resolves to the first non-empty of `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` and Foundry's
injected `FOUNDRY_PROJECT_ENDPOINT`; precedence applies to *values*, so an exported-but-empty name
falls through instead of masking a real one. The credential follows the same environment:
`AzureCliCredential` on a developer host, `ManagedIdentityCredential` when
`FOUNDRY_HOSTING_ENVIRONMENT` holds a non-empty value — never `DefaultAzureCredential`, whose
probing would walk a credential chain that cannot succeed in a container and turn a config error
into a slow one.
- `SECURITY.md` states a **deprecation notice period**: at least one minor release and no fewer than
30 days between announcement and removal, with security-critical removals named as the explicit
exception. The supported-versions table is deliberately version-free, since a release number
written there would drift at the next tag.
- The README links the organisation's single canonical `GOVERNANCE.md` instead of vendoring a copy,
and states the maintenance model (solo-maintained, no SLA, fork-and-own) on the first screen.
- `CLAUDE.md` opens with an English note explaining what the file is for a visitor: the working
agreement with the AI agent that builds this repository, doubling as its invariant ledger.
### Fixed
- `env.template` claimed the AZURE profile resolved its credential through `DefaultAzureCredential`.
It never has. An operator copying the template was told the wrong identity would be used.
## [1.0.0] - 2026-08-12
### Added

View file

@ -1,5 +1,15 @@
# portfolio-optimiser
> **Note for visitors — what this file is.** This is the working agreement between the repository
> and the AI coding agent that builds it (the [Claude Code](https://claude.com/claude-code)
> convention), and it is written in Norwegian because that is the maintainer's working language.
> It doubles as the repository's **invariant ledger**: each block below records a design decision,
> the measurement that forced it, and the test that turns red when the decision is undone.
>
> **You need none of it to use the framework** — start with the [README](README.md). It is
> published anyway, because the reasoning behind a decision is worth more than the decision, and
> because a rule kept out of sight is a rule that drifts without anyone noticing.
## Kontekst
Generisk, åpent Python-rammeverk på **Microsoft Agent Framework (MAF)** som finner kostnadsbesparelser INNI hvert prosjekt i en portefølje av uavhengige prosjekter. Multi-agent samarbeid genererer kandidat-tiltak; en obligatorisk deterministisk validator avgjør verdiene; fageksperter vurderer via HITL, og systemet lærer av dommene. Publiseres på Forgejo for alle som har MAF.

View file

@ -12,6 +12,14 @@ generates candidate measures; a **mandatory deterministic validator** (solver +
decides the numbers; domain experts judge the outcomes (human-in-the-loop); and the system
**learns from their verdicts** across runs.
> **Solo-maintained, fork-and-own.** This is a starting point, not a vendor product. One
> maintainer, no SLA, MIT licensed. Issues are welcome as signals; pull requests are not accepted.
> See the [organisation governance](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md)
> for the full model — including what to adopt instead if you need vendor accountability.
*AI-generated: all code produced by Claude Code through dialog-driven development, with human
review, test and judgement before anything ships.*
## Install
Python ≥3.10, with [`uv`](https://docs.astral.sh/uv/). The package is not published to a package
@ -38,7 +46,7 @@ that does not travel with wheel metadata — so the wheel names `llm-ingestion-o
alongside the wheel (measured: 65 packages, exit 0):
```bash
uv pip install portfolio_optimiser-1.0.0-py3-none-any.whl \
uv pip install portfolio_optimiser-1.1.0-py3-none-any.whl \
"llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.3.2" \
"llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.3.4"
```

View file

@ -24,10 +24,31 @@ Include:
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |
| < latest| :x: |
Support follows the tags, not a calendar, and the table is deliberately version-free — a release
number written here would drift the moment the next tag lands.
| Version | Security fixes | What that means |
| ------- | -------------- | --------------- |
| Newest tagged release | :white_check_mark: best-effort | Fixes land on `main` and ship in the next tag |
| Every earlier tag | :x: | Earlier tags are never re-released. Upgrade, or fork and patch |
There is no long-term-support branch and no backporting. One maintainer, best-effort.
## Deprecation Notice Period
When a supported surface is removed, or a dependency stops receiving security fixes:
- The deprecation is announced in [`CHANGELOG.md`](CHANGELOG.md) under the release that introduces
it, and repeated in the release notes on the forge.
- **At least one minor release — and no fewer than 30 days — passes between that announcement and
the removal**, so anyone reading the changelog has a version to move to before the old one goes.
- **The stated exception is a security-critical removal.** If leaving a surface in place is itself
the risk, it goes in the next release and the changelog says plainly why the notice period was
not used. This has not happened so far.
This is a notice period, not an SLA. See the
[organisation governance](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md)
for what this project does and does not promise.
## Security Best Practices

View file

@ -18,5 +18,13 @@ PORTFOLIO_LOCAL_API_KEY=ollama # local servers accept any
# --- AZURE / Foundry profile (targeted verification only — D6) ---
# Deployment (model) names are tenant-specific and live in data/model_map.json (azure.*).
# Credential resolves via Azure DefaultAzureCredential (`az login` / managed identity).
# The credential is chosen by the ENVIRONMENT and is not configured here:
# * developer host -> AzureCliCredential; constructing it acquires no token, so `az login`
# stays your own manual step (docs/2026-07-15-foundry-auth-recipe.md).
# * Foundry hosted agent -> ManagedIdentityCredential, selected when FOUNDRY_HOSTING_ENVIRONMENT
# holds a non-empty value. A container has no Azure CLI; the platform
# mints the agent a dedicated Entra identity at deploy time instead.
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT= # tenant-specific Foundry project endpoint (operator supplies)
# Hosted only: Foundry injects FOUNDRY_PROJECT_ENDPOINT itself, and the framework reads it as a
# fallback. The name above wins whenever it holds a non-empty value, so an explicit export is never
# shadowed by a platform default — and an exported-but-empty name falls through rather than masking.

View file

@ -1,6 +1,6 @@
[project]
name = "portfolio-optimiser"
version = "1.0.0"
version = "1.1.0"
description = "Generic framework on Microsoft Agent Framework for per-project cost-savings optimization"
readme = "README.md"
requires-python = ">=3.10"

View file

@ -26,7 +26,7 @@ from portfolio_optimiser.run import (
run_project,
)
__version__ = "1.0.0"
__version__ = "1.1.0"
__all__ = [
# Portfolio orchestration

View file

@ -0,0 +1,139 @@
"""Load-bearing gates on two claims the PUBLISHED surface makes about itself.
AAA+ criterion A5 is that no claim on the public surface is untrue. Two of this repo's claims are
made in prose that no test could see, and both drift silently:
1. ``env.template`` tells the reader which credential the AZURE profile resolves. It said
``DefaultAzureCredential`` while :mod:`portfolio_optimiser.backends` has never constructed one
Fase 4b picks ``ManagedIdentityCredential`` or ``AzureCliCredential`` by environment, and Learn's
MAF guidance names the specific credential *over* ``DefaultAzureCredential`` deliberately.
2. ``README.md`` publishes a wheel-install command that spells the wheel's FILENAME, and a wheel
filename carries the version. A version bump moves the file the build produces without touching
the README, leaving a stranger with an install command for a file that does not exist.
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).
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
proves nothing.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[1]
_ENV_TEMPLATE = _REPO_ROOT / "env.template"
_README = _REPO_ROOT / "README.md"
_PYPROJECT = _REPO_ROOT / "pyproject.toml"
_BACKENDS = _REPO_ROOT / "src" / "portfolio_optimiser" / "backends.py"
# The credential is chosen on ONE assignment statement. Reading the credential names off that line —
# rather than off the whole module — is what keeps the explanatory comments out of the measurement.
_CREDENTIAL_ASSIGNMENT = re.compile(r"^\s*credential\s*=\s*(?P<expr>.+)$", re.MULTILINE)
_CREDENTIAL_CALL = re.compile(r"(\w*Credential)\s*\(")
# A wheel filename spells the distribution, the version and the tags. The version is the drifting part.
_WHEEL_FILENAME = re.compile(r"portfolio_optimiser-(?P<version>[0-9][^-\s]*)-py3-none-any\.whl")
# `[project]`'s own version line: the value hatchling stamps into the wheel filename.
_PROJECT_VERSION = re.compile(r'^version\s*=\s*"(?P<version>[^"]+)"', re.MULTILINE)
def _constructed_credentials() -> set[str]:
"""The credential classes ``backends.py`` actually constructs, read off the assignment line."""
match = _CREDENTIAL_ASSIGNMENT.search(_BACKENDS.read_text(encoding="utf-8"))
if match is None:
return set()
return set(_CREDENTIAL_CALL.findall(match.group("expr")))
def _built_version() -> str:
match = _PROJECT_VERSION.search(_PYPROJECT.read_text(encoding="utf-8"))
assert match is not None, "pyproject.toml has no [project] version line"
return match.group("version")
# --------------------------------------------------------------------------------------------
# Control: the extractors find something. Without these, every gate below could pass vacuously.
# --------------------------------------------------------------------------------------------
def test_credential_extractor_finds_the_assignment() -> None:
"""CONTROL. If the assignment is reshaped, the gates below must fail loudly, not silently pass."""
constructed = _constructed_credentials()
assert len(constructed) == 2, (
"expected backends.py to construct exactly two credentials on one assignment; "
f"the extractor found {sorted(constructed)}"
)
assert all(name.endswith("Credential") for name in constructed)
def test_readme_publishes_a_wheel_install_command() -> None:
"""CONTROL. The version gate below is only meaningful while the README names a wheel file."""
found = _WHEEL_FILENAME.findall(_README.read_text(encoding="utf-8"))
assert found, (
"README.md no longer spells a wheel filename — the version gate has nothing to guard"
)
# --------------------------------------------------------------------------------------------
# A5 — env.template describes the credential the code actually uses
# --------------------------------------------------------------------------------------------
def test_env_template_names_the_credentials_backends_constructs() -> None:
"""Every credential the AZURE path can construct is named in the template the operator copies."""
template = _ENV_TEMPLATE.read_text(encoding="utf-8")
missing = sorted(name for name in _constructed_credentials() if name not in template)
assert not missing, (
f"env.template does not name {missing}, which backends.py constructs. An operator "
"reading the template cannot tell which identity the AZURE profile will use."
)
def test_env_template_does_not_claim_default_azure_credential() -> None:
"""No line may present ``DefaultAzureCredential`` as the resolution mechanism.
Line-anchored on purpose. ``backends.py`` names the class in prose to explain why it is NOT
used, and that explanation is legitimate; what is not legitimate is the template telling an
operator that resolution goes through a credential the code never constructs.
"""
offending = [
f"env.template:{number}: {line.strip()}"
for number, line in enumerate(_ENV_TEMPLATE.read_text(encoding="utf-8").splitlines(), 1)
if "DefaultAzureCredential" in line
]
assert not offending, (
"env.template claims a credential backends.py never constructs:\n" + "\n".join(offending)
)
# --------------------------------------------------------------------------------------------
# A5 — the published install command names the file the build actually produces
# --------------------------------------------------------------------------------------------
def test_readme_wheel_command_cites_the_built_version() -> None:
"""The wheel filename in the README must carry the version hatchling will stamp on it."""
built = _built_version()
cited = sorted(set(_WHEEL_FILENAME.findall(_README.read_text(encoding="utf-8"))))
drifted = [version for version in cited if version != built]
assert not drifted, (
f"README.md tells a reader to install portfolio_optimiser-{drifted[0]}-py3-none-any.whl, "
f"but the build produces version {built}. The published install command names a file that "
"does not exist."
)
@pytest.mark.parametrize("artefact", [_ENV_TEMPLATE, _README, _PYPROJECT, _BACKENDS])
def test_guarded_artefacts_exist(artefact: Path) -> None:
"""CONTROL. A missing artefact must fail here rather than turn a gate into a no-op."""
assert artefact.is_file(), f"{artefact} is missing; the gates above would read nothing"

View file

@ -2,4 +2,4 @@ from portfolio_optimiser import __version__
def test_version():
assert __version__ == "1.0.0"
assert __version__ == "1.1.0"

2
uv.lock generated
View file

@ -1487,7 +1487,7 @@ wheels = [
[[package]]
name = "portfolio-optimiser"
version = "1.0.0"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "agent-framework-core" },