Skip to content

Commit cc930f1

Browse files
Merge branch 'main' into dependabot/github_actions/actions-minor-patch-bfe9f83554
2 parents 4ec9ed3 + 14f93d4 commit cc930f1

9 files changed

Lines changed: 839 additions & 38 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,67 @@ All notable changes to AgentFlow are documented in this file.
44

55
## [Unreleased]
66

7+
### Quality — the auth-manager mutation threshold ratchets to 0.90
8+
9+
* **`serving/api/auth/manager.py` now gates at 0.90.** CI run 34542418689 on
10+
77825f6 scored it 98.4% (553 killed of 562). The nine survivors are the
11+
named residue in `tests/unit/test_auth_manager_mutation.py`. The old
12+
comment above its `ModuleTarget` in `scripts/mutation_report.py` called
13+
the structured-logging and Redis-URL survivors equivalents. T-48's
14+
behaviour tests killed all of them except the upper-cased Redis URL
15+
default, which is one of the nine. The comment now gives the measured
16+
score and names the nine equivalent survivors, pointing to the test
17+
file's docstring for their reasons. 0.90 is the bar the other serving
18+
modules hold, and it sits 8.4 points under the measured 98.4%, so the
19+
nine named equivalents cannot fail the gate while a real loss of killed
20+
mutants still does.
21+
22+
### Fixed — mutation_local no longer scores a missing pytest temp directory as a kill
23+
24+
* **`scripts/mutation_local.py` recorded every mutant that reached a
25+
`tmp_path` test as killed.** `measure_module()` handed pytest a `--basetemp`
26+
under a scratch directory it never created, and pytest `mkdir`s that path
27+
without parents, so the first `tmp_path` fixture failed at setup and pytest
28+
exited 1. Under `-x` that false kill covered every mutant that survived the
29+
tests before it. `run_mutant()` now creates the `--basetemp` parent before it
30+
starts pytest, so a mutant that reaches a passing `tmp_path` test is scored
31+
survived, and a real failure is still a kill.
32+
33+
### Quality — the auth-manager mutation lane pins what its survivors changed
34+
35+
* **`serving/api/auth/manager.py` sat two points above its 0.80 threshold.**
36+
CI run 34464697021 on 6231b73 scored it 82.1% (430 killed of 524). Most of
37+
the 94 survivors changed something a behaviour test can see, including the
38+
logging and Redis-URL mutants that `scripts/mutation_report.py` calls
39+
equivalents: a log event is the operator's interface, and the Redis URL
40+
decides which server holds the rate-limit budget.
41+
`tests/unit/test_auth_manager_mutation.py` now pins the key-store
42+
writability probe, read-only file included; the construction wiring (the
43+
injected store and audit publisher, usage rows flowing through the real
44+
`UsageWriter`, the default store following `db_path`, the security config
45+
path at construction and on `load()`, the grace-period fallback and its
46+
warning, the Redis server the limiter targets); the `load()` log events
47+
`api_keys_loaded`, `api_key_store_write_skipped_readonly` and
48+
`hashed_key_count_exceeds_guidance` (strictly above the soft limit,
49+
unindexed entries only); what `load()` writes back, and the key file it
50+
leaves byte-for-byte alone; that a match's slot comes from the material that
51+
matched, never from the stored entry; that `load()` carries rate-limit
52+
windows over by bucket; scan order in `authenticate()`; when
53+
`check_rate_limit()` trusts Redis; and `AGENTFLOW_API_KEYS` parsing.
54+
* **Two simplifications remove mutants no test could kill.** The writability
55+
probe opens the key file in binary append mode: the text layer and its
56+
`encoding="utf-8"` did nothing for an open-and-close probe, and three
57+
mutants changed only them. `_legacy_env_keys()` stops passing
58+
`allowed_entity_types` at `TenantKey`'s own default (`None`);
59+
`rate_limit_rpm=DEFAULT_RATE_LIMIT_RPM` stays because `TenantKey`'s Field
60+
default is evaluated at import, while a monkeypatch of the module global
61+
is a load-time read.
62+
* **The residue is named, not suppressed.** Nine equivalent mutants stay
63+
alive, each on a line that also carries killable mutants, so a line-level
64+
`# pragma: no mutate` would silence those too; the test file's docstring
65+
lists them with their reasons. The threshold is unchanged here: it is set
66+
from the CI measurement of this tree.
67+
768
### Fixed — a key configured without a key_id keeps the same id on every load
869

970
A key whose configuration carries no `key_id` — every key from

‎pyproject.toml‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -338,13 +338,9 @@ asyncio_mode = "auto"
338338
# starting with `src.`, which (not duckdb) was the real blocker. (The gate runner
339339
# keeps mutmut workspaces free of relative pytest --basetemp overrides because
340340
# under py3.11 they break coverage->mutant mapping for file-I/O targets like
341-
# key_rotation; see scripts/mutation_report.py.) manager.py runs at an honest 0.80
342-
# threshold (not 0.90): it is a
343-
# large stateful auth class whose residual survivors are equivalent mutants
344-
# (structured-logging args, model_copy updates equal to their defaults, redis-url
345-
# strings masked by the `_redis = None` override, the env-only-dead write path) --
346-
# every behaviour-reachable mutant, including every auth-bypass and throttle
347-
# off-by-one, is killed. See scripts/mutation_report.py.
341+
# key_rotation; see scripts/mutation_report.py.) manager.py gates at 0.90 like
342+
# the other serving modules; scripts/mutation_report.py gives its measured
343+
# score and names its nine equivalent survivors.
348344
paths_to_mutate = [
349345
"src/agentflow_runtime/serving/api/auth/manager.py",
350346
"src/agentflow_runtime/serving/api/auth/key_rotation.py",

‎scripts/mutation_local.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,8 +495,12 @@ def run_mutant(
495495
496496
Each mutant gets its own `--basetemp`: pytest wipes and recreates that
497497
directory at startup, so concurrent runs sharing one abort each other and
498-
exit 2.
498+
exit 2. pytest creates `--basetemp` itself with a non-recursive mkdir, so
499+
the parent has to exist before pytest starts -- otherwise the first
500+
`tmp_path` fixture errors at setup, pytest exits 1, and the mutant is
501+
scored killed.
499502
"""
503+
basetemp.parent.mkdir(parents=True, exist_ok=True)
500504
command = [
501505
python,
502506
"-m",

‎scripts/mutation_report.py‎

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,23 +76,22 @@ class ModuleTarget:
7676
threshold=0.90,
7777
tests=("tests/unit/test_nl_queries_mutation.py",),
7878
),
79-
# manager.py runs at 0.80, not the 0.90 the pure-function guards (sql_guard,
80-
# sql_builder, ...) hold. It is a ~400-line stateful auth class whose
81-
# surviving mutants are dominated by EQUIVALENTS that no behaviour-level test
82-
# can kill: structured-logging arguments (the auth logger event names / kwargs),
83-
# `model_copy(update=...)` dicts whose mutated field equals its default
84-
# ("matched_slot" already defaults to "current"; "key"==api_key on a plaintext
85-
# match), the redis-url strings masked by the `_redis = None` override under the
86-
# duckdb-free harness, and the config-file write path that is dead under the
87-
# env-only test. Every BEHAVIOUR-reachable mutant is killed -- crucially every
88-
# auth bypass (the verify_api_key argument-swap mutants on the indexed / legacy
89-
# / previous-key paths and in _matches_key_material) and every rate-limit /
90-
# failed-auth throttle off-by-one. Local mutmut (py3.10) scores 405/483 = 83.9%;
91-
# 0.80 leaves headroom for equivalent-mutant noise while still enforcing a real
92-
# floor (the do-nothing baseline was 76.5%). key_rotation is the next target and
93-
# stays declared-only until it gets its own duckdb-free test.
79+
# manager.py scored 553/562 killed (98.4%) on CI run 34542418689 against
80+
# commit 77825f6. The nine survivors are the named residue in the module
81+
# docstring of tests/unit/test_auth_manager_mutation.py, grouped:
82+
# __init__ 55 and 93 -- _key_store_readonly_skip_logged flag and the
83+
# upper-cased Redis URL default;
84+
# _load_config 3 and 5 -- encoding="utf-8" case / None;
85+
# _sweep_expired_windows 10 and 20 -- pop(key, None) race guard;
86+
# authenticate 10 and 11 -- model_copy of a just-proven-equal key;
87+
# load 1 -- skipped_readonly_write truthiness. The docstring has the
88+
# one-line reason for each. Every behaviour-reachable mutant is killed.
89+
# The threshold is 0.90 because that is the bar the other serving modules
90+
# hold; it sits 8.4 points under the measured 98.4%, so the nine named
91+
# equivalents cannot fail the gate while a real loss of killed mutants
92+
# still does.
9493
Path("serving/api/auth/manager.py"): ModuleTarget(
95-
threshold=0.80,
94+
threshold=0.90,
9695
tests=("tests/unit/test_auth_manager_mutation.py",),
9796
),
9897
# key_rotation runs at 0.90. Its residual survivors (local mutmut: 21 of 365)

‎src/agentflow_runtime/serving/api/auth/manager.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ def probe_key_store_writable(path: Path | str | None) -> bool:
133133

134134
def _file_is_writable(path: Path) -> bool:
135135
try:
136-
with path.open("a", encoding="utf-8"):
136+
# Binary: an open-and-close probe has no text to encode.
137+
with path.open("ab"):
137138
return True
138139
except OSError as exc:
139140
if is_permission_denied(exc):
@@ -708,7 +709,6 @@ def _legacy_env_keys(self) -> list[TenantKey]:
708709
name=name.strip(),
709710
tenant="default",
710711
rate_limit_rpm=DEFAULT_RATE_LIMIT_RPM,
711-
allowed_entity_types=None,
712712
created_at=datetime.now(UTC).date(),
713713
)
714714
)

‎tests/unit/test_auth_manager_memory_bounds.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,12 @@ def test_load_sweeps_expired_rate_windows(self, manager: AuthManager) -> None:
6060
manager._rate_windows["fresh-key"] = [now - 5]
6161
manager.load() # triggers _sweep_expired_windows under config lock
6262
assert "stale-key" not in manager._rate_windows
63-
# 'fresh-key' is also removed because load() rebuilds _rate_windows
64-
# from keys_by_value (none configured in this fixture) — that
65-
# rebuild is documented behaviour from session 17 and is independent
66-
# of the H-C4 sweep, but the assertion below pins it so a future
67-
# refactor that reuses the old `defaultdict` cannot reintroduce
68-
# the unbounded-growth path silently.
63+
# 'fresh-key' is also removed: load() keeps only the windows whose
64+
# bucket belongs to a still-configured key, and no configured key
65+
# names 'fresh-key' (the fixture configures none). That carry-over is
66+
# independent of the H-C4 sweep, but the assertion below pins it so a
67+
# future refactor that keeps every window cannot reintroduce the
68+
# unbounded-growth path silently.
6969
assert "fresh-key" not in manager._rate_windows
7070

7171

0 commit comments

Comments
 (0)