b1 lane (PARTIAL, not a supersession of #432): interpreter proof, ASVS payload types, auth+store, doc-lane gates - #487
b1 lane (PARTIAL, not a supersession of #432): interpreter proof, ASVS payload types, auth+store, doc-lane gates#487wshallwshall wants to merge 45 commits into
Conversation
…CKLOG #1245) Bootstrap auto-retirement reads must_change_password as "never claimed". That holds only while the flag has one writer; admin_reset_password re-raises it, so a reset makes a claimed account look unclaimed and the next trigger disables it. "Never claimed" is monotonic. must_change_password is not -- rotation clears it, a reset re-sets it -- and no non-monotonic bit can encode a monotonic predicate across a re-set. So record the fact instead of inferring it. password_claim_set() states the single-writer rule once for all three backends: a set_password carrying must_change_password False is a credential the holder chose, and only that call records the claim. A caller passing True gets an EMPTY term, so the column is absent from the SET list and can be neither stamped nor cleared. COALESCE makes the claim write-once. The read is a hard subscript, deliberately unlike its .get() neighbours: a missing key would decode as None, None means "never claimed", and that would retire a claimed account -- this defect, re-introduced by its own fix. Backfill sits INSIDE each column-creation guard. Hoisted out it becomes a permanent second writer, which is exactly what #1245 documents. Three guards, three mechanisms: PRAGMA check (SQLite), users_cols plus a _MIGRATION_REV bump (Postgres -- without it an open DB takes the hash fast path, never gains the column, and every user read then raises), COL_LENGTH with the backfill deferred through EXEC (SQL Server -- a statement naming a column added earlier in the same batch fails to compile). Verified: ruff format --check 265 files, ruff check messagefoundry, mypy strict 265 files, and 152 passed across test_auth_service/test_store/test_last_admin_guard/ test_api_auth. Those four paths were NAMED, which overrides testpaths and does not run the console suite. Full suite not yet run.
…lag (BACKLOG #1245)
Both WP-3 gates open-coded the same claimed-ness test against
must_change_password. Route both through one _unclaimed_bootstrap() predicate
that reads the recorded password_claimed_at instead.
Two copies of one lifecycle test is how bootstrap_expiry_warning silently
inherited this defect: a fix scoped to _retire_superseded_bootstrap alone would
have greened every test while still telling an operator that a claimed, in-use
admin account expires in N hours.
admin_reset_password is untouched and byte-identical. That is a constraint, not
an omission -- suppressing the flag on the reset path would mean an admin reset
no longer forces rotation, breaking the ASVS 6.4.6 property that path exists to
provide. The defect's own wording most naturally suggests exactly that fix, and
it is the wrong one.
The docstring at :579-581 asserted the property this defect defeats ("this can't
lock out a legitimate single-admin deployment") and is corrected here rather
than in a follow-up: it is the reason nobody looked.
Verified: ruff format --check 265 files, ruff check messagefoundry, mypy strict
265 files, 152 passed across four NAMED test paths (which overrides testpaths
and does not run the console suite). Full suite not yet run.
…ACKLOG #1245) SECURITY.md:58-59 and :1164 restated the same false safety property as the code docstring, in the document operators actually read. The item scoped the fix to the docstring; that fixes the weakest instance and leaves the strongest. Both now say the claim is RECORDED (users.password_claimed_at, stamped by self-service rotation and never cleared) rather than inferred from current credential state, and that an admin reset does not un-claim. OUT OF SCOPE AND DELIBERATELY UNTOUCHED: :1828-1829, the HIPAA 164.312 emergency-access nomination. It does not restate the false property -- it nominates this account as a required compliance control, which is a product and compliance judgement with two defensible answers, not a factual fix. Routed to the owner via the Liaison seat. Correcting :58-59 makes :1828 read differently without making it right. Zero deployments (CLAUDE.md section 0), so this describes what a first deployment would hit, not a live exposure.
… it (BACKLOG #1245) The durable output of #1245 is not the column, it is the invariant: a monotonic lifecycle fact must be RECORDED with exactly one structurally-constrained writer, never INFERRED from mutable credential state. The argument for an ADR rather than a comment is failure history. The property was already asserted in prose in two places -- the docstring and SECURITY.md -- and prose is exactly what failed. Bootstrap retirement (WP-3) had no ADR; it lived only in SECURITY.md, which is how the guarantee drifted from the code with nothing reporting it. Records both rejected alternatives with the measurement that killed them, because both are re-proposable and both fail non-obviously: - last_login_at is genuinely unforgeable by the reset, but is written AFTER the credential check, so a bootstrap that logs in once with the printed one-time password and never rotates becomes permanently non-retirable -- silently deleting the ASVS 6.4.5 expiry arm for exactly the case it exists to cover. Unrepairable within its own inputs: a reset re-arms BOTH of them. - password_changed_at != created_at fails because the reset refreshes it. Also records what this does NOT fix: the stacked username-as-identity proxy, so any local account named "admin" remains subject to retirement and delete-and-recreate still mints a second silently-dead account. Narrowed, not removed. Filed separately as content; no number allocated by me for it. Number allocated via scripts/coord/alloc.ps1 to this worktree; index row in this same commit, as the ledger gate requires.
…shortcut (BACKLOG #1245) Four new tests plus a shared _claim_bootstrap() helper, covering all three retirement triggers after an admin reset of a CLAIMED bootstrap -- the victim's own next login, an engine restart, and create_local_user -- plus the warn path, which is the second reader of the same lifecycle test and would otherwise stay blind to a fix scoped to the retirement gate alone. The load-bearing rule: every one of them claims the bootstrap by LOGGING IN AND ROTATING through the service, never by a direct store.set_password. The existing tests faked the claim with a direct store write, and that shortcut is exactly why a second writer of must_change_password was invisible to this suite. test_unclaimed_bootstrap_is_still_retired_after_an_admin_reset is the negative control and is deliberately NOT an xfail: it reds if anyone "fixes" this by special-casing the reset path, which is the wrong fix the item's own wording suggests. NO strict-xfail marker anywhere. The item specified one because it assumed the test would land before the fix. Both are in this branch, so the tests PASS, and a strict xfail on a passing test XPASSes and reds the suite. Recording the deviation rather than carrying a marker that would be wrong on arrival. Verified: 34 passed in tests/test_auth_service.py, and 152 passed across four NAMED paths (test_auth_service, test_store, test_last_admin_guard, test_api_auth) -- naming paths overrides testpaths, so the console suite did NOT run. mypy strict 265 files clean, ruff clean. NOT YET RUN: the full suite, the controlled revert for attribution, and the adversarial review of this diff. Do not price #1245 as fully verified.
…gine (BACKLOG #1245) Owner ruling, 2026-08-13, routed via the Liaison seat: "I don't think interface engines need break the glass. That is for EHRs." The HIPAA 164.312 row nominated the bootstrap admin as the emergency-access control. That nomination is what made #1245 sharp -- a sealed break-glass credential is precisely the one an administrator later resets, and the documented remedy applied to the documented break-glass account would have destroyed it on a first deployment. A compensating control resting on a false premise. The owner rejected the premise both options shared rather than choosing between them: no mechanism is nominated, because this component does not need one. Break- glass exists so a clinician can reach a patient record when normal authorisation would refuse. This engine holds no point-of-care record; the record of authority lives in the systems on either side, and that is where the path belongs. Marked not-applicable WITH the reason rather than dropped, so the row still answers the requirement it sits under. SCOPE, and deliberately narrow: this is a ruling about break-glass in an interface engine and about THIS row. It is NOT a precedent for any other HIPAA row or compliance nomination. The same shape found elsewhere is a new item. One question this removes rather than answers: whether any alternative break- glass mechanism ships in the code. Nobody measured it and nobody now needs to. :58-59 and :1164 are unchanged by this commit -- they were the separate factual fix and landed in 711a9b5.
…irectory admin (BACKLOG #1245) Blocker found by the adversarial review of my own diff, and it is the change-that-reads-as-hardening-but-deletes-a-control shape. The retired must_change_password test excluded a directory-provisioned account BY ACCIDENT: such a row has no password, so it carries the flag False, and the old predicate returned early on exactly that. password_claimed_at reproduces no such side effect -- a federated row has no stamp either, which reads as "never claimed". So replacing the flag without this guard would newly auto-disable a real AD administrator named "admin", revoke its sessions, and audit it as auth.bootstrap_admin_retired -- an account the directory still considers valid, disabled by the engine with no directory-side signal. That is the same lockout class WP-3's own fix exists to prevent, re-opened on a different row shape by the fix itself. Reachable because the bootstrap is minted only when the store is EMPTY: a directory-first install or a DR restore leaves the username free. The test pins it through the public path -- start the engine against a non-empty store (seeds roles, mints no bootstrap, fires the :518 retirement trigger), then add a second administrator for the supersession arm. It carries a positive control asserting the row really is in the shape that used to be protected by accident (provider ad, no password_hash, flag False, no stamp), so a green cannot mean the fixture built the wrong row. It also asserts the advisory warner stays silent, because that path asks the same question. RED-FIRST, verified rather than assumed: with the guard, 35 passed. Removing ONLY the guard (needle confirmed absent, 85 bytes) reds this test alone. Restored byte-identical, 35 passed again. ruff and mypy strict clean. Three blockers from that review remain open and are recorded with fix directions in the shared episode note; this branch is still NOT landable.
… (BACKLOG #1245) Blocker from the adversarial review. The one-time backfill is the arm that can disable the only administrator of an UPGRADED database, and it was executed by no test on any backend: every test opens ":memory:", which creates users WITH the column from _SCHEMA, so the guarded migration branch is never entered. A grep for password_claimed_at over tests/ returned one hit, a docstring. Deleting the backfill outright reddened nothing. This drives the real path: claim the bootstrap through the service, manufacture a pre-#1245 database, reopen, and assert the claim came back. Three things the test does deliberately: - REBUILDS the table rather than ALTER ... DROP COLUMN. SQLite re-parses the stored CREATE TABLE text after a drop, and the trailing comment this change puts on the final column makes that reconstruction fail with "incomplete input". Measured, not anticipated. - DERIVES the retained column list from the live table, so it does not hard-code a schema that will drift. - Asserts the legacy shape was really manufactured before relying on it. Without that, a green is equally consistent with the column never having been dropped. It also asserts the backfill restored the ORIGINAL claim instant rather than any non-NULL value: the stamp is evidence about WHEN the holder took the account, so a backfill writing "now" would satisfy a not-None assertion while destroying the fact. SCOPE, stated in the test rather than implied: it pins that the stamp is RESTORED. The consequence of a restored stamp is pinned by the sibling tests, and is not re-tested here because the rebuilt legacy table has no primary key and cannot carry the user_roles foreign key a second administrator needs. MUTATION-VERIFIED both directions: 36 passed with the backfill; deleting the UPDATE (191 bytes, needle confirmed absent) reds this test ALONE; restored byte-identical, 36 passed again. Two blockers remain open (the ADR's false "structural" claim, and the unbounded reset temp for a claimed bootstrap). This branch is still NOT landable.
…e claim (BACKLOG #1245)
Blocker from the adversarial review, and the sharpest one: ADR 0164 asserted the
single-writer property was "structural, not conventional" and that "you cannot
record a claim without being the authenticated holder". Both false, both measured.
set_password declared the CLAIMING value as its default, so a caller that merely
OMITTED the keyword recorded a claim -- demonstrated against a live store. And
scripts/security/dast_target.py already passes False outside self-service
rotation. Asserting a structural guarantee in the same paragraph that says a
conventional one is not good enough is SDS-3.7 inside the document written to
prevent SDS-3.7.
Fixed both halves rather than picking one, because either alone leaves a false
sentence standing:
- STRUCTURAL, for the accidental path: set_password's default flips to True
across the protocol and all three backends, so omission is now the SAFE branch
and a forgotten keyword can no longer stamp a claim. Verified first that all
four callers pass the argument explicitly, so this changes NO current behaviour
-- it bounds the next caller. create_user keeps False deliberately: it never
writes this column and AD provisioning depends on it.
- HONEST, for the deliberate path: the ADR now states that a caller can still pass
False, that the real invariant is "every caller passing False has just
authenticated the holder", and that what enforces it is review rather than the
type system. The retraction is KEPT rather than deleted, because the deleted
version is what a later reader would re-derive.
Also corrects password_claim_set's docstring from a closed enumeration ("and only
that call") to an "at least" floor, per SDS-3.6: the argon2 rehash-on-login path
is a second caller passing False. It is safe, and the docstring now says WHY
rather than denying it exists -- reaching it needs a verified password, and on any
row where the flag is False with a password set the stamp is already present.
mypy strict clean. A broad auth/store/migration slice was still running at commit
time and its result is NOT included here; the default flip is expected to be
behaviour-neutral by the caller audit above, and that expectation is not yet
confirmed by the slice.
B3 remains open: a claimed bootstrap's admin-reset temp has no expiry path. NOT
landable.
…G #1245) Last of the four blockers from the adversarial review, and the subtlest: this change CREATED the exposure by removing an accidental bound. The ASVS 6.4.1 initial-password expiry carves the bootstrap out entirely, on the stated premise that WP-3 gives that account its own deadline. That premise holds only while the account is UNCLAIMED. Once claimed, WP-3 deliberately stops covering it -- which is the whole point of the claimed-ness fix -- so after it, nothing bounded an admin-issued temp on the highest-privilege account name in the system. THE GAP WAS MASKED BY THE DEFECT ITSELF. Pre-#1245 the reset re-armed retirement and the account got disabled, so nobody noticed the temp had no deadline. Fixing #1245 removed that accidental bound, which is why the bound has to be put back deliberately rather than treated as a pre-existing gap. settings.py states the harm in the repo's own words: such a session's one permitted action is to set the password, i.e. account takeover. The carve-out is NARROWED, not deleted: an unclaimed bootstrap keeps it, because WP-3 really does own that account's lifecycle and a second deadline would be redundant. A claimed bootstrap is gated exactly like any other account. The comment that asserted the removed control is corrected in the same change -- it is the sentence that would otherwise keep the false premise alive. MUTATION-VERIFIED, AND THE RESULT IS ASYMMETRIC, which is what distinguishes a correction from a widening: 38 passed with the narrowing; restoring the OLD wide carve-out reds the claimed-bootstrap test while the unclaimed-carve-out test STILL PASSES (1 failed, 1 passed). A control that failed on both would not have told me which layer does the work. Restored byte-identical, 38 passed again. Both tests carry positive controls: the claimed case asserts the temp WORKS inside the window before asserting it fails outside, so the refusal cannot be an unusable credential; the unclaimed case asserts the account was never claimed before relying on the carve-out. ruff and mypy strict clean. ALL FOUR review blockers are now fixed.
… bound a credential (BACKLOG #1245) BLOCKER found by an adversarial review OF MY OWN BLOCKER FIX, and it is the same defect class I was fixing, one layer down. a6ea82b narrowed the ASVS 6.4.1 carve-out so a CLAIMED bootstrap is gated, and KEPT it for an unclaimed one on a premise I wrote in that commit: "WP-3 retires an unclaimed bootstrap on its own timer, so a second deadline here would be redundant." THAT PREMISE IS FALSE, and my own test proved it while asserting the opposite. service.py gates the WP-3 time arm on `expiry_hours > 0`, and `bootstrap_expiry_hours = 0` is a DOCUMENTED supported value. Measured before this fix, at bootstrap_expiry_hours=0 with a 72-hour 6.4.1 policy: the printed first-run administrator credential still logged in at 73 hours, at 8760 hours, and at 87600 hours -- ten years. A second reachable misconfiguration, expiry 8760 against a 72-hour policy, bounded it at 100x the stated policy. Neither is reported by security_loosenings(), so both were silent. WORSE, AND THE PART I WANT ON THE RECORD: the test I added in that same commit, test_an_unclaimed_bootstrap_temp_keeps_its_carve_out, set bootstrap_expiry_hours=0 IN ITS OWN FIXTURE -- disabling the very deadline its comment invoked as the reason the carve-out was safe -- and its stated purpose was to red if anyone removed the carve-out. That is a test locking a hole in as intended behaviour, which is the worst shape available, written into the commit whose purpose was closing a carve-out. THE FIX: remove the carve-out entirely. It conflated TWO CONTROLS -- WP-3 retires an ACCOUNT, 6.4.1 expires a CREDENTIAL, different triggers and different outcomes, and one is not a substitute for the other. Dropping it is near-behaviour-neutral at the stock 72/72 defaults, because a freshly minted bootstrap has created_at and password_changed_at within milliseconds, so 6.4.1 comes due at the same instant WP-3 retirement already does -- while closing both misconfigurations. RED-FIRST: the replacement test is parametrized over both reachable misconfigurations (0 and 8760) and BOTH params red against the pre-fix code. After the fix the original probe inverts at all three ages: 73h, 8760h, 87600h now refuse. ALSO REWRITTEN, not deleted: test_bootstrap_admin_is_not_gated_by_initial_password_ expiry, PRE-EXISTING on origin/main since 2026-07-30, asserted a 500-hour-old credential against a 1-HOUR policy as correct. It pinned the hole. Inverted with the retraction recorded in place, because the old assertion is what a later reader would re-derive. AND THE TWO OPERATOR-FACING SITES the previous commit missed, both still asserting the blanket exemption: settings.py's initial_password_expiry_hours docstring and docs/CONFIGURATION.md. My earlier commit message claimed "the comment" singular; there were three, and the one I corrected was the inline one an operator never reads. Verified: ruff, ruff format, mypy strict 265 files, 39 passed in tests/test_auth_service.py, and 1010 passed / 333 skipped / 0 failed across an auth/store/bootstrap/password/login slice.
…ACKLOG #1245) BLOCKER from an adversarial re-review of ef51a9d, and it is the same account-versus-credential conflation that commit says it removed -- surviving in the ONE field whose entire job is telling the operator when the credential dies. BootstrapAdmin.expires_at was computed from bootstrap_expiry_hours alone, and api/app.py writes it verbatim into bootstrap-admin.txt as "expires: <ISO> - sign in and change this password before then". Once ef51a9d stopped 6.4.1 carving the bootstrap out, that made the file LIE IN BOTH DIRECTIONS. Measured at HEAD before this commit: bootstrap_expiry_hours=0 file stated NO deadline, credential died at 72h bootstrap_expiry_hours=8760 file stated +8760h, credential died at 72h (121x) A file that OVERSTATES a deadline is worse than one that omits it: the operator plans around a date that is not real. Compounding it, the ASVS 6.4.5 warn window gated on bootstrap_expiry_hours <= 0 and so never fired at all for the deadline that now actually ends the credential. FIX: derive both the issued deadline and the warn window as the MINIMUM of the two bounds -- WP-3 retiring the ACCOUNT at created_at + bootstrap_expiry_hours, and ASVS 6.4.1 expiring the CREDENTIAL at password_changed_at + initial_password_expiry_hours. Each arm contributes nothing when its own setting is 0, so None survives as correct only when BOTH are off. Keying the warning off the same minimum means the two can never disagree. At stock 72/72 the surfaced value is UNCHANGED: a freshly minted bootstrap takes created_at and password_changed_at from one clock read, so the minimum is that shared instant. The reviewer measured them identical, delta exactly 0.0 in twelve probe rows -- the previous comment called that "within milliseconds", which understated a property the code guarantees exactly. ALSO REWRITTEN, not deleted, both with the retraction recorded in place: - test_bootstrap_admin_deadline_is_none_when_expiry_off, PRE-EXISTING on origin/main, set ONLY bootstrap_expiry_hours=0 and asserted no deadline while 6.4.1 sat at its default 72. It pinned the lie. Now asserts None only when BOTH bounds are off, plus the WP-3-off case where a deadline exists and must show. - A comment 45 lines above ef51a9d's own replacement test still described the carve-out premise as merely NARROW rather than false. That commit ran a hunt for surviving statements of the premise and missed one in the file it was rewriting. New test pins all four combinations and asserts the stated instant is never later than the credential actually survives. Verified: ruff, ruff format, mypy strict 265 files, 48 passed across test_auth_service + test_search_presets_api, and a four-configuration probe showing stated == earliest-real in every case.
…sets (BACKLOG #1233) delete_user removed user_roles, sessions, webauthn_credentials and the user row, but never search_presets. There is no FK cascade on that table, so the rows outlive the account -- carrying PHI-shaped `criteria` (ADR 0136) that no owner can reach, list or purge, and that nothing counts. Fixed in ALL THREE backends with the identical keyed DELETE. The item understated its own scope by two thirds: the four-DELETE body is verbatim in store.py, postgres.py and sqlserver.py, and the two server legs are CI-only, so a divergence between them surfaces first in CI rather than locally. Parity asserted by measurement -- one occurrence in each of the three, against a positive control (the webauthn DELETE) reading three in each. `owner` already holds Identity.user_id rather than the username (#1225, landed), which is what makes this a single keyed DELETE instead of a name lookup -- and a name lookup would have keyed a deletion on the reassignable value #1225 exists to stop trusting. THE ITEM NAMES THE WRONG BLOCKING TEST. It cites tests/test_retention.py, which mentions delete_user ZERO times (positive control: test_search_presets_api.py mentions it 3 times). The test that actually reds is test_a_recreated_username_does_not_inherit_the_departed_operators_presets. THAT TEST NEEDED CARE, NOT JUST A FLIPPED ASSERTION. Its precondition asserted "delete_user does not purge preset rows" -- the defect -- as the ENABLING HALF of #1225's scenario. After this fix its substantive assertions still pass, but TRIVIALLY: with the row purged there is nothing left to inherit, so it would have stopped being evidence about the KEY at all. #1233 would have silently retired #1225's only end-to-end coverage while looking green. So the test now asserts the purge (replacing the retracted precondition, recorded in place), then RESTORES the captured row and continues. That is not artificial: it is exactly the state of a database upgraded from before #1233, where orphan rows already exist and only the key protects them. Both controls stay pinned -- the row is gone, AND if it were not, the key would not match. MUTATION-VERIFIED: removing the SQLite purge (96 bytes) reds that test alone; restored byte-identical, 4 passed. The Postgres and SQL Server arms are UNVERIFIED locally -- those legs skip without MEFOR_TEST_POSTGRES / MEFOR_TEST_SQLSERVER. Verified: ruff, ruff format, mypy strict 265 files, 48 passed across test_search_presets_api + test_auth_service. NOT REVIEWED BY ANYONE BUT ME -- I wrote the fix and its test, which is the same channel. Wants an outside adversarial read before it lands.
…e ADR's backfill claim (BACKLOG #1245) Two residuals from the first adversarial review. SERVER LEGS. Neither test_postgres_store.py nor test_sqlserver_store.py called set_password at all -- confirmed by grep with a positive control (the same pattern returns a hit in test_store_backend.py, and create_user returns 2 in each suite). So the Postgres and SQL Server claim terms were executed by no test on any leg. That matters most on SQL Server, where the term splices a positional argument into the MIDDLE of a "?" parameter list and an off-by-one would silently write the claim timestamp into failed_attempts or updated_at. The shared password_claim_set helper covers the SQL TEXT; the argument binding is per-backend and hand-written. Both suites now round-trip set_password and assert the neighbouring columns held their values. THEY SKIP LOCALLY -- measured 300 skipped, 0 executed, without MEFOR_TEST_POSTGRES / MEFOR_TEST_SQLSERVER -- so their correctness is UNVERIFIED until a CI DB leg runs them. ADR 0164 BACKFILL BOUNDARY. The ADR presented the migration backfill without naming the case it cannot cover: its predicate is `must_change_password = 0 AND password_hash IS NOT NULL`, so it reconstructs a claim from the same mutable flag the ADR exists to stop trusting. A pre-upgrade bootstrap that was claimed and THEN admin-reset has the flag back to 1, is excluded, and reads unclaimed after upgrade -- pre-fix behaviour surviving the fix on that row. Inherent: the claim left no durable trace before the column existed, and password_changed_at is refreshed by every set_password, which is the same measurement that killed rejected alternative 2. Affected population is developer databases; a first deployment creates the column at mint. TWO LAYERING SLIPS OF MINE, recorded rather than rewritten. Four agents wrote this worktree concurrently and I staged whole files, so two commits carry work that belonged elsewhere: f414884 (#1233) also carries the postgres.py _MIGRATION_REV comment correction, and 5ed8ceb (#1245 deadline) also carries the COALESCE write-once test. Both are the same feature family and both are tested, but neither is one coherent layer. Amending would rewrite SHAs a review may already reference, which is the worse trade. Verified: 44 passed, 300 skipped across test_auth_service + the two DB suites; ruff, ruff format and mypy strict 265 files clean at the previous commit and unchanged by this one (docs and tests only).
…EMANTICALLY ONE CONFLICT, docs/SECURITY.md, and it was an INVALIDATED CLAIM rather than a competing edit. main carried PR #361 (the previous Builder 1's #1131 work), which documents #1245's defect as shipped behaviour -- correct for the tree it was written against: "both the expiry timer and the supersession check fire only while the bootstrap is still unclaimed, which is carried by the must_change_password flag -- so an administrative password reset, which re-sets that flag, RE-ARMS them." This branch makes that sentence FALSE. A mechanical keep-both-sides resolution would have shipped a security document asserting BOTH "a reset re-arms retirement" AND "a reset does not un-claim it", about the same defect, in the document operators read. NEITHER SIDE WAS RIGHT AS A WHOLE, which is why this was resolved by intent: - TAKEN (ours): the recorded-claim mechanism and the reset-does-not-un-claim consequence -- true of the tree after this branch. - DROPPED (theirs): "an administrative password reset ... re-arms them" -- this branch falsifies it. - KEPT (theirs): "auto-retirement is not the only way to lose an administrator: the failed-attempt lockout is a separate mechanism and it does reach a claimed sole administrator" -- #1131's contribution, orthogonal to #1245, still true. Taking "ours" wholesale would have silently dropped a true sentence another item paid for. L30 CONTROL RUN, with the expectation stated rather than assumed. docs/BACKLOG.md AUTO-merged (no conflict), and this branch touches it ZERO times across sixteen commits -- positive control: docs/SECURITY.md appears twice. So the correct expectation is not 0/0/0 but EQUALITY WITH MAIN, and parse_items gives 281 items / 206 open / 75 closed on the merged tree and 281/206/75 on origin/main's own copy. Identical. L30 SECOND CLAUSE: the merged paragraph was re-read end to end for what it now CLAIMS, not merely for absent markers. It is coherent -- recorded claim, reset does not un-claim, and the lockout named as a separate mechanism that does reach a claimed sole administrator.
…edential (BACKLOG #1245) The last three residuals from the adversarial reviews, all the same shape: sites that are true about the ACCOUNT and incomplete about the CREDENTIAL, which is the distinction #1245 turned on. settings.py bootstrap_expiry_hours and docs/CONFIGURATION.md both said "0 = no time expiry" without qualification. Since #1245 removed the bootstrap's carve-out from the ASVS 6.4.1 gate that is only true of the account: at 0, or at any value longer than initial_password_expiry_hours, the account survives while the printed credential still dies on the other clock. Both now say which of the two they bound, and name bootstrap-admin.txt as stating the earlier. security_loosenings() -- WRITTEN DECISION, not a fix. Its own docstring says the unreported set is "enumerated in the floor test's exemption set so the gap is a written decision that a new switch cannot silently join". initial_password_expiry_hours was unreported and #1245 made it LOAD-BEARING: it is now the only bound on the first-run administrator credential whenever bootstrap_expiry_hours is 0 or longer, so setting it to 0 unbounds that credential and nothing in the registry says so. Recorded rather than fixed, with the reason. It is not a [security] field, so the completeness floor (which iterates SecuritySettings.model_fields) never covered it and this is not a floor-test gap. Reporting it needs a new REQUIRED parameter -- every parameter there is required BY DESIGN, precisely so an optional detector cannot be added quietly -- which is a larger change than the item that exposed it. Handed to the dispatcher as content. This is the registry's own standard applied to itself: the gap was already there, #1245 changed what it costs, and the docstring demands that be written down rather than implied. Verified: ruff, ruff format, mypy strict 265 files, 187 passed across test_security_posture_defaults + test_settings + test_auth_service (the floor test gates this registry and still passes -- the change is docstring-only), and link_check.py "every relative link resolves".
…(BACKLOG #1020) Owner ruling 2026-08-13, option (b) -- gate startup on a DELIVERABLE channel -- recorded by the dispatcher at 30d5358. The serve gate in __main__.py computes readiness as notify_security_events + email_smtp_host + email_from: SMTP WIRING ALONE. It asks "is a transport configured" and never "can the account that matters actually receive" -- the instrument answering the adjacent question, SDS-3.8. On a first run the only account that exists is the bootstrap administrator, created with no address, so the gate reports a healthy channel while all ten notice types about the account holding frozenset(Permission) silently no-op, including LOGIN_AFTER_FAILURES. THE CHECK MUST LIVE IN THE LIFESPAN, and the item's own stated location was wrong. It pointed at __main__.py:2259; _serve is SYNCHRONOUS and opens no store, so there is no user table to ask. The ASGI lifespan is the only place the store and the freshly minted bootstrap admin are both in hand. The owner's ruling corrected this and it is why the fix is here. DELIVERABILITY, NOT "REQUIRE AN EMAIL AT CREATION". update_user_profile issues UPDATE users SET display_name=?, email=? unconditionally on every directory login, so any address a human sets on an AD or OIDC account is overwritten at that holder's next sign-in. A fix resting on an OPERATOR ACTION cannot cover that population; an assertion about the state of the table can. Deliberately narrow: it asks whether SOME enabled Administrator carries an address, not whether mail would arrive. Proving delivery needs an SMTP round trip at startup, which is a different and much larger change. Eight tests, including a POSITIVE CONTROL (a reachable admin must still start -- without it, a predicate that refused unconditionally would pass the refusal test and break every deployment), a disabled admin and an addressable non-admin (so the count cannot be satisfied by the wrong row), warn-enforcement parity with the transport gate, and each of the three preconditions asserted separately because one combined case cannot show which arm did the work. A TRAP THE FIXTURE HIT, WORTH THE COMMENT IT NOW CARRIES: set_user_roles on a bare store raises FOREIGN KEY constraint failed, because the roles table is seeded by AuthService.initialize() and not by MessageStore.open(). That failure HANGS rather than reporting -- the exception escapes before store.close(), aiosqlite's non-daemon worker thread stays alive, and the process never exits. pytest produced ZERO output for five minutes with no traceback; it was only legible when driven outside the runner. The fixture now builds through initialize() and closes the store on every error path. GAP, STATED RATHER THAN PAPERED OVER: these tests exercise the PREDICATE, not the WIRING. Removing the lifespan call site would not red any of them, by design. There is one call site and one definition (asserted), but nothing proves the lifespan invokes it. Tests that build a managed app exist (test_api.py and siblings), so an integration test asserting startup actually refuses is the next step -- NOT done here. Verified: ruff, ruff format, mypy strict 265 files, 8 passed in 1.30s.
…NGS (BACKLOG #1020)
The wiring test that belongs here was written, run, and REMOVED. It hangs, and
what it found matters more than the coverage it would have added.
MEASURED, WITH A CONTROL:
sibling lifespan test that does NOT raise
(test_security_posture_defaults::test_managed_app_stashes_auth_settings_for_the_registry)
-> 1 passed in 1.13s
raising from the lifespan BODY, after `yield` -> exits cleanly
raising during lifespan STARTUP, before `yield` -> HANGS, pytest emits zero output
So an exception raised during lifespan startup does not unwind. The gate sits
after engine.start(), the upload-retention runner and the security notifier are
all running, and before the three asyncio.create_task handles the teardown
expects. The finally guards every handle with `is not None`, so this is not an
unbound name -- it is teardown of a PARTIALLY-STARTED lifespan not completing.
Pre-existing; the gate is merely the first thing to raise in that window.
WHAT THIS DOES NOT ESTABLISH, kept separate on purpose: what uvicorn does.
Production startup failure goes through uvicorn's own path, which is UNMEASURED.
The refusal may exit cleanly in `serve`.
CONSEQUENCE, STATED PLAINLY: #1020's refusal path is UNVERIFIED under the runner
that actually ships, and a hang there would be WORSE than the defect it fixes --
a silent notice at least leaves a running, legible server. **This is why the gate
should not be relied on until someone drives it under uvicorn.**
I nearly reported the opposite. The first run of the hanging test returned exit
code 0 and I almost read that as a pass -- it was my own wrapper's exit after a
Wait-Job timeout, printing "HUNG". A success exit code that is a fact about the
wrapper, not the subject.
The eight predicate tests are unaffected: 8 passed in 1.26s. ruff and mypy clean.
The gap they leave -- deleting the lifespan call site reds nothing -- is now
documented in the file rather than implied by its absence.
One conflict, docs/adr/README.md, and it is the APPEND/APPEND case: my ADR 0164 row against main's 0165. Both belong. Neither side is wrong and picking one would silently delete a landed ADR's index row -- which the ledger gate would not catch, because it checks that an ADDED heading owns its number, not that an existing row survived a merge. Resolved by keeping both, ours first so the table stays in number order, stripping only the three marker lines. Asserted after: 0 markers, both rows present, 157 rows with ZERO duplicated ADR numbers -- the specific hazard of a keep-both resolution is a row appearing twice, so it is checked rather than assumed. Both referenced files exist, and link_check.py resolves every relative link. docs/SECURITY.md auto-merged cleanly this time; the semantic resolution it needed landed earlier at 93ccb3b. L30 CONTROL, expectation stated rather than assumed: this branch touches docs/BACKLOG.md zero times, so a merge that brings main's ledger forward should land on MAIN'S counts, not 0/0/0. parse_items before 281/206/75, after 284/205/79 -- matching origin/main's own 284/205/79, measured independently.
…1256) My own #1143 research finding, filed by the dispatcher at f12a1f5 and built here. #1015's guard resolves the account by USERNAME and asks whether THIS ACCOUNT carries a different subject. So it constrains WHICH subject may bind to a given account, and is structurally incapable of seeing a SECOND ACCOUNT already bound to the subject now presenting. Nothing else sees it either: measured, no UNIQUE constraint names the federated columns on any backend -- 0/0/0, against 13/8/10 total UNIQUE declarations as the positive control. Without this, one verified identity could own two accounts: bind as `jdoe`, have the IdP later resolve you to a different on-prem object, and both rows carry your subject while #1015 refuses neither, because each account's own binding is self-consistent. REFUSED, NOT RE-POINTED. Silently moving the binding would hand the subject the newer account and strand the older one -- the account-takeover-without-credential- compromise shape #1015 exists to prevent, arriving from the other side. New store method `get_user_by_federated_subject` on the protocol and all three backends, mirroring `get_user_by_username`. A lookup rather than a `list_users()` scan because it sits on the login path. Both columns are compared, never `subject` alone: a subject is unique only WITHIN its issuer, so a subject-only match would refuse two unrelated people sharing an opaque identifier at different IdPs. SCOPE, AND WHY THE SCHEMA HALF IS NOT HERE. The item's own caution is that SQL Server types these NVARCHAR(MAX), which cannot be an index key, so a UNIQUE constraint there needs a RE-TYPE and not merely a constraint. Measured: the file has a house convention for exactly this (NVARCHAR(450) + COLLATE Latin1_General_100_BIN2 + a runtime length check, used for reference_sets.[key], a documented schema divergence) -- but it BOUNDS AT CREATION and there are ZERO `ALTER COLUMN` statements anywhere in the file. Re-typing a live column with data would be the first, on the backend whose suites skip locally. So this closes the defect BEHAVIOURALLY on all three backends today, and the structural constraint stays open with its cost measured rather than being half-built. I am not pricing that migration; per the dispatcher's instruction it goes back if it becomes an owner question, and the length choice for existing data is one. I am also NOT calling this structural. The check is a code-level refusal -- a convention, enforced by review. Claiming otherwise is exactly the error a review caught in ADR 0164 earlier tonight. RED-FIRST, ASYMMETRIC: 24 passed with the guard; removing ONLY the guard (512 bytes, absence verified) reds exactly ONE test -- the new one -- while the other 23 stay green. A control that reddened everything could not show which layer does the work. Restored byte-identical, 24 passed again. The new test is deliberately distinguished from `test_same_subject_changed_username_is_same_account`, which passes on the DEFECTIVE code and is not evidence about it: there both usernames resolve through AD to the SAME object, so only one account ever exists. This one resolves them to genuinely different objects, with a positive control asserting that through the same seam the service uses rather than the fake's internals. Verified: ruff, ruff format, mypy strict 265 files, 24 passed in tests/test_auth_oidc_service.py. The Postgres and SQL Server lookups are UNEXERCISED locally -- those suites skip without their env vars -- so their correctness waits on CI, which is the item's stated proof condition.
… exits (BACKLOG #1020) Closes the question my own earlier note left open, and the answer BLOCKS the item from landing as written. That note said the harness hang did not establish what uvicorn does, and that production startup failure goes through uvicorn's own path which was UNMEASURED. Measured now, and it is both halves: REFUSES CORRECTLY AND LEGIBLY -- uvicorn prints the full RuntimeError including the operator-facing remedy, then "ERROR: Application startup failed. Exiting." and raises SystemExit(3). No socket is served. The gate does what #1020 asks. THE PROCESS THEN DOES NOT EXIT -- still alive 90 seconds after printing "Exiting.", and had to be killed. Timed directly on the process rather than through a wrapper. THE DISTINCTION DECIDES SEVERITY, and it is not the one I expected. This is a HUNG refusal, not a silent one. An operator watching a console sees exactly the right error. A SUPERVISOR does not: NSSM, systemd or a container runtime sees a process that started and never exited -- running and dead, which is precisely the state a restart policy cannot detect and will not recover. Worse than the mis-report #1020 fixes, because a wrong readiness answer is at least visible to the thing watching. So #1020 is NOT landable as written: the refusal must TERMINATE the process, not merely decline to serve. The underlying teardown behaviour is pre-existing -- engine.start(), the upload-retention runner and the security notifier are all running when the gate raises -- and is filed separately rather than fixed here. METHOD NOTE, because it nearly cost the finding: my first probe reported "HUNG -- the refusal does NOT terminate under uvicorn either". That was my PowerShell wrapper's Wait-Job timeout branch, not the subject; the captured output underneath it showed uvicorn exiting via SystemExit(3). Third time tonight a wrapper's exit path has manufactured a verdict about the thing it wrapped. The measurement that settled it timed the PROCESS with WaitForExit and read the real exit code.
… (#1257) The teardown was never incomplete -- it stops the engine, both notifiers, the upload-retention runner and all three background tasks. It was UNREACHABLE. Its try: opened at the yield, so it guarded the RUNNING phase and never the STARTUP phase, and everything brought up between engine.start() and the yield was abandoned in place on a startup failure. What that costs is the process, not tidiness. engine.stop() ends in store.close(), and aiosqlite's connection worker is a NON-DAEMON thread, so skipping it blocks interpreter shutdown forever. Under uvicorn it reads as a HUNG REFUSAL: the correct error prints, then "Application startup failed. Exiting.", then SystemExit(3) -- and the process stays alive. An operator watching a console sees the right message. A supervisor does not: NSSM, systemd or a container runtime sees a service that started and never exited, which a restart policy cannot detect or recover. No teardown ORDER decision was needed and none was made. The existing order is reused verbatim: hoist the five teardown handles above the new try, open try: immediately before await engine.start(), re-indent the span, and delete the inner try: that used to begin at the yield, so the EXISTING finally -- body byte-identical -- now pairs with the new outer one. Control for the re-indent: git diff -w shows 23 lines of real change, being the comments plus five hoisted inits plus one try against five relocated inits and one deleted try. A whitespace-blind diff is the right instrument for a whitespace-only transform. engine.stop() is safe on a partial start (every subsystem is is-not-None guarded, and engine.py states the property outright), so wrapping engine.start() itself is sound and fixes a start() failure too, which hung identically before. The hoist is part of the fix, not tidiness. Deleting one hoisted init showed the finally reaches those names BEFORE engine.stop(), so an unbound one raises UnboundLocalError, aborts the teardown early, and the hang comes straight back with the real startup error replaced. Measured: at HEAD the regression test is killed by the 60s watchdog with the process still alive; with the fix it passes in 1.8s. The assertion is deliberately that THE PROCESS EXITS -- not that the error prints, not that SystemExit is raised. Both of those were measured true while the process went on living, so neither discriminates, and a test asserting either would have passed against the defect.
Every test in this file drove the predicate directly, so none of them would fail if the lifespan's call site were deleted. That closure was written and removed once because driving a startup failure through lifespan_context hung with zero output. The cause was never this gate -- it was the lifespan not unwinding, fixed as #1257 -- so the wiring assertion is now possible. Proven to have teeth by mutation: with the lifespan's call to the gate removed, the wiring test fails with DID NOT RAISE while all nine other tests still pass. That is also the clearest statement of the gap it closes. Adds a positive control alongside it: the identical app under enforcement=warn starts and unwinds cleanly, so the refusal above cannot be confused with an app that fails to start for an unrelated reason. The long comment block recording the hang is replaced. It asserted "THIS BLOCKS #1020 FROM LANDING AS WRITTEN", which the fix has made false.
…ers (#1259) A conflicted ledger censused CLEANLY, and the counts AGREEING is the finding. `>>>>>>> branch` starts with ">", so the banner-block scanner treated it as a blockquote line and kept scanning instead of ending the block; items from BOTH sides were then counted and the total looked plausible. Measured: the live ledger and a marker-poisoned copy of it produce identical counts with no exception. The refusal goes in the READER rather than in a pre-commit hook because of HOW it bit -- a landing seat ran this on a merge-tree blob before checking the merge's exit status. A hook on the working copy would not have been running at all. Three gates inherit it from the one function: backlog_status_check, backlog_citation_check and dangling_citation_check. `=======` is deliberately NOT detected. A Markdown setext H1 underline is a run of "=" and can be exactly seven, so matching it could refuse a legitimate document; every real conflict carries the other two markers, so the exclusion costs no detection while removing a false positive. A test pins the exclusion, because a merge-integrity gate that refuses the real ledger is not a small failure. The proof condition is the item's own: the poison is applied to a COPY OF THE REAL LEDGER rather than a synthetic fixture, since the finding was that the real file parses identically poisoned and clean. Mutation-proven. With the detector disabled the refusal test fails DID NOT RAISE while the setext test still passes, so the two are demonstrably testing different things. The positive control that the live ledger still parses is load-bearing, not decoration: without it, a reader that refused every input would satisfy the raises-assertion on its own. End-to-end, the gate still reports 520 items (284 live + 236 archived), exit 0.
…guard see it (#1242 limb 4) RESTORING SPECIFIED BEHAVIOUR, not adding a requirement. The promotion of this writer was specified to carry the union of live and payload keys so the schema could grow without hand-editing the record. That was delivered for TOP-LEVEL scalars (#382, limb 3) and silently not for sub-table entries: evidence was re-emitted as exactly path/line/expect and absence as exactly pattern/positive_control/mutation, so any other field inside an entry vanished on every rewrite. The module header already states the governing rule -- enumerate what you ORDER, never what you KEEP -- and those two emissions were exactly the enumeration it forbids. TWO HALVES, because the writer was not the only thing blind here. WRITER. The explicit emissions stay, as an ORDERING, with a carry-through tail appended. They are left spelled out rather than generated so the ordered keys keep their exact typing -- `line` stays a bare int through int(), the rest stay TOML basic strings -- which keeps every byte of today's output identical. Carrying unknown fields through would be worthless if it re-typed the known ones on the way past, and a test asserts that too. GUARD. The field-preservation invariant compared TOP-LEVEL keys and, for sub-tables, only the ENTRY COUNT. Counting entries cannot see a field vanish from inside one, so writer and guard were blind in the same place: a rewrite could drop a field from every evidence entry, keep the count, and report green. The invariant now compares keys one level down and names the entry it would lose them from. NOT KEYED ON THE FIELD NAMES THAT EXIST TODAY. A name-keyed fix satisfies the symptom and drops the next field anyone adds, which is the defect again with a longer list. The test therefore uses a key the writer has never heard of; a test naming a field that exists today would pass against the lengthened-list fix. MUTATION-PROVEN, BOTH HALVES, each failing exactly one test with the other 17 green: carry-through disabled -> the survival test fails sub-table key comparison off -> the backstop test fails, i.e. a field dropped from EVERY evidence entry passes the guard. That is the blindness this closes, measured. BASE: `or key in cell` returns 0 in this diff's own copy of apply.py, so #382 is in and this does not carry limb 3's reversal in behind it. THIS DOES NOT CLOSE #1242. A fourth limb class -- live-only top-level TABLE values type-mangled through _scalar() -- is untouched here. The banner is not mine to write.
…022) delete_webauthn_credential refuses when the credential being removed is the last second factor and MFA is still required (ADR 0068 decision 5). disable_mfa never asked. Both are SELF-SERVICE, both sit behind the same step-up gate, and both can take an account to zero second factors -- so the account's MFA requirement was enforced on one route and not the other. require_mfa defaults to TRUE with scope every_local_account, so this was the DEFAULT posture, not an exotic configuration. THIS IS NOT THE ADMIN ESCAPE HATCH AND DOES NOT NARROW IT. The "always-available recovery for a locked-out passkey user" is admin_reset_mfa -- a different method, which clears TOTP and every passkey and stays deliberately unguarded. A test now pins that: under exactly the conditions that make the self-service path refuse (MFA required, TOTP the only factor), the admin path still clears. WHY BOTH PATHS SHOULD REFUSE, on the merits rather than by symmetry: with require_mfa on, dropping to zero factors is NOT a lockout -- it lands the user in the enroll-required flow. Neither self-service path is a rescue path, so neither has a reason to be the exception. AN EXISTING GREEN TEST CHANGES, AND IT IS NOT A COSMETIC CHANGE. tests/test_mfa.py::test_disable_and_admin_reset_clear_mfa now runs with require_mfa=False. Its subject is that disable and admin-reset CLEAR MFA; the self-service disable in its setup was incidentally exercising the defect, because the bootstrap admin it builds holds TOTP as its only factor. Turning the requirement off keeps that test on its own subject. Stated here rather than quietly adjusted: a green test that flips is exactly where a real regression hides, and a reviewer cannot otherwise tell which kind this was. Mutation-proven: with the guard disabled the refusal test fails DID NOT RAISE while the positive control (disable IS allowed when MFA is not required) and the admin-recovery test both stay green -- so the three are demonstrably testing different things. The refusal is also asserted not to have mutated the store on its way out; a refusal that already disabled MFA would be worse than none, since the error would claim a state it had not preserved. AND THE ROUTE MUST MAP IT. Adding the guard without touching DELETE /me/mfa would have turned the refusal into a 500: auth_routes.py called disable_mfa with no try/except, so the ValueError escaped uncaught. That reports a user-correctable condition as a server fault AND swallows the remedy the message carries. The neighbouring /me/mfa/confirm route already mapped ValueError to 400; this one did not. Found by grepping the callers of the method being changed -- the guard is only finished when its refusal reaches the caller in a form they can act on. Pinned end-to-end: the API test drives the real step-up ceremony (enroll, confirm, mfa-verify in a LATER TOTP step because the activating code is single-use, then a disable-bound reauth) and asserts 400 with the remedy text, and that MFA is STILL ON afterwards -- a 400 whose side effect already happened would be worse than the 500. Mutation-proven: drop the route mapping and the ValueError escapes uncaught.
…t (ADR 0015) Owner-ruled: reword the docstring rather than lift the ADR block to the vault. Lifting would have closed nothing -- the sentence already ships in the wheel AND the sdist (pyproject only-include carries messagefoundry/), so it reaches PyPI either way. WHAT CHANGED, and only this: the docstring named three evasions concretely -- "a base64'd / hashed / re-encoded echo defeats it". That is a ready-made list to try. It now states the CLASS instead: the match is literal, so any transformed echo passes through unmasked. A reader of the code learns that from `form in text` plus .replace() anyway, so the general statement discloses nothing the source does not; the enumeration did. KEPT DELIBERATELY, because removing it would be the worse defect: "Defence in depth, not a seal". A docstring implying the scrubber is a seal misleads the next maintainer -- the same false-premise shape, pointed inward. The candour stays and gains an operator-facing consequence: treat a captured reply as potentially secret-bearing regardless, which is what an operator should DO and is true whatever the transformation. Also kept: the _SCRUB_MIN_LEN rationale. The constant is visible at :269 either way, and the docstring explains why a short low-entropy value (a facility code a CDC-IIS ACK echoes) must survive rather than be shredded. BEHAVIOUR UNTOUCHED, asserted rather than asserted-of: the diff is inside the docstring, _SCRUB_MIN_LEN = 8 at :269 is unchanged, and the scrub loop is byte-identical. 52 passed in test_security_static.py, 149 passed across -k soap, ruff + mypy clean. THE HALF THIS DOES NOT CLOSE, reported rather than silently widened: the IDENTICAL sentence is published at docs/adr/0015-...md:462 in this public repo. Rewording only the docstring leaves the recipe in the tree -- the mirror image of the argument that vaulting the ADR closes nothing while the docstring ships. Whether the ADR's accepted-residual text should be reworded too is a scope question I am handing back rather than deciding, because editing a decision record is a different act from editing a docstring.
…ON (#1022) THE SAME DEFECT AS THE JSON ROUTE, ONE LAYER UP, AND MY OWN FIX CREATED IT. b680ee0 made the service refuse and mapped that refusal to HTTPException(400) on DELETE /me/mfa. This route DELEGATES to that same handler, so from that commit onward a console user clicking "Disable MFA" on their last factor got FastAPI's bare {"detail": ...} rendered into a browser form navigation. Measured, not argued -- with the translation removed the response body is literally: {"detail":"this is your last second factor and MFA is required for your account - enroll another factor first"} Every sibling refusal in this module already answers with _account_response(error=...) -- the AD/already-enrolled case at :223, the no-enrollment-staged case at :264. This route was the only one that did not, so the guard landed and the console got worse at precisely the moment it started refusing. 429 is re-raised rather than translated, following the ui_reauth precedent at :201, so the Retry-After semantics survive. THE TEST ASSERTS THE BODY IS A PAGE, NOT THE STATUS. The mutation shows why: without the fix the response is STILL a 400, so a status-only assertion passes against the defect. What discriminates is "<html" in the body. It also asserts totp_enabled is unchanged -- a refusal whose side effect already happened would be worse than the bad rendering. 248 passed in the console suite, 77 across the JSON auth suite, ruff + mypy clean. FOUND BY A TEMP REPRO I HAD LEFT IN THE TREE from before a context compaction, asking exactly this question and never answered. The lesson is the one b680ee0 already taught and I applied too narrowly: a refusal is only finished when it reaches the caller in a form they can act on -- and disable_mfa has TWO callers. I checked one, fixed it, and wrote that the guard was complete.
My own adversarial review confirmed this one, and it is the sharpest kind: an assertion
captioned as a guard that could not fail.
WHAT WAS WRONG. The test asserted the original error message was PRESENT in the child's
output, captioned "THE TEARDOWN MUST NOT MASK THE STARTUP ERROR". CPython chains: an
exception raised inside the `finally` carries the original as __context__, and Starlette's
lifespan handler formats the WHOLE chain before uvicorn logs it. So the original text is
present EVEN WHEN IT WAS REPLACED -- the assertion is true in exactly the case it was
written to catch.
MEASURED WITH A PAIRED CONTROL rather than reasoned about. Engine.stop patched to raise
AFTER a real stop (so store.close() still runs and the process still exits promptly, which
is what makes this distinct from the hang):
clean run "deliberate failure..." PRESENT chaining banner ABSENT
masked run "deliberate failure..." PRESENT chaining banner PRESENT
So presence discriminates nothing and the banner separates them exactly.
THE FIX IS TWO ASSERTIONS, NOT A REWORDING. The presence check stays but is relabelled as
what it actually establishes -- the refusal was reported at all -- and marked weak on
purpose. The new check asserts the chaining banner is ABSENT, which is the masking property.
MUTATION-PROVEN IN BOTH DIRECTIONS, which the original never was: with the injection the new
assertion FAILS naming the teardown; without it, 11 passed across this file and
test_security_notice_deliverability.py. The original test's cited mutation (un-hoisting a
teardown handle) fails via the 30-SECOND TIMEOUT and never evaluates these assertions at all
-- so that evidence, which I quoted in 0e9c104's commit message, never exercised them.
WHY IT MATTERS BEYOND THIS FILE: the module docstring sets the standard -- "not that the
error prints... neither discriminates" -- and the assertion asserted precisely that the error
prints. A control resting on a false premise, whose harm is that a reader believes
post-engine.stop() masking is guarded and does not add the guard.
The #1257 FIX itself is unaffected: process-exits, the hoist, and the 1.8s-vs-61.5s pair all
stand. What was defective was one assertion in its test.
…guard see it (#1242)
THREE LABELS, ONE DEFECT. Builder 2's "unknown top-level non-scalars", the Dispatcher's
"non-scalar values stringified", and the inheritance in my own _carried() path all route
through _scalar(), which branched on bool and int then fell through to
toml_str(str(value)). A table became a quoted PYTHON REPR -- sym_table = "{'a': 1}" -- which
PARSES, so nothing went red, and re-reading returned the STRING. The value was not
recoverable from the file. One dict/list branch closes all three; measured across all three
call sites by round-tripping through tomllib, not by reading the emitted text.
THE GUARD IS THE HALF THAT MATTERS, and it was blind here BY CONSTRUCTION. `lost = set(was)
- set(now)` is a pure key-set difference; a type-mangled field KEEPS ITS KEY and passes it.
That check was written to catch DROPPED keys and it does -- but a rewrite could corrupt
every value while preserving every key and report green. Now type-compared as well.
SCOPED TO KEYS THE PAYLOAD DID NOT TOUCH, deliberately. The corruption is the WRITER
retyping something nobody asked it to. A payload that intentionally retypes a field is an
EDIT, and an unscoped check would refuse it -- a guard that refuses legitimate edits is a
guard someone disables, which is how you arrive back at no guard.
DOTTED KEYS ARE QUOTED, AND THIS IS THE SUBTLE ONE. A dotted key is not a syntax error in
TOML, it is a NESTING OPERATOR: {1.2.2 = "x"} is VALID and reads back as
{'1': {'2': {'2': 'x'}}}. File loads, gate green, structure different. Spaces and quotes
fail LOUDLY and are therefore safe; the dot is the only key that corrupts quietly -- and
ASVS requirement ids ARE that shape. Measured against my own first emitter, which would
have shipped it. The rule is unconditional (quote unless ^[A-Za-z0-9_-]+$) because
"quote the odd-looking ones" fails when 1.2.2 does not look odd.
NOT json.dumps: {"a": 1} is JSON, not TOML. Arrays coincide between the two, tables do not,
so a serializer that looks right on arrays emits a file that will not parse.
MUTATION-PROVEN: remove the dict branch and all four new tests fail while all 18 existing
ones pass. A guard nobody has watched fire is indistinguishable from one that cannot.
Quoting is verified at depth 1, 2 and 3 and inside arrays of tables, with plain-key and
float/bool controls that pass in BOTH modes so the result is not blanket-passing.
22 passed in this file, 410 passed across -k asvs, ruff + mypy clean. Base carries limb 3
(union walk present, `or key in cell` absent) per the stale-base check.
Owner DELEGATED the choice ("do what you sessions judge best") -- recorded as delegated, not
as approval of a particular option, because those are different sentences and only the first
is supported. The Dispatcher then decided the shape; this implements it.
WHAT CHANGED: accepted-residual 2 named three evasions concretely. It now states the CLASS --
the match is literal, so any transformed echo passes through unmasked -- plus the
operator-facing consequence: treat a captured reply as potentially secret-bearing regardless.
A reader learns the limitation either way; only the ready-made list is gone.
KEPT DELIBERATELY: "defence in depth, not a seal". Removing the limitation would be the worse
defect -- a record implying the scrubber is a seal misleads the next maintainer, which is the
same false-premise shape pointed inward.
MARKED IN PLACE: "wording narrowed 2026-08-14, THE DECISION IS UNCHANGED". An ADR is a
decision record; editing its text without saying which part moved would make a later reader
unable to tell a narrowed WORDING from a revised DECISION.
THIS MUST LAND WITH 91b3cc3, and that is why the sequencing changed after the first design.
Neither the docstring reword nor this one is on origin/main, so BOTH locations currently
publish the identical sentence. Narrowing either alone is a remediation that is not one: it
strips the recipe from one artifact while the other keeps publishing it, and reads as
withdrawn when it is not. That is the same argument that ruled OUT vaulting the ADR, pointed
the other way.
VERIFIED WITH A CONTROL, because two zeros are what a blind scan prints too:
recipe in docs/adr/0015 : 0 recipe in transports/soap.py : 0
recipe in origin/main's copy of the ADR : 1 <- the pattern CAN hit
"defence in depth, not a seal" still present in both : 1 and 1
99 passed across the seven test files that read docs/adr from disk.
…262) THE LANE ALREADY EXISTED and is well built -- one list used twice to defeat drift, an existence check per module, -rs so a skip cannot read as a pass. The defect was its MEMBERSHIP, which is the part the item calls the real work. ADDED, both measured by TRACING WHAT THEY OPEN AT RUNTIME: test_cutover_slug_rot.py 153 allowlisted paths read test_backlog_citation_check.py 348 test_cutover_slug_rot holds test_present_tense_mirror_prose_does_not_grow -- THE TOPOLOGY RATCHET THAT REDDENED main IN THE ITEM'S OWN MEASURED INSTANCE. The gate that caused the filing was absent from the lane that exists to run it. THE MEMBERSHIP RULE IS NOW WRITTEN BESIDE THE LIST, with the evidence that no static criterion can re-derive it: "mentions a docs-ish token" gives 135 candidates; "quotes a real allowlisted path" MISSES 8 OF THE 16 already listed, because those gates build paths rather than writing one literal. A criterion that cannot re-derive the KNOWN members cannot find unknown ones -- so the rule records the tracing method instead. CARRIED AS A LIMIT RATHER THAN IMPLIED COMPLETE: the trace ran without five CI extras, 89 tests SKIPPED, and a skipped test reads nothing. THE LIST IS A FLOOR, NOT A CENSUS. More members may be missing; re-derive with full extras before believing otherwise. test_doc_ref_handle READS NO DOCUMENTATION -- it tests mfdoc:v1:ref: handles in the store and is in the lane because its NAME reads like "documentation reference". KEPT, per dispatcher ruling: removing a guard is a different act from adding one, and dropping it on a reading of its name is the same move that put it there. Noted in place so the next reader is not misled. NOT DONE: the item's proof condition is a Markdown-only PR carrying a deliberate violation of each newly-added gate, asserting THE PR goes red. That needs a PR, not a local run.
…ot mention (BACKLOG #1242) RETRACTING MY OWN SCOPING, on someone else's measurement. I wrote the type guard's `k not in c` clause and flagged it as the part my own judgment could not check -- a mutation test by the author proves a guard is CONNECTED, never that it is connected to the right thing. The ASVS Tracker measured it and the hole is real. THE HOLE. With the writer's dict branch disabled, a payload OMITTING the key was refused while a payload CARRYING it exited 0 and wrote a Python repr into a TOML string. So the guard stopped looking at the exact moment a cell is rewritten. NOT A CORNER, and this is why it outranks a tidier fix: of 345 cells in the record exactly ONE holds a top-level non-scalar, and the natural payload for rewriting that cell ECHOES the key. The guard covered every cell that cannot be hurt and skipped the one that can. THE FIX KEEPS THE PROPERTY THE SCOPING WAS FOR. The intent was right -- a payload that intentionally retypes a field is an EDIT, and a guard refusing legitimate writes is a guard someone disables. The payload STATES a type, so compare against it rather than declining to look: an intentional retype agrees with its own payload and passes, a writer corruption disagrees in BOTH zones. _ORDERED is excluded because render() coerces those by design (int(cell['level']), the quoted emissions), so a payload stating another type there is NORMALISED, not corrupted -- refusing it would be the cry-wolf failure the scoping exists to avoid. MUTATION-PROVEN, and it changed the change. Four mutants: revert to `k not in c` -> killed always use the LIVE type -> killed drop the _ORDERED exclusion -> SURVIVED, so I wrote the test that kills it drop the _SUBTABLES exclusion -> SURVIVES, and is documented as such The third is the point: 23 tests stayed green while that clause did nothing, which is this item's own defect one level up. _SUBTABLES is left in as belt-and-braces with the reason written down rather than claimed as covered -- evidence and absence render as arrays of tables on both sides, so it cannot fire today, and that is a property of the current writer rather than an invariant. VERIFIED: ruff format + check clean (0.15.22, matching constraints.lock), mypy clean on the changed module, 24 passed in tests/test_asvs_apply.py and 411 passed / 23 skipped across -k asvs. Interpreter resolves messagefoundry to THIS worktree, checked by printing __file__. The venv lacks the x12/xml extras, so pytest printed INCOMPLETE RUN -- none of these is a full-suite claim. THIS DOES NOT CLOSE #1242, and the banner is not mine to write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…LOG #1272)
THE GUARD ASKED THE WRONG QUESTION. `skipif(shutil.which("bash") is None)` asks
*is bash PRESENT*. What matters is *is the bash I found USABLE FOR WHAT I AM ABOUT
TO DO*. On Windows the WSL launcher is present, passes that guard, and then cannot
resolve any path this process wrote -- so every block failed identically regardless
of its content. SDS-3.8 in a gate's own guard clause.
MEASURED 2026-08-14, single-variable control. Same tree, same commit, same
interpreter, same three modules; only the PATH order changed:
WSL launcher first 19 failed, 28 passed, 7 skipped
Git Bash first 47 passed, 7 skipped
19 + 28 = 47, so the nineteen that failed are exactly the nineteen that passed.
Both bashes exist here and NOTHING PINNED WHICH ONE WAS FOUND -- which is worse
than "broken on Windows", because two seats get opposite verdicts from two honest
measurements and neither reading generalises.
THE SOLUTION ALREADY EXISTED AND DID NOT PROPAGATE. test_merge_gate_controls
solved this on 2026-08-10, carries the reasoning, and passes 23/23 under the WSL
resolution. Three sibling modules kept shutil.which. So this commit MOVES that
cluster to tests/_bash_support.py rather than inventing one -- git-derived
candidates first (git ships bash beside it), plus a LIVE NAMESPACE PROBE that
writes a token and requires the candidate to read it back. Rejecting "system32" by
name would be matching a spelling; reading a file tests the actual namespace.
A SKIP WAS REJECTED, NOT MERELY NOT CHOSEN. ci.yml sets defaults.run.shell: bash on
every OS, so a leg without a usable bash could not run the gate at all -- and
silence is also what a genuinely broken workflow block looks like. Loud failure
names the real condition. I proposed a skip first; the existing module's reasoning
is better and I withdrew it.
126/127 SEPARATED FROM 2, AND THIS IS THE HALF THAT SURVIVES A RESOLUTION
REGRESSION. bash exits 126/127 when it cannot RUN what it was handed and 2 for a
genuine syntax error; testing only `returncode != 0` conflates them, which is how
one unresolvable path presented as 160 syntax errors that did not exist. The
unrunnable list is asserted FIRST and separately, because if the checker could not
run, the other list is uninformative.
DISCRIMINATION PROVEN, not assumed -- three classes against the resolved bash:
valid script exit 0 neither list
real syntax error exit 2 -> failures "syntax error: unexpected end of file"
unrunnable exit 127 -> unrunnable
So a real defect is still caught and a harness failure names itself.
VERIFIED under the WSL-first PATH, which is the condition that produced the 160:
test_workflow_shell_syntax 3 passed, "syntax-checked 160 shell blocks with
C:\Program Files\Git\bin\bash.exe; 0 failed, 0 unrunnable"; test_merge_gate_controls
unaffected at 23 passed; ruff check + format clean at the pinned 0.15.22; mypy clean
on the new module.
NOT DONE HERE: test_dependabot_automerge_guardrails and test_installed_coord_hooks
still call shutil.which directly. They are the remaining adopters and this does not
close #1272.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…its controls (BACKLOG #1272) FIXING MY OWN PREVIOUS COMMIT, WHICH INTRODUCED THE DEFECT IT WAS FIXING. 368c1ce created tests/_bash_support.py and adopted it in ONE module while leaving test_merge_gate_controls holding its own private copies of the same six helpers. That is two copies of a resolver whose entire item is "the fix existed and did not propagate" -- non-propagation, committed by the propagation commit. Caught by the Dispatcher on review, not by me. test_merge_gate_controls now IMPORTS them by alias rather than having its call sites rewritten, and that choice is load-bearing rather than lazy: THE PROVEN MODULE KEEPS ITS BEHAVIOUR AND ITS TESTS KEEP GUARDING THE HELPER. A promotion that moved the helpers away from the tests proving them would leave a helper that looks identical, is now used in four places instead of one, and is proven in none. THE NEGATIVE CONTROL TRAVELS WITH THEM, for the same reason. The sys.executable stand-in -- a real, runnable program that is not a shell, which the namespace probe MUST refuse -- is what makes _bash_sees more than a hopeful call. Left behind, the promoted helper would be unguarded everywhere it is now used. It lives in tests/test_bash_support.py beside the module it controls. ONE CONTROL ADDED: the 126/127-versus-2 split is now asserted, not merely described. If 2 ever entered CANNOT_RUN_CODES every real syntax error would be reclassified as a harness fault and silently stop failing -- this split's own purpose, inverted. That is a mutation nothing else in the suite would have caught. VERIFIED under the WSL-first PATH, which is the condition that produced the original 19: test_merge_gate_controls + test_bash_support + test_workflow_shell_syntax = 27 passed. ruff check + format clean at the pinned 0.15.22. The orphaned imports the move left behind (os, shutil, subprocess, and two now-unused aliases) are removed. STILL NOT DONE, AND THIS STILL DOES NOT CLOSE #1272: test_dependabot_automerge_guardrails and test_installed_coord_hooks have not adopted it. The second is not a copy of the same fix -- it resolves `shutil.which("sh") or shutil.which("bash")`, trying sh FIRST, so a helper that proves only BASH usable leaves it discovering sh the same unproven way. Pin the shell, not the name; and now, prove the shell, not the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ne exists (BACKLOG #1216)
CLOSES THE BUILD FOR #1216. #1272 was filed tonight for the same defect and is a
duplicate -- neither filer could see the other, because #1216 is on origin/main and
#1272 is on an unpushed branch. Builder 3 held #1216, released it, and ruled the
merge in this direction; disposition of #1272 routes to the Lander.
THE CONTRACT IS BUILDER 3's CORRECTION AND IT IS WHY THIS IS GENERAL. My first
helper found "a usable bash". That is not enough: test_installed_coord_hooks
resolves `shutil.which("sh") or shutil.which("bash")` -- sh FIRST. A bash-only
helper would have repaired that file BY ACCIDENT on this box, where which("sh")
finds nothing, and left it broken wherever an UNUSABLE sh sits on PATH, because sh
would win the `or` and never be probed. So require_shell takes the caller's own
preference order and PROBES EVERY NAME.
PINNED BY A TEST THAT CANNOT PASS VACUOUSLY: require_shell(tmp, "mf-not-a-real-shell",
"bash") must fall through to a working bash, AND the same call with only the
impossible name must FAIL. Without the second arm, a helper that returned something
unprobed would pass the first whenever the fallback happened to work anyway.
LOUD FAILURE, NOT SKIP -- and here the ITEM's text is the half being corrected, on
Builder 3's ruling. #1216 says "skip honestly". ci.yml sets defaults.run.shell: bash
on every OS, so a leg without a usable shell cannot run the gate at all: a skip
there is a green that proves nothing, and a red at least gets investigated. The cost
is near-zero because git-derived candidates are tried first, so a box with git
neither skips nor fails. The item text needs amending to match; that is not mine.
RELATIVE INVOCATION EVERYWHERE. Not converting a namespace at all is stronger than
converting one correctly, and an absolute Windows path is precisely what the WSL
launcher mangled -- backslashes eaten, exit 127.
MEASURED, single-variable, under the WSL-first PATH that produced the original
failures:
before 19 failed, 28 passed, 7 skipped (three modules)
after 72 passed, 7 skipped, 0 failed (all five, incl. the promoted helper)
Every one of the 19 now passes without changing which bash PATH offers. ruff check +
format clean at the pinned 0.15.22; mypy clean on the helper.
THE FINDING THAT OUTLIVES THE FIX. #1216's own text says "Three independent lanes
reached it separately." I am the fourth, and I re-derived it from scratch while a
fully-diagnosed item -- cause, control and fix -- sat on origin/main since
2026-08-11, and while test_merge_gate_controls carried the same mechanism in a
docstring dated 2026-08-10. Two independent records existed before tonight and four
lanes still paid. The remedy is a habit, not a patch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…upersession Two files conflicted, ONE hunk each, and BOTH sides had changed both -- a real call under 8a, not a mechanical resolution. Resolved by reading what each side is FOR: scripts/asvs/apply.py -- TOOK THE LANE'S SIDE, and main's own comment is the argument for it. main carries the ORIGINAL `k not in c` scoping under a long note that says, in its own words, "THE INTENT ABOVE IS RIGHT AND THIS IMPLEMENTATION OF IT IS KNOWN-INCOMPLETE -- see the open BACKLOG #1242". The lane's side IS #1242's fix: compare against the type the payload STATED, with _ORDERED and _SUBTABLES excluded for stated reasons. So keeping main's side would preserve a carefully-written warning ABOUT a defect this change REMOVES -- an invalidated claim with no marker, which is the failure git cannot detect because only one side changed the world and the other described it. Nothing load-bearing is lost. main's "latent, not live" conditional-tense discipline survives in the lane's version as EVIDENCE rather than caution: measured with the writer's dict branch disabled, a payload omitting the key was refused while one carrying it exited 0 and wrote a Python repr into a TOML string. tests/test_asvs_apply.py -- APPEND COLLISION, main's side of the hunk is EMPTY. Kept both. Verified by NAME rather than by count: all 22 of main's tests present, ZERO lost, 2 added -- test_the_TYPE_guard_sees_a_corruption_the_payload_ALSO_MENTIONS (must refuse) and test_the_TYPE_guard_does_NOT_refuse_a_field_the_writer_COERCES_BY_DESIGN (must not). That is the asymmetric pair, not a single arm. Verified on the result: new predicate present exactly once, old `k not in c and type` form gone, zero conflict markers in either file. Built with plumbing; no working tree touched.
…no engine module CI caught what my local run could not: test_tooling_partition asserts every test either imports an engine module or is declared as tooling, and the new test_coord_seat_session_key.py does neither -- it drives seat.ps1 through subprocess, so it imports nothing from messagefoundry. Its sibling test_coord_seat_prompt.py was already listed; this adds the new file directly after it, keeping the manifest alphabetical. WORTH RECORDING: this is the same gate flagged as the blocker on PR #487, where it had been reached by SIMULATING the check in Python rather than running it. Hitting it here for real, on a different file, confirms the mechanism that simulation predicted. The simulation was right about the gate and could not tell which file would trip it. The local suite passes this gate now (9 passed), but note that passing locally is what it did before too -- the failure only appears once the full partition runs.
… module CI CONFIRMS THE BLOCKER THAT WAS ONLY SIMULATED. PR #487 was opened as a draft specifically to settle this by running the check rather than modelling it in Python, and the real result names the same file the simulation predicted: test_tooling_partition.py::test_every_non_engine_test_is_classified these tests import no engine module, so they are unclassified: ['test_bash_support.py'] It failed on all three test legs plus the harness leg. WORTH RECORDING, because it is the reason the draft was worth opening: the simulation was right about the GATE and could not have been trusted about the FIX. It ran against this branch's older copy of test_tooling_partition.py, while a merge takes main's newer rule file -- so a green simulation would have proved nothing about the tree that actually runs. Only CI tests the tree that merges. The identical failure appeared independently on PR #486 for a different file (test_coord_seat_session_key.py), which is corroboration from an unrelated branch that the gate behaves as described rather than reacting to this one. This clears ONE of the two blockers on #487. The other stands and is unrelated: merging this branch would restore a vault-derived count that main deliberately dropped, with merge-tree reporting no conflict.
The figure is vault-derived and this file ships to PyPI. A coverage count over a closed public requirement set discloses the uncovered set by subtraction, which is why the scorecard is vaulted in the first place. `main` already words this clause without a figure. Merging this branch as-is would have brought the number BACK, and `git merge-tree` reports NO CONFLICT because the two sides edited different line ranges of the same comment -- so nothing would have objected. That was the second of two blockers recorded on PR #487. ONLY THE COMMENT MOVES. The guard itself is this branch's fix and is deliberately untouched: main still carries `k not in c`, which skips every key the payload carries, and repairing that is what these commits are for. RECORDED BECAUSE I NEARLY DID THE OPPOSITE. Comparing the two comment blocks, I judged main's better-worded and figure-free version to be the later revision and concluded the fix was to take main's whole block. It is the EARLIER one. Diffing the CODE rather than the prose showed main still has the unrepaired guard, so taking its comment would have shipped a comment describing an implementation that no longer exists -- and would have read as reverting the fix. Prose quality is not version order. A note now sits inline saying the total is omitted on purpose, so a future merge that reintroduces it has something to contradict.
…de it, plus BACKLOG #1306 (#486) * fix(coord): a CLI seat declaration must attach to the session that made it seat.ps1 keys one record per (worktree, session). Both hooks read session_id off their stdin payload and pass it as -SessionId. The CLI path -- the exact invocation the SessionStart banner prints and tells every seat to run -- has no payload and no id a person could type, so Get-SessionKey fell through to the literal string 'nosid'. The result was two valid records in one box: the hook's, carrying the session and no goal, and the declaration's, carrying the goal and no session. Nothing reported it because neither record is wrong. Measured across the live seats directory before the fix: 21 declarations, 18 of them in nosid.json 18 of 18 boxes holding a declaration ALSO held a session-keyed record reading seat=null, goal=null fleet.ps1 rendered each of those boxes twice, once as a declared seat and once as NOT-DECLARED. So the fleet could see a goal, could see a live session, and could not join them -- which is the one question a declaration exists to answer. That is the hollow-record failure CLAUDE.md section 5 describes, arriving one layer further in: the schema was fed, and what fed it could not be attributed. Get-SessionKey now falls back to $env:CLAUDE_CODE_SESSION_ID. Precedence, highest first: -SessionId, the payload's session_id, the env var, then 'nosid'. Env is ranked below the payload so a hook holding the real thing always wins, and the new rung is labelled sessionIdSource='env' so a reader can still tell where the identity came from. The variable is authoritative rather than a guess. On the session that found this, the SessionStart hook had written its record with sessionIdSource='param' from its payload, and $env:CLAUDE_CODE_SESSION_ID held the same value. CLAUDE_CODE_HOST_SESSION_ID is deliberately NOT read: it is the `local_`- prefixed session-management MCP namespace, and keying on it would re-split every box. Tests cover the env rung, the precedence, and a negative control proving the rung does the work. Note what let this survive: every test in test_coord_seat_prompt.py passes -SessionId explicitly, so the suite was green and blind to the invocation the banner actually prints. The new helper builds its environment from scratch rather than mutating os.environ, because the suite itself runs inside a session that sets the variable. * backlog: file #1306 -- the worktree gate denies a read-only hooksPath query, reproduced before filing Reported by the Builder 1 seat, relayed by the Dispatcher, allocated and filed here because allocation and the commit that files an item cannot be split across worktrees. The gate decides on command SHAPE rather than on whether a value is assigned, so a bare read of core.hooksPath is refused with a message saying it would change the shared configuration -- when it changes nothing. The explicit --get spelling is unaffected, so a caller who needs the value has one, which is most of why this is a small item. A second instance turned up while filing it, and it is the worse half: writing the entry through a shell heredoc was itself denied, because the heredoc QUOTES the command strings. The rule matched the key inside a documentation payload that assigns nothing and runs nothing. So the same predicate that blocks a read blocks writing the item describing the read, and the obvious next move for whoever hits it is to reword the evidence out of the record. The entry was written with the file edit tool instead; no gate was routed around. It fails closed and there is no bypass. This is a false positive, not a hole, and the fix must not be read as licence to loosen shape-matching generally. Two failed measurements are recorded in the item because either would have closed it as unfounded. The first drove the gate with parameters it does not have and returned the same failure on every case including the control. The second omitted cwd from the payload, so the gate was OFF and allowed the write case it must deny. A gate probe whose positive control does not deny is measuring nothing, and it looks exactly like a clean result. The leak gate then blocked the first attempt at this commit for carrying a worktree slug. It was right: this ledger is public and the slug is internal coordination detail the item does not need. Removed rather than allowlisted, which is what the gate's own remediation text says to do. * test(tooling): classify the new seat-session-key test, which imports no engine module CI caught what my local run could not: test_tooling_partition asserts every test either imports an engine module or is declared as tooling, and the new test_coord_seat_session_key.py does neither -- it drives seat.ps1 through subprocess, so it imports nothing from messagefoundry. Its sibling test_coord_seat_prompt.py was already listed; this adds the new file directly after it, keeping the manifest alphabetical. WORTH RECORDING: this is the same gate flagged as the blocker on PR #487, where it had been reached by SIMULATING the check in Python rather than running it. Hitting it here for real, on a different file, confirms the mechanism that simulation predicted. The simulation was right about the gate and could not tell which file would trip it. The local suite passes this gate now (9 passed), but note that passing locally is what it did before too -- the failure only appears once the full partition runs.
…ranch moved it to
`test_the_bash_namespace_probe_rejects_an_interpreter_that_cannot_see_the_fixture`
is registered at `tests/test_merge_gate_controls.py`. This branch's #1272 work
split it into a new `tests/test_bash_support.py` and left the registry pointing
at the old home, so the node id dangles.
MEASURED, because "whose defect is this" was the whole question:
origin/main test_merge_gate_controls.py has the function YES
tests/test_bash_support.py exists NO
this branch test_merge_gate_controls.py has the function NO
tests/test_bash_support.py exists YES
The registry line is BYTE-IDENTICAL on both. So it resolves on main and dangles
here, and the branch is what moved it. Not a pre-existing main defect.
THE CHECK IS DOING EXACTLY ITS JOB. Its own docstring names the failure it
exists to catch -- "a registry of dangling node ids satisfies every count-based
assertion" -- which is why it resolves each node instead of counting them.
Verified with a planted control rather than a bare green: 7 passed, then
deliberately repointed at a nonexistent file to confirm 3 tests fail, then
restored to 7 passed. A green I have not seen fail is not evidence.
NOT FIXED HERE, and it is pre-existing on main rather than this branch's:
the entry sits in the `green` list of a control about the backlog-hygiene
gate's four benign shapes, and a bash-namespace probe has nothing to do with
those. Correcting the path makes the registry resolve; deciding which control
should own the entry is a separate question and not one to settle inside a
stranded branch.
|
CORRECTION TO THIS PR'S OWN TITLE, measured rather than remembered. The title called this a "conflict-resolved rebuild of #432". That is FALSE and it File sets against origin/main: #432 36 files The 28 include ADR 0164, messagefoundry/auth/service.py, messagefoundry/api/app.py #432 IS NOT SUPERSEDED BY THIS PR AND MUST NOT BE CLOSED ON ITS ACCOUNT. Whether WHAT THIS PR ACTUALLY IS: the eight shared files plus two of its own, held in |
…the live network (#494) Found while triaging PR #487. Three rows of test_release_age_passes_an_aged_release_and_holds_a_fresh_one now RUN on windows-2025 (rc=0, so the step body completed) and FAIL, every one returning age_ok='true' where 'false' is expected. THE PASSING ROW IS THE FINDING. The test's own comment calls `aged 30 days` the discriminating pass, "without this row the whole release-age suite would be satisfied by a step that denies unconditionally." It is green because requests==2.32.3 really is old -- a fact about PyPI, not about the guardrail. The reading is forced by the workflow being fail-closed rather than inferred from the failures. Every branch of the age step routes to age_ok=false: unwired ecosystem, empty or ERR pairs, blank newVersion, name or version failing shape validation, ERR or empty body, missing or null timestamp, unparseable date, future date, under-age. true reaches the output only from a real aged upload time, so true on a row whose fixture is {"urls":[]} is positive evidence the body reached the network. The workflow is NOT the defect. Not the cwd/script.name change: I replicated both invocation forms against Git Bash with full_env built the same way and the same chmod(0o755) stub, and the stub interposed under BOTH. The open question is which interpreter require_shell resolves on that runner image, which cannot be measured off the runner. The test discards the one output that would settle it. _run_step_body captures the child and returns only what it parses out of GITHUB_OUTPUT, so the step's own "was published Nh ago" notice and every ::warning:: never reach the CI log. The failure reports a wrong boolean and withholds the sentence naming the age it computed. That is step one of any fix, before theorising about PATH. Downstream of #1216/#1272, not an objection to them: require_shell is what made these rows run at all, and this was invisible underneath the WSL-launcher failures it removed. Nobody holds either number -- the Builder 1 seat measured zero claim rows and zero commits for both, with a positive control, and declined the hand-off -- so this needs a dispatcher assignment rather than an owner by default.
DRAFT ON PURPOSE. DO NOT UNDRAFT WITHOUT READING THE TWO BLOCKERS BELOW. This is opened to get
CI data, not to land.
This branch is the conflict-resolved rebuild of PR #432, whose own head is
DIRTYand conflicts ontwo files. No PR has ever pointed at this branch -- it sat on
originand was invisible togh pr list, which is how it went unreviewed.41 commits, 26 behind
main. Re-derived against90d6f180at open time: still merges clean.BLOCKER 1 -- merging this AS-IS would silently regress
mainmainhas already evolved past this branch on the guard comment inscripts/asvs/apply.py, andin the right direction.
main's version was reworded to drop a vault-derived cell count; thisbranch carries the older draft that still states the figure.
git merge-treereports NO CONFLICT, because the two sides edited different line ranges of thesame comment. A clean merge is not a safe merge here. Before this leaves draft, that block must
be resolved to
main's version -- a semantic resolution, not a textual one. That figure isvault-derived and a coverage count over a closed public requirement set discloses the uncovered set
by subtraction, so it must not return to a file that ships to PyPI.
This is the same hazard recorded against the original #432 -- "do not resolve textually, that picks
a winner by accident" -- arriving on the resolved branch rather than the original.
BLOCKER 2 -- a tooling-partition failure that was SIMULATED, never executed
tests/test_bash_support.pyis expected to need adding totests/tooling_manifest.txt, or threerequired
test (...)contexts go red. That conclusion was reached by simulating the check inPython, not by running it, and
mainchangedtests/test_tooling_partition.pyafter thisbranch's merge base -- so a merge takes
main's newer rule file while the simulation used thebranch's older copy.
This draft exists to settle that in one CI cycle rather than by more reading. Read the real
result, not the simulation.
What is in it
#1216/#1272prove the interpreter actually in use and make the promoted bash resolver a singlesource;
#1242two ASVS writer fixes (compare the type the payload STATED, and carry non-scalarvalues);
#1245/#1233/#1256bootstrap-credential expiry, preset purge on delete, and oneverified subject binding one account;
#1020/#1022PHI security-notice refusal and last-factorprotection;
#1262the two doc-subject gates the docs lane was missing; ADR 0164.Also outstanding
No test suite has been run against this branch by any seat. That is stated rather than implied,
and it is the second reason this is a draft.
Per ADR 0165, any ledger disposition for the numbered items above must be authored by a Dispatcher
or Lander and carried on this branch -- not by the builder whose work this is.