Skip to content

docs(auth): record the disable_mfa last-factor refusal and the shipped notice-gate placement (BACKLOG #1022, #1020) - #1180

Merged
wshallwshall merged 2 commits into
mainfrom
claude/b1022-1020-records
Sep 15, 2026
Merged

wshallwshall merged 2 commits into
mainfrom
claude/b1022-1020-records

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Two "the code is right, the record is wrong" rows, in one PR. Both guards ship on main; this changes tests and documentation only.

What I built

BACKLOG #1022 — ADR 0068 gains AC-18.

  • AC-18 states the disable_mfa last-factor refusal in the shape AC-10 uses for the passkey path: the same message on the same condition, reaching the caller as a 400 on the JSON route and as the account page on /ui. It names tests/test_mfa.py::test_disable_mfa_REFUSES_stripping_the_last_factor_when_mfa_is_required, its positive control, the admin-recovery carve-out test, tests/test_api_auth.py::test_disabling_the_LAST_second_factor_is_a_400_not_a_500, and the web console page test.
  • AC-17's tail is tightened. It said "AC-10 refuses the last-factor case on the self-service paths". AC-10 is scoped to WebAuthn deletion and covers one path, not two. It now names one criterion per path.

BACKLOG #1020 — the uvicorn demonstration, the exit-3 line, and ADR 0167's title.

  • tests/test_notice_gate_refusal_terminates_under_uvicorn.py arranges the gate's real preconditions — notices on, security_notifications_required true, enforcement=enforce, SMTP fully wired, and an empty store whose only account is the addressless bootstrap administrator the lifespan itself mints — runs the app under uvicorn in a subprocess, and asserts the process exits 3 carrying the gate's own refusal text. A warn arm reaches a RUNNING server and self-stops with 99.
  • docs/DEPLOYMENT.md gains the exit-3 line ADR 0167 accepted and never got, as "Exit codes — a startup-stage refusal exits 3, not 2" in the bind-guard section. A third test in the new file pins the document to the same number, because nothing else executes it.
  • ADR 0167's H1 and its docs/adr/README.md row now name the placement that shipped: checked in the ASGI lifespan after the bootstrap administrator is created.

I deliberately built no guard and no gate, because both already ship

The brief's verification pass was right and I re-checked both by symbol:

  • fix(ci): stop the backlog gate reporting a match as a miss #1022's "OWED — code ... POST /users/{user_id}/reset-mfa still has no self-exclusion" is FALSE at head. The self-exclusion is at messagefoundry/api/auth_routes.py:882, carrying an explicit BACKLOG #1022 comment, pinned by tests/test_api_auth.py:1701.
  • chore(licence): rename the copyright entity to MessageFoundry Foundation, LLC (BACKLOG #1528) #1020's "NOT BUILT: the gate" is FALSE at head. _assert_security_notice_is_deliverable is at messagefoundry/api/app.py:6138 and is awaited at :6816, right after bootstrap = await auth.initialize().
  • I added no guard inside AuthService.admin_reset_mfa. tests/test_mfa.py::test_the_ADMIN_recovery_path_is_not_narrowed_by_the_guard refuses one, and it is the always-available recovery path for a locked-out passkey user.
  • The alleged AC-10/AC-11 contradiction is not there. AC-10 on main is scoped to WebAuthn deletion, so it does not collide with AC-11's mandate. The real defect was under-description, which AC-18 fixes.

What the two rows already had, which the brief did not predict

Two of #1022's three owed records were already corrected on main and I left them alone:

  • docs/SECURITY.md:392 and :415 already state the refusal and the self-exclusion correctly. (That file is held by a sibling Builder this wave; I did not touch it, and it needs no change.)
  • docs/adr/0068-*.md:140 already carries the retraction prose. Only the formal AC was missing.

ADR 0167: title changed, file deliberately NOT renamed

The filename still reads ...-checked-early-in-the-asgi-lifespan.md, which the ADR's own overturn banner ruled impossible. I did not rename it: every existing citation resolves through it, and a rename breaks them to fix a word. Both the ADR and the README row now say the filename is an address, never a claim.

Open question, left open rather than decided here: whether the file should eventually be renamed is a judgement about the record that nobody has ruled on.

Records I corrected because this PR made them false

ADR 0167 and its README row both listed the DEPLOYMENT.md exit-3 line and the uvicorn re-measurement as still owed. Both are now written, so both records say so — as a dated correction in place, naming the old wording, rather than a silent swap. The "What is NOT demonstrated" section is kept and marked discharged rather than deleted, because the reason it existed is the lesson.

The ADR's status line also said the EARLY-LIFESPAN placement is what "this ADR's title and filename still assert". After this PR only the filename does; that sentence is corrected in place.

Unfiled cleanup, with no backlog number cited

Two items I found and did not build. Neither is given a #N, because citing a number I have not allocated resolves to nothing today and to unrelated work later.

  1. AuthService.has_notifiable_admin has zero production call sites. The shipped gate inlines the same enumeration (list_users, disabled-skip, notify_email, Role.ADMINISTRATOR), so the open-coded copies of "who is an enabled administrator" number four. messagefoundry/auth/service.py is held by a sibling Builder this wave, so I did not touch it. ADR 0167 already records this; I confirmed it still holds.
  2. The gate's refusal sentence has no single home. messagefoundry/api/app.py builds it as an inline f-string body with no exported constant, and it is hand-copied in tests/test_security_notice_deliverability.py (twice), in docs/DEPLOYMENT.md, and now in my new test. Nothing fails if the sentence is reworded — the copies just stop matching, including the tests meant to notice. Hoisting an identifying constant into api/app.py and importing it everywhere is the fix. api/app.py is held by another sibling Builder this wave, so I did not take it.

Collision warning

docs/adr/README.md is a tail-append file and a likely conflict. I edit one existing row in place — line 194, ADR 0167 — and append nothing, so a conflict here would be positional rather than a contest over content.

Files changed: docs/adr/0068-browser-webauthn-passkeys-offloopback.md, docs/adr/0167-*.md, docs/adr/README.md, docs/DEPLOYMENT.md, and one new test file.

Checks I ran

All with the worktree's own .venv.

Check Result
ruff check . All checks passed
ruff format --check . 1300 files already formatted
mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/' (CI's exact invocation) no issues, 292 source files
pytest on the new file + test_mfa.py + test_api_auth.py + test_lifespan_startup_unwinds.py + test_security_notice_deliverability.py 96 passed
pytest doc-drift suites: test_link_resolution, test_ledger_check, test_operator_docs_no_warning_sign, test_cutover_slug_rot, test_docs_cite_no_refused_config_keys, test_off_loopback_runbook, test_runbook_proxy_tls_floor, test_api_tls 479 passed, 2 skipped
python scripts/docs/link_check.py 4615 relative links in 357 files, every one resolves
The cited web console test, by node id 1 passed
/simplify (4 review agents), fixes applied see below

Checks I SKIPPED: the full pytest suite. Every run in this worktree prints INCOMPLETE RUN -- coverage was NOT collected / Optional extras absent from this interpreter: vault, so no local run here establishes a green suite regardless.

CI legs that must be read after I exit

  • The Linux and Windows pytest matrix legs, in full — the new test spawns subprocesses and the local runs were Windows-only.
  • windows-service-smoke (NSSM), which never runs locally.
  • Both mypy legs, including the --platform win32 pass.
  • The vault-extra collection, which this worktree cannot run.

What /simplify changed

Four review agents; the important finding was mine to own. My first docstring claimed the two arms "differ in exactly one setting". That was false — the enforce arm goes through uvicorn.run and the warn arm hand-builds uvicorn.Server, and the difference is forced (only uvicorn.run turns a startup failure into an exit code, and it never hands back the Server a self-stopping control needs). The docstring now states what the control establishes and no more. Also applied: the arms are two named functions rather than an early-sys.exit fallthrough; the control's exit code is passed to the child instead of being spelled twice; the no-op textwrap.dedent is gone; subprocess.run replaces the hand-rolled Popen/kill/re-communicate; the child installs a real log handler so the warn arm's assertion does not ride logging.lastResort; and the comment over two probe assertions now says they are ordering aids implied by the exit code, not independent coverage.

Skipped, with reasons: hoisting a shared _phi_app helper (the child runs with sys.path[0] at the temp directory, so reuse means plumbing the repo root into the subprocess for one call — the docstring now records the duplication as a decision); a shared tests/_uvicorn_child.py runner (two call sites, and per-file subprocess plumbing is the suite's convention); and both api/app.py changes, which belong to a sibling Builder this wave.

Proposed ledger banner text, for the Lander

I did not edit docs/BACKLOG.md — the engine's copy is a public stub and the real ledger is vault-only.

For #1022:

RESIDUAL CLOSED 2026-09-15 (PR: this one). THE OWED RECORD IS WRITTEN AND THE OWED CODE WAS ALREADY BUILT. Of the four things this row still owed, three were already on main when checked by symbol: the POST /users/{user_id}/reset-mfa self-exclusion (api/auth_routes.py:882, with a BACKLOG #1022 comment and a pinning test), docs/SECURITY.md's two corrected rows (:392, :415), and ADR 0068's line-140 retraction prose. The row's "OWED -- code ... still has no self-exclusion" paragraph is FALSE at head and must not be carried. The fourth, the missing acceptance criterion, is written here: AC-18 states the disable_mfa last-factor refusal in AC-10's shape and names its three tests, and AC-17's loose "AC-10 refuses ... the self-service paths" is tightened to one criterion per path. No guard was added anywhere, and none inside admin_reset_mfa, which stays the recovery path. Not closed by this: nothing, on the row's own terms. Still explicitly out of scope: the check-then-act race, whose consequence is forced re-enrollment.

For #1020:

THE RIDER IS DISCHARGED 2026-09-15 (PR: this one), BY DEMONSTRATION RATHER THAN INFERENCE, WHICH IS THE ONLY THING THAT COULD DISCHARGE IT. tests/test_notice_gate_refusal_terminates_under_uvicorn.py arranges the gate's real preconditions, runs the app under uvicorn in a subprocess, and asserts the process exits 3 carrying the gate's own refusal text, against a warn arm that reaches a RUNNING server and self-stops with 99. That also closes ADR 0167's own open re-measurement, which asked for the real gate's exit code inside a post-#394 lifespan. The row's "NOT BUILT: the gate" paragraph is FALSE at head -- _assert_security_notice_is_deliverable is at api/app.py:6138 and awaited at :6816 after auth.initialize() -- and must not be carried. Also written: the docs/DEPLOYMENT.md exit-3 line the ADR accepted and never got, pinned to the test by a doc-drift assertion; and ADR 0167's H1 plus its README row now name the shipped post-bootstrap placement, with the filename left alone on purpose so citations keep resolving. NOT closed by this, and it is the one thing left: has_notifiable_admin still has zero production call sites, because the gate inlines the same enumeration -- four open-coded copies of "who is an enabled administrator". That is unfiled cleanup, deliberately carrying no number, and it touches auth/service.py and api/app.py, both held by other Builders this wave.

Questions this brief left open

  1. Should docs/adr/0167-*.md be renamed to match its corrected title? The brief said a rename is possible but unruled, so I did not take it. Both records now say the filename is historical, which makes the state legible either way.
  2. Should the exit-3 doc-pin test live in my new file or in tests/test_off_loopback_runbook.py? That file already reads docs/DEPLOYMENT.md and asserts agreement, so it is arguably the natural home. I put it in my own new file to stay inside the paths I hold this wave.

wshallwshall added 2 commits September 15, 2026 09:30
#1022)

The guard ships. ADR 0068 never grew an acceptance criterion for it, so the
record under-describes a control that exists.

AC-18 states the TOTP-disable refusal in the shape AC-10 uses for the passkey
path: the same message on the same condition, reaching the caller as a 400 on
the JSON route and as the account page on /ui. It names the three tests that
pin it, plus the positive control and the admin-recovery carve-out.

AC-17's tail said "AC-10 refuses the last-factor case on the self-service
paths". AC-10 is scoped to WebAuthn deletion and covers one path, not two.
Tightened to name one criterion per path.

The web console citation is spelled in full, because that suite moved under
packaging/ and the bare tests/test_webui.py this section uses elsewhere no
longer resolves.

No guard was built. The refusal, its route mapping and its console rendering
are all on main already; this commit touches documentation only.
…cord exit 3 (BACKLOG #1020)

#1020's non-closure rider asks for the refusal to be DEMONSTRATED to terminate
under the runner that ships. It exists because someone inferred, so an
inference cannot satisfy it. Neither test standing near it discharges it:
test_security_notice_deliverability.py drives the real refusal through
lifespan_context, where no process exits; test_lifespan_startup_unwinds.py runs
uvicorn but patches the gate's call site with a raising stub, deliberately,
because its subject is #1257's unwinding. Neither is weakened or reused.

The new test arranges the gate's real preconditions -- notices on,
security_notifications_required true, enforcement=enforce, SMTP fully wired,
and an empty store whose only account is the addressless bootstrap
administrator the lifespan itself mints -- runs the app under uvicorn in a
subprocess, and asserts the process exits 3 carrying the gate's own refusal
text. A warn arm reaches a RUNNING server and self-stops with 99, so "it
exited" was not the only outcome the rig could produce. The two arms share the
app, the gate and the store state; they differ in the dial AND in the uvicorn
entry point, because only uvicorn.run turns a startup failure into an exit code
and it never hands back the Server a self-stopping control needs. The docstring
says so rather than claiming a one-variable experiment.

That also closes ADR 0167's open re-measurement: its exit-code arms were taken
on a minimal repro, and nobody had measured the real gate inside a lifespan
that unwinds properly.

docs/DEPLOYMENT.md gains the exit-3 line the ADR accepted and never got. A
third test pins the two together, because nothing else executes that document.

ADR 0167's H1 and its docs/adr/README.md row now name the placement that
SHIPPED. The filename still says early-in-the-asgi-lifespan, which the ADR's
own overturn banner ruled impossible; the file is deliberately not renamed so
existing citations keep resolving, and both records now say the filename is an
address rather than a claim. Whether to rename is a judgement about the record
that nobody has ruled on, so it is left open.

No gate was built. _assert_security_notice_is_deliverable is on main and is
awaited in the lifespan after auth.initialize(); this commit touches tests and
documentation only.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

LANDER review. Read against head ca3c0abb8. Merge.

THE NEW TEST IS THE BEST-CONTROLLED THING I HAVE READ IN THIS REPOSITORY, and it is worth saying why in detail, because it solves a problem most demonstration tests fail at.

A test that demonstrates SHIPPED behaviour has no red state available to it — the code already works — so the usual question "would this have caught the bug?" has no answer. tests/test_notice_gate_refusal_terminates_under_uvicorn.py answers a harder and better one instead: is the observation I am making distinguishable from nothing?

Three arms, each carrying its own control:

  1. The demonstration. The real gate, real preconditions, run under uvicorn in a subprocess; asserts the process exits 3 carrying the gate's own refusal text.
  2. A POSITIVE CONTROL on that arm — the same app under warn reaching a RUNNING server and self-stopping with a distinct 99. Its failure message states the logic outright: "the control never reached a RUNNING server, so the refusal above is equally consistent with...". Without it, "the child exited" is consistent with the app never having started at all, and a passing demonstration would prove nothing about the gate.
  3. A doc-drift test that CONTROLS ITS OWN INSTRUMENT. test_the_deployment_doc_states_the_same_exit_code pins the operator-facing number, and then does the thing almost nobody does: # POSITIVE CONTROL on the instrument: a substring search that finds nothing proves nothing followed by an assertion that a string which MUST be present is present. A substring search over a file that moved would otherwise report "clean" forever.

The distinct 99 is the load-bearing detail. Exit codes only carry information when the arms are separable, and the docstring says so no larger than it is: "A child that exits is equally...".

THE DOCUMENTATION IS CORRECTED THE WAY THIS REPOSITORY SHOULD CORRECT THINGS. Not swapped out — annotated in place, with the direction of the old error named:

That was true when written and is false now, in the UNDER-claiming direction — a reader planning off it plans to build something that already exists — so it is recorded here rather than deleted.

and

corrected in place rather than rewritten, because a reader who absorbed the old wording needs to see which half moved.

A record that silently agrees with reality teaches nobody which of their beliefs to revise. This one tells a reader exactly that, and it separates "the date this record was corrected" from "a ratification", which is a distinction I have watched three different notes blur tonight.

THE DEPLOYMENT.md ADDITION DOES NOT JUST STATE THE NUMBER, IT DEFUSES THE SURPRISE. It explains why exit 3 is forced — the check needs the user table, the pre-flight is synchronous and opens no store, so the lifespan is the only place the store and the bootstrap administrator are both in hand, and uvicorn exits 3 for a startup failure regardless of what the refusal asks for. Then it states the honest size of the consequence: "This changes nothing operationally under the shipped service wrapper" because NSSM is set to AppExit Default Restart, and "the reason to write the number down is a reader's surprise, not a broken script."

WHAT IT LEAVES OPEN IS STATED RATHER THAN QUIETLY CLOSED. has_notifiable_admin() still has ZERO production call sites, because the shipped gate inlines the enumeration — so the open-coded copies of "who is an enabled administrator" now number FOUR, three in auth/service.py and the gate's own in api/app.py. A PR that tidied the record while implying that was resolved would be the worse outcome. It is worth its own item.

The AC-17 tightening is the same care at small scale: "AC-10 refuses the last-factor case on the self-service paths" was one criterion asserted over two paths, and it now names one per path. A criterion that covers more ground than it was scoped for is how a gap gets certified.

And it explains why a THIRD test was written rather than widening one of the two beside it — test_lifespan_startup_unwinds.py deliberately PATCHES this gate's call site because its subject is #1257's unwinding, and the wiring test drives the refusal under lifespan_context where no process exits. Neither could discharge this rider without becoming a worse test of its own subject.

CLEAN, no failures. Enqueuing.

Verdict: merge.

@wshallwshall
wshallwshall added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit ed9039f Sep 15, 2026
41 checks passed
@wshallwshall
wshallwshall deleted the claude/b1022-1020-records branch September 15, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant