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:
parent
9a869baf8a
commit
28a420ab97
10 changed files with 246 additions and 10 deletions
50
CHANGELOG.md
50
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
29
SECURITY.md
29
SECURITY.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
10
env.template
10
env.template
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from portfolio_optimiser.run import (
|
|||
run_project,
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
__all__ = [
|
||||
# Portfolio orchestration
|
||||
|
|
|
|||
139
tests/test_public_surface_claims_loadbearing.py
Normal file
139
tests/test_public_surface_claims_loadbearing.py
Normal 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"
|
||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -1487,7 +1487,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "portfolio-optimiser"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue