Skip to content

Batch 257 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout, reconcile admin grant and unkeyed counts (BACKLOG #3164 #3121 #3113 #3260 #3145 #2950 #3141 #3170) - #2375

Merged
wshallwshall merged 40 commits into
mainfrom
b257-dchain
Oct 19, 2026

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Status, 2026-10-19 (Manager, adopting batch 257/259). Head 573fbd3: main e6f5347 (engine PR 2431, #3696 items 3 and 4) merged in 2b52200 plus review fix 573fbd3, on top of fc325e9 (main e43ffbd, engine PR 2428, #3070). merge-tree against main e6f5347 rc 0. Before that: main 501bfdc merged (8843d5f) plus review fixes 2ec9197 and cff00e1. The merge carries #3696's paging refusal through pipeline/alerts.py and docs/PHI.md stream 8, keeping this PR's #3170 counts. Commit 9fd48a0 (a lost-race revoke_own_session writes one auth.session_revoke_missed row) is DECIDED KEEP, by the Manager and the Lander under the owner's /driver delegation (PR comments of 2026-10-18 21:13Z and 21:18Z, and the Lander's comment 6255261708). It is not an owner ruling on the substance.

Batch 257 (auth), the auth-service chain: vault BACKLOG #3164, #3121, #3113, #3260 (limb 2), #3145, #2950, #3141, #3170. Three stacked links (D1, D2, D3) plus a merge of main 6f66234, cut as ONE pull request because every link edits messagefoundry/auth/service.py and each branched from the previous link's tip. Sibling batch PRs: 2347 (#3010) and 2361 (#2796).

Owner ruling carried here (2026-10-16, fleet wiki note 20261016T211331989Z-vfzmx7): #3260 limb 2 is BUILT, not accepted as a named gap. It writes one audit row per revoke_own_session miss, cuts the session id to 12 characters, and leaves the response unchanged and identical for an unknown and a foreign id. Manager decision, under the owner's driver rule: on the console plane, an id that is not lower-case hex is recorded as "malformed" instead of its first 12 characters. A real session id is always hex, so the ruling holds for every real id. Crafted text (bidi characters, formula prefixes) never reaches the hash-chained trail. Register 1af question 4 is answered by this build, and 1af needs a re-draft (vault work, after merge).

Merge danger

Reversible: yes -- a revert of the squashed commit undoes all of it. There is no schema migration and no config key. The engine UI seam digest moves (to 5d9c162790077dfc at head 573fbd3, unchanged by the e43ffbd and e6f5347 merges, re-derived at each merge of main); the console and the engine carry it in the same commit, so a revert moves both back together.

Blast radius:

  • auth/service.py: TOTP enrolment refusal, revoke_own_session, the directory re-sync notices, the reconcile apply path and its administrator-grant alert, the store-fault sets.
  • auth/reconcile.py: UNKEYED strikes and referral counts.
  • auth/notifications.py and pipeline/security_notify.py: notice wording.
  • api/app.py: the lifespan credential probe, the reconcile alert wiring, and the OIDC step-up refusals.
  • api/security.py, api/auth_routes.py and api/auth_models.py: step-up refusals and headers.
  • apiclient/client.py: the sign-in timeout.
  • pipeline/alerts.py, pipeline/alert_sinks.py, store/base.py, the web console session routes, and one string in ide/src/stepUp.ts.
  • Docs: docs/SECURITY.md, docs/PHI.md, docs/ASVS-L2-PHASE0-CHANGES.md, and changelog fragments.
  • No pipeline stage, no transport, no store schema.

Evidence (per item)

  • #3164. Before: probe_credential_generation ran the password generator on the event loop in the lifespan. After: it is awaited via asyncio.to_thread. The new tests/test_credential_probe_off_loop.py drives the real lifespan and fails on the old code.
  • #3121. Before: the reconciler's own ROLES_CHANGED and ACCOUNT_DISABLED notices were worded for an administrator's act and closed with "If this was you". After: the directory notices carry source: directory, name no administrator, say the open sessions were ended, and the directory disable has its own subject. Tests pin the full description and subject.
  • #3113. Before: a misleading provision-admin refusal; no refusal row when revoke_session raised. After: items 2 to 7 are built, with raise tests for items 2 and 7. Item 1 was built, then SUPERSEDED when main's #3025 landed: its compare-and-set binds each enable to its own run's staged secret, so the refused run's authenticator entry is never live, and the refusal again says to remove it (main's wording; merge review fix a4d2251). The auth.mfa_enroll_refused row is written in a finally. Items 8 and 9 are declined in writing in confirm_mfa_enrollment.
  • #3260 limb 2. Before: a revoke_own_session miss wrote no audit row. After: one auth.session_revoke_missed row per miss on both planes; the response is unchanged. The JSON and console miss tests fail on the old code.
  • #3145. Items 4, 5 and 6(b)(c)(d) built. Item 3 built for the IdP opener's argument assembly. Declined in one written line each: item 1 (the #2597 contract, pinned by test_the_alert_fires_even_when_the_start_then_fails), item 2 (it would reorder engine.start()), the checks.py preamble half of item 3 (declined when #3063 was not yet on main; #3063 is now on main, so that ground is stale and the decline is open finding 5 of the 2026-10-18 merge), item 6(a), and item 6's extra session read.
  • #2950. Before: EngineClient.login used the 5-second client timeout. After: login, reauth, verify_mfa and confirm_mfa get a 30-second read (SIGN_IN_TIMEOUT_SECONDS, the IDE's value), and every other request keeps 5 s. The test reads request.extensions["timeout"].
  • #3141. Before: a directory reconcile that granted Administrator raised no administrator_granted alert. After: it does, right after the role write, through an injected callable judged on the sealed apply-time roles, and an aborted pass pages nothing. Vault #3602 item 4 closes, and item 8 should close.
  • #3170. Items 1, 3, 4, 5, 6 and 7 fixed with tests. Item 2 is declined by Manager ruling: exempting UNKEYED revocations would change what the mass-revoke breaker counts.
  • Merge of main (b1eccda). Six conflicts resolved by keeping both sides' intent; the merge commit body names each one. Notable: a pass of probe faults only (main's #3100 slice 1) now also strikes an id-less row, as an outage or a referral-only pass does (#3170).
  • Combined tree (b257-dchain head 573fbd3, main e6f5347 merged; Builder, 2026-10-19). ruff and format clean; mypy linux 361, win32 342 and tests 1387, clean; pytest 3232 passed and 2 skipped over 82 files (every test_ad_* including test_ad_reconcile_paging_refusal, test_ldap_*, auth, MFA, OIDC, directory, session, seal, alert and reconcile, posture, inventory, changelog and seam tests), then 2804 passed over 54 files after the review fix; seam digest unchanged at 5d9c162790077dfc; git merge-tree against origin/main e6f5347 rc 0. The earlier merges' checks follow:
    • ruff check and ruff format --check: clean (2099 files).
    • mypy strict: clean on linux (361 files), win32 (342) and tests (1386). scripts/asvs not run.
    • pytest at 8843d5f: 4149 passed, 3 skipped, 0 failed over 81 auth, OIDC, MFA, directory, session, seam and changelog files plus the web console suite; after the review fixes, the oidc_step_up, floor_exempt_amr, mfa_directory_recheck and seam suites pass.
    • Two local tests/test_rig_admin.py failures in the review fork's near-full run (49927 passed) are attributed to a frozen non-editable webconsole copy in that worktree's venv; read that test on CI.
    • git merge-tree against origin/main b4e50b0 (at head cff00e1): rc 0.

Links

Link Branch Head Items
D1 b257-d1 d2c88da1aea5971dbc02277293fc10498624dbc0 #3164, #3121, #3113
D2 b257-d2 e6511df5cbe8f5980a69d8e00fba0889bfa0d153 #3113 carry-over, #3260 limb 2, #3145, #2950
D3 b257-d3 ed6efa27d6982a8a00a1f198a30e63cd543498c7 #3141, #3170, a store-fault carry-over
merge b257-dchain 71addac55377cd26607319e3eb8260b39e1579ac origin/main 6f66234 merged in (b1eccda), plus review round one
merge b257-dchain cff00e191691c79563b8cc40e80f2fcafbf8875a origin/main 501bfdc merged (8843d5f) plus review fixes 2ec9197 and cff00e1
merge b257-dchain fc325e9a4817d9497b9145e21efd5ed2599fda6d origin/main e43ffbd merged (engine PR 2428, BACKLOG #3070); no review fixes
merge b257-dchain 573fbd37b93de3e4f61f58815062c790e7525624 origin/main e6f5347 merged (engine PR 2431, #3696 items 3 and 4) in 2b52200, plus review fix 573fbd3

NOTE: the commit message of c67c178 (D2, #3260) says the UI seam did not move. It did move. The correction is in _ui_seam.py and the console CHANGELOG.

Unread legs

  • Hosted only: the Postgres and SQL Server store legs (store_call_errors and the miss row touch store-fault paths there), windows-service-smoke, and the IDE TypeScript build and tests (one string changed in ide/src/stepUp.ts).
  • gates (ubuntu-latest) and gates (windows-latest). The full suite was not run locally.
  • Load flakes seen by Builders, never on the final tree:
    • tests/test_mfa_directory_recheck.py::test_a_slow_lookup_does_not_hold_the_accounts_credential_queue: D3 saw it fail 2 of 4 whole-file runs; it passed in isolation, and it did not fire on the merged tree.
    • test_asvs_login_deadline::...argon2...: D2 saw a timing failure under load; this branch does not touch argon2.
    • tests/test_generated_credential_failure.py::test_a_failed_refusal_audit_still_answers_with_the_refusal[NotImplementedError]: D1 saw it fail once in parallel.

Known defects shipped open (for rows)

From D1 (round two):

  1. A race's losing provision run says nothing about the password, which is the live one (#3025 territory).
  2. An orphaned session can hold a session-cap slot when its revoke is refused.
  3. Design notes: a typed source field instead of the source string; an async probe method; a generic name for write_audit_soft; merging the two off-loop tests.

From D2 (round two):
4. A client timeout above 30 s gets a shorter read on the queued calls (read=max(...)).
5. store_call_errors lacks pyodbc.Error for an unmapped SQLSTATE (a SQL Server deadlock, 40001, still answers 500).
6. change_password also queues behind the re-proof lock and keeps 5 s.
7. On a store fault, the step-up fallback drops X-Step-Up-Via.
8. The finally row write can replace a propagating CancelledError.
9. The miss row's soft write catches Exception.
10. There are three overlapping store-fault sets.
11. The per-account sign-in queue has no engine-side bound.
12. The #3260 changelog omits the malformed case.
13. store_call_errors() imports the server drivers at every AuthService construction.
14. _SESSION_ID_SHAPE admits short hex and duplicates DIGEST_ID_PATTERN.
15. Some comment lines exceed 100 columns.

From D3 (round two):
16. The referral alert's payload key probed now carries two different counts.
17. The cooldown pop works one way only.
18. There is no user.disabled guard on the grant report (deliberate: it matches sign-in).
19. ValueError breadth across six _audit_write_errors callers.
20. There are two grant mechanisms (sign-in versus reconciler). A row: move the sign-in grant onto the injected callable, which would close the #2610 server-error-after-role-write limit.
21. administrator_granted_alert duplicates the api/security helper.
22. Raw directory_object_id reads remain in two places.
23. _unkeyed_strikes duplicates the strike arithmetic.
24. The same facts are restated across docs (SDS-3.5).

From the merge (round two); items 25 to 27 are being fixed in this batch's next link, D3b:
25. The probe-fault alert and the auth.ad_reconcile_faulted row count plan.probed, while the fault's own message counts plan.asked. Manager ruling: move them to asked, as #3170 did for the referral.
26. docs/SECURITY.md says an all-failure pass still pages; a pass where every probe fails and none faults pages nobody. This error is pre-existing on main.
27. Missing tests: the fault twin of the referral-count test, and a service-level test of a faults-only strike. Also a changelog orphan line, and stale comments in service.py and the REFERRAL_ABORT docstring.
28. The per-source count rule is restated in six places.

Landing-order constraint

None for this PR against main. The next link, D3b, stacks on this PR and on PR 2347 (#3010), and needs both merged first.

Proposed vault banners (from the commit messages, untouched)

#3164: BUILT on engine branch b257-d1. The lifespan awaits asyncio.to_thread(auth.probe_credential_generation) with its guard kept; tests/test_credential_probe_off_loop.py proves the generator runs off the loop thread.

#3121: BUILT on engine branch b257-d1. Reconciler and sign-in re-sync ROLES_CHANGED and ACCOUNT_DISABLED notices carry source directory, name no administrator, and close with "If you did not expect this change"; the directory disable has its own subject; administrator wording kept; tests pin descriptions and subject.

#3113: BUILT on engine branch b257-d1. Items 2 to 7 done with tests (raise test for items 2 and 7); item 1 was built and then superseded by main's #3025 compare-and-set, so the refusal says to remove the shown entry (merge review fix a4d2251); items 8 and 9 declined in writing in confirm_mfa_enrollment. The PHASE0 line is corrected; no vault docs/security file quotes it, so no cell re-read is owed unless the scorecard holder finds one. Addendum (D1 carry-over, round-two findings 1, 3, 5, 6), BUILT on engine branch b257-d2: the auth.mfa_enroll_refused row is written in a finally around the revoke, soft-written itself, so no exception the revoke raises skips it, and aiosqlite's closed-connection ValueError is logged as a store refusal. The directory role notice says the open sessions were ended; the stale notifications.py comment and two SECURITY.md wording points are fixed. Round-two findings 2, 4 and 7 stay open.

#3260: limb 2 BUILT on engine branch b257-d2, closing the row with limb 1 (engine PR 2190). revoke_own_session writes one auth.session_revoke_missed row per miss on both planes, through write_audit_soft, with the id cut to 12 characters (a console id that is not hex is recorded as malformed) and nothing else, so an unknown id and a foreign id get the same answer and the same row (owner ruling 2026-10-16, wiki note 20261016T211331989Z-vfzmx7). The engine UI seam moves (5d9c162790077dfc at head cff00e1). Register 1af question 4 is answered by this build, and the 1af entry needs a re-draft. A revoke of the caller's own session that loses a rotation race writes the same miss row (9fd48a0, decided KEEP under the owner's delegation, not an owner ruling).

#3145: BUILT on engine branch b257-d2. Items 4, 5 and 6(b)(c)(d) built, item 3 built for the opener's argument assembly; items 1, 2, 6(a), the checks.py preamble half of 3, and 6's extra session read declined in one written line each (see the #3145 commit body).

#2950: BUILT on engine branch b257-d2. login, reauth, verify_mfa and confirm_mfa wait 30 s for their reply (SIGN_IN_TIMEOUT_SECONDS, the IDE's value), read phase only; every other request keeps 5 s. The per-account queue has no bound, so a lost reply is rarer, not impossible.

#3141: BUILT on engine branch b257-d3 (batch 257, D3). A reconcile pass whose role write newly gives an account Administrator raises administrator_granted (via directory_reconcile, granted_by ) right after the write, through an injected callable, judged on the sealed apply-time roles; an aborted pass writes nothing and pages nothing. It pops the no_enabled_administrator cooldown as intended. changelog.d/2610 now states the server-error-after-role-write limit. Item 3: the two low test findings fixed; concurrent first sign-ins paging twice and the future-route reminder declined in writing. Vault #3602 item 4 closes with this; item 8 (the reconciler-first timing race) closes too, since the reconciler-first case now pages.

#3170: BUILT on engine branch b257-d3 (batch 257, D3). Items 1, 3, 4, 5, 6, 7 fixed: the referral's message, audit row (field renamed to asked) and alert count plan.asked, the breaker alert keeps probed; outage and referral-only passes strike UNKEYED rows (strikes only); changelog 2434 names the rename; PHI.md lists directory_object_id_missing; the _directory_presence comment is corrected; the inline id checks read _directory_key/_holds_directory_key with reason strings unchanged. Item 2 DECLINED by Manager ruling: exempting UNKEYED revocations would change what the mass-revoke breaker counts. Items 1 and 3 have tests. After the merge of main: a pass of probe faults only (main's #3100 slice 1) also strikes an id-less row.

The api/app.py lifespan called AuthService.probe_credential_generation
synchronously, so the password generator's policy screens ran on the
loop thread at start. A pathological password_extra_context_words list
makes the generator retry many times. The lifespan now awaits
asyncio.to_thread(auth.probe_credential_generation), the same shape as
the Kerberos preflight a few lines below, and keeps its except guard.

The method stays synchronous, so the existing synchronous test in
tests/test_generated_credential_failure.py is untouched, and the verify
CLI caller, which has no loop, is untouched too.

tests/test_credential_probe_off_loop.py drives the real lifespan and
records where the generator's screen ran; it fails on the old code.
A second test pins that the guard still logs a raise and the start
continues.
…trator (BACKLOG #3121)

The directory reconciler's ACCOUNT_DISABLED and ROLES_CHANGED notices
said an administrator made the change and closed with "If this was
you". _apply_reconcile_revocation now tags its notice detail with
source "directory", and so does the sign-in role re-sync in
_complete_ad_login, so the two directory ROLES_CHANGED notices read
alike.

In pipeline/security_notify.py the source tag alone now decides
from_directory, for any kind, so a new directory notice cannot fall
back to the administrator wording by missing from a list. ROLES_CHANGED
and ACCOUNT_DISABLED take directory descriptions that name no
administrator, gain the directory line, and close with "If you did not
expect this change". The administrator path keeps its wording, as the
row says; that wording is not judged correct (BACKLOG #1141, vault
#3072 item 10).

Tests pin the descriptions whole, not only words the subject would
satisfy: the directory variants, the unchanged administrator variants,
the sign-in address line on the re-sync notice, and the existing
directory_sessions_ended description. The reconciler and login-path
tests assert the source tag. docs/SECURITY.md says how directory
notices are worded.
This commit is the BACKLOG #3113 layer; the two before it are #3164 and
#3121.

Item 1: provision_first_administrator's refusal when enable_totp
matches no row no longer tells the operator to remove the entry it
showed. Under the re-stage race (vault #3025) another run can turn on
this run's secret, so the text says to keep the entry until a sign-in
shows which works. A race test drives two runs on one row in a fixed
order and measures that the loser's secret is the live one.
Items 2 and 7: in confirm_mfa_enrollment a store refusal of
revoke_session is logged via write_audit_soft and the
auth.mfa_enroll_refused row is still written; session_ended records
the revoke's outcome instead of a constant.
Items 3 and 5: the Elevation docstring, the "a retry works" comment
and two long SECURITY.md sentences now say what the code does.
Item 4: the docs/ASVS-L2-PHASE0-CHANGES.md TOTP key row no longer says
a live code proves the copy that is enabled; it names #3025.
Item 6: test_webui asserts enable_totp's result.
Items 8 and 9 are declined in code comments: 8 would undo #1902's
rotate-first order; 9 needs enable_totp to return a reason on all
three store backends.

Proposed PR title:
Auth follow-ups: start-up probe off the loop, directory notice wording,
TOTP enrolment refusal fixes (BACKLOG #3164 #3121 #3113)

Proposed banner, #3164: BUILT on engine branch b257-d1. The lifespan
awaits asyncio.to_thread(auth.probe_credential_generation) with its
guard kept; tests/test_credential_probe_off_loop.py proves the
generator runs off the loop thread.

Proposed banner, #3121: BUILT on engine branch b257-d1. Reconciler and
sign-in re-sync ROLES_CHANGED and ACCOUNT_DISABLED notices carry
source directory and render directory wording with the "If you did
not expect this change" closing; administrator wording kept; tests
pin the descriptions.

Proposed banner, #3113: BUILT on engine branch b257-d1. Items 1 to 7
done with tests (race test for item 1, raise test for items 2 and 7);
items 8 and 9 declined in writing in confirm_mfa_enrollment. The
PHASE0 line is corrected; no vault docs/security file quotes it, so no
cell re-read is owed unless the scorecard holder finds one.
#3121: the directory line "not from the MessageFoundry console" is
gone from the role re-sync and disable notices. A re-sync's roles come
from the directory's groups through the group-to-role map an
administrator edits in the console, so that line could be false. The
role notice now says MessageFoundry changed the roles to match the
directory's groups. from_directory is gated on the kind again, so the
reconciler's directory_sessions_ended, which now carries the source
tag, does not claim the directory changed anything. A directory
ACCOUNT_DISABLED gets its own subject, since the administrator subject
says the MessageFoundry account was disabled. Tests render the
as-sent shapes and pin the subject. Comments that called a directory
change an administrator's act are reworded.
#3113: the race test cancels the second run on failure and uses a
low-entropy synthetic secret; the PHASE0 row links SECURITY.md rather
than restating it; the revoke comment states it covers a refusal of
the revoke alone.
#3164: the lifespan comment names what the loop is already running.

Proposed PR title:
Auth follow-ups: start-up probe off the loop, directory notice wording,
TOTP enrolment refusal fixes (BACKLOG #3164 #3121 #3113)

Proposed banner, #3164: BUILT on engine branch b257-d1. The lifespan
awaits asyncio.to_thread(auth.probe_credential_generation) with its
guard kept; tests/test_credential_probe_off_loop.py proves the
generator runs off the loop thread.

Proposed banner, #3121: BUILT on engine branch b257-d1. Reconciler and
sign-in re-sync ROLES_CHANGED and ACCOUNT_DISABLED notices carry
source directory, name no administrator, and close with "If you did
not expect this change"; the directory disable has its own subject;
administrator wording kept; tests pin descriptions and subject.

Proposed banner, #3113: BUILT on engine branch b257-d1. Items 1 to 7
done with tests (race test for item 1, raise test for items 2 and 7);
items 8 and 9 declined in writing in confirm_mfa_enrollment. The
PHASE0 line is corrected; no vault docs/security file quotes it, so no
cell re-read is owed unless the scorecard holder finds one.
…e fault (BACKLOG #3113)

Round-two findings 1, 3, 5 and 6 from the D1 review, carried to this branch.

- Finding 1: confirm_mfa_enrollment writes auth.mfa_enroll_refused in a
  finally around the revoke, so a defect the soft write re-raises
  (NotImplementedError, sqlite3.ProgrammingError) no longer skips the row,
  and aiosqlite's closed-connection ValueError is logged as a store
  refusal, so the confirm still answers session_lost. The row's read and
  write move into _audit_enroll_refused.
- Finding 3: the directory role notice says the sessions already open on
  the account were ended, as the disable notice does; both senders
  (reconciler pass, sign-in re-sync) revoke first.
- Finding 5: the DIRECTORY_SESSIONS_ENDED comment in auth/notifications.py
  no longer says ACCOUNT_DISABLED names an administrator.
- Finding 6: docs/SECURITY.md says the directory notice names no
  administrator as what made the change (its closing still names one),
  and no longer says flatly that the refused confirm ends the session.

Findings 2, 4 and 7 stay open by brief.
…(BACKLOG #3260)

Limb 2 of vault BACKLOG #3260, built on the owner ruling of 2026-10-16
(fleet wiki note 20261016T211331989Z-vfzmx7).

AuthService.revoke_own_session writes auth.session_revoke_missed through
write_audit_soft on a miss, with no defect re-raised, so the 404 and the
console's revoke_missed notice stand whatever the audit write does. The
detail is the id's first 12 characters and nothing else, so an unknown
id and another account's id write the same row as well as getting the
same answer. The method takes a required keyword client, which both
planes pass (DELETE /me/sessions/{id} and /ui/account/sessions/{id}/revoke);
the auth.session_revoked row now carries it too.

The seam digest records names, not parameters, so it did not move; the
_ui_seam history and the console changelog say so. SECURITY.md and a
changelog fragment describe the row.

Tests: tests/test_self_target_refusal_audit.py (JSON plane, unknown and
foreign in one run, plus the success control) and
packaging/messagefoundry-webconsole/tests/test_ui_session_revoke_miss_audit.py
(console plane, unknown, foreign and an over-long plain-text id).
Each item fixed or declined in one written line:

1. Declined. A gate-refused restart paging config_changed on every
   attempt is #2597's crash-loop contract, pinned by
   test_the_alert_fires_even_when_the_start_then_fails (comment in the
   app.py lifespan).
2. Declined the same way: the audit-chain walk runs inside
   engine.start(), and moving it ahead of the notice gate reorders that
   start (comment above auth.initialize()).
3. Built in part. build_idp_opener_from_settings in auth/oidc_http.py
   assembles the arguments for both call sites, AuthService.__init__ and
   verify/federation.py::_idp_opener; each keeps its own error mapping.
   The checks.py settings-preamble refactor is declined: it would
   collide with vault #3063 on the same helper.
4. Built. A step-up refusal made after the flow was redeemed carries
   start_client and client_moved through _step_up_address_fields, the
   helper the success row now uses too. They come from the staged flow,
   never the token; extra stays first. A refusal before the state check
   (or with no flow) carries neither.
5. Built. SECURITY.md documents the two fields; PHI.md's audit_log
   section row no longer says writers "only ever" store shapes, counts
   and ids, and the detail row names the client-address fields.
6. (a) Declined: the client raises IdpStepUpRequired before the
   registered handler because that handler can only re-prove a
   password, which POST /me/reauth refuses for this session.
   (b) Built as wording: the three IdP refusals say to repeat the action
   in the web console, which opens /ui/reauth for it; a bare /ui/reauth
   still bounces an unregistered next to /ui, unchanged.
   (c) Built: _step_up_refusal catches a store fault reading the
   mechanism (driver errors, RuntimeError, OSError, aiosqlite's
   ValueError; AUDIT_WRITE_DEFECTS re-raised) and keeps the 403 with the
   password wording instead of a 500. The one extra session read on a
   refusal is declined: it is the read that picks the wording.
   (d) Built: STEP_UP_VIA_HEADER / STEP_UP_VIA_IDP live in
   api/auth_models.py, which the FastAPI-free apiclient already imports.
… only (BACKLOG #3145)

mypy strict on tests refused reading the constant off apiclient.client, which does not re-export it. The test now pins the constant's value in auth_models and that each of the three modules names it and no longer spells the literal.
…OG #3260 #3145 #2950)

Item #2950. EngineClient.login, reauth and verify_mfa pass
timeout=httpx.Timeout(SIGN_IN_TIMEOUT_SECONDS), a named 30-second
constant matching the IDE's SIGN_IN_TIMEOUT_MS, the way integrity_check
passes its own. reauth queues behind the per-account re-proof lock and
verify_mfa behind the credential lock, and both rotate the token, so
they get the same timeout. The engine's queue (keyed_lock.hold_keyed_lock)
has no bound, so no value covers every wait: the login docstring says a
lost reply is rarer, still possible, and why. Every other request keeps
the 5-second default. apiclient stays FastAPI-free.

New test tests/test_apiclient_sign_in_timeout.py reads
request.extensions["timeout"] on an httpx.MockTransport: the three
requests carry 30 s on every phase and /auth/me keeps 5 s; a second test
reads the IDE's constant from ide/src/signInSession.ts.

Proposed PR title:
b257 D1+D2 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout

Proposed vault banners:

#3113 (D1 carry-over, round-two findings 1, 3, 5, 6): BUILT on engine
branch b257-d2. The auth.mfa_enroll_refused row is written in a finally
around the revoke, so no exception the revoke raises skips it, and
aiosqlite's closed-connection ValueError is logged as a store refusal.
The directory role notice says the open sessions were ended; the stale
notifications.py comment and two SECURITY.md wording points are fixed.
Round-two findings 2, 4 and 7 stay open.

#3260: limb 2 BUILT on engine branch b257-d2, closing the row with limb 1
(engine PR 2190). revoke_own_session writes one auth.session_revoke_missed
row per miss on both planes, through write_audit_soft, with the id cut
to 12 characters and nothing else, so an unknown id and a foreign id get
the same answer and the same row (owner ruling 2026-10-16, wiki note
20261016T211331989Z-vfzmx7). Register 1af question 4 is answered by this
build, and the 1af entry needs a re-draft.

#3145: BUILT on engine branch b257-d2. Items 4, 5 and 6(b)(c)(d) built,
item 3 built for the opener's argument assembly; items 1, 2, 6(a), the
checks.py preamble half of 3, and 6's extra session read declined in
one written line each (see the #3145 commit body).

#2950: BUILT on engine branch b257-d2. login, reauth and verify_mfa wait
30 s (SIGN_IN_TIMEOUT_SECONDS, the IDE's value); every other request
keeps 5 s. The per-account queue has no bound, so a lost reply is rarer,
not impossible.
…d refusal (BACKLOG #3145 #3113 #2950)

- store/base.py gains a cached store_call_errors() (driver errors,
  RuntimeError, OSError, aiosqlite's closed-connection ValueError).
  api/security.py's step-up refusal catches it in place of its own copy,
  the refused enrolment's revoke catches it plus CipherError, and
  AuthService builds it at startup so the first fault does not import
  the server drivers on the event loop.
- api/security.py drops its private _STEP_UP_VIA_HEADER alias for the
  shared auth_models constant; the test imports that, and its source
  scan is replaced by a pin of the wire spelling.
- complete_oidc_step_up binds actor, client, return_to and flow once in
  a partial, so a new refusal arm cannot leave the address fields out.
- apiclient builds its sign-in httpx.Timeout once at module level.

Skipped: a shared test helper for swapping EngineClient's transport
(four copies, three outside this diff); moving the sign-in timeout into
_adopt_rotated (confirm_mfa rotates but holds no per-account lock on the
engine, so it keeps the default).
Confirmed and fixed:
- The seam digest DOES record AuthService method signatures, so the
  required client keyword on revoke_own_session moved it. Regenerated
  ENGINE_UI_SEAM and the golden (0971e2440e4237c7), set the console's
  SUPPORTED_ENGINE_SEAMS, and corrected the _ui_seam note and the
  console changelog, which said the digest did not move.
- confirm_mfa also rotates under the account's re-proof lock, and a lost
  reply there loses the recovery codes: it gets the sign-in timeout too.
- The sign-in timeout raises the read phase only
  (httpx.Timeout(self._timeout, read=30)), so a GUI-thread caller still
  learns of an unreachable engine within the client's own bound.
- The step-up refusal's store-fault catch logs with its traceback, so a
  defect raising ValueError or RuntimeError can be told from a fault.
- The refused enrolment's row write is soft as well, so a store fault
  there cannot replace the answer or a propagating defect; the
  cancellation-after-commit limit is stated in the comment.
- A sign-in flow refused as flow_purpose_mismatch no longer records a
  start_client from another ceremony.
- The console's plain-text session id is copied into the miss row only
  when it is lower-case hex; anything else is recorded as malformed.
- SECURITY.md says the miss row is soft-written; the IDE's step-up text
  says to repeat the action, as the engine's now does.
- The refuse(...) calls lost their magic trailing commas.

Open, out of this brief's scope: _audit_write_errors() still lacks
aiosqlite's closed-connection ValueError for the reconciler's soft
writes; that is the reconcile alert path, left to the D3 Builder.

Proposed PR title:
b257 D1+D2 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout

Proposed vault banners:

#3113 (D1 carry-over, round-two findings 1, 3, 5, 6): BUILT on engine
branch b257-d2. The auth.mfa_enroll_refused row is written in a finally
around the revoke, soft-written itself, so no exception the revoke
raises skips it, and aiosqlite's closed-connection ValueError is logged
as a store refusal. The directory role notice says the open sessions
were ended; the stale notifications.py comment and two SECURITY.md
wording points are fixed. Round-two findings 2, 4 and 7 stay open.

#3260: limb 2 BUILT on engine branch b257-d2, closing the row with limb 1
(engine PR 2190). revoke_own_session writes one auth.session_revoke_missed
row per miss on both planes, through write_audit_soft, with the id cut
to 12 characters (a console id that is not hex is recorded as
malformed) and nothing else, so an unknown id and a foreign id get the
same answer and the same row (owner ruling 2026-10-16, wiki note
20261016T211331989Z-vfzmx7). The engine UI seam moved to
0971e2440e4237c7. Register 1af question 4 is answered by this build,
and the 1af entry needs a re-draft.

#3145: BUILT on engine branch b257-d2. Items 4, 5 and 6(b)(c)(d) built,
item 3 built for the opener's argument assembly; items 1, 2, 6(a), the
checks.py preamble half of 3, and 6's extra session read declined in
one written line each (see the #3145 commit body).

#2950: BUILT on engine branch b257-d2. login, reauth, verify_mfa and
confirm_mfa wait 30 s for their reply (SIGN_IN_TIMEOUT_SECONDS, the
IDE's value), read phase only; every other request keeps 5 s. The
per-account queue has no bound, so a lost reply is rarer, not
impossible.
…builder (BACKLOG #3260 #3145 #2950)

Review round two found tests/test_revocation_guard_census.py red on this
branch: build_idp_opener_from_settings became a site the census did not
list, and its two callers stopped counting as sites, so the census could
no longer see AuthService.__init__'s tie to _refuse_idp_revocation. The
wrapper is now in _BUILDERS, so both callers are sites again and the
census passes (5 of 5).

Round-two findings left open, for the PR body: a client timeout above
30 s shortens the read on the queued calls (use max); store_call_errors
lacks pyodbc.Error for an unmapped SQLSTATE such as a deadlock;
change_password also queues behind the re-proof lock and keeps 5 s; the
store-fault fallback drops X-Step-Up-Via, so a client prompts a
federated user for a password; the finally row write can replace a
propagating CancelledError with a defect; the miss row's soft write
catches Exception; three overlapping store-fault sets; the queue itself
is unbounded (engine-side bound would close the lost reply); the 3260
changelog omits the malformed case; store_call_errors now imports the
server drivers at every AuthService construction; _SESSION_ID_SHAPE
admits short hex and duplicates DIGEST_ID_PATTERN; some added comment
lines exceed 100 columns.

Proposed PR title:
b257 D1+D2 auth follow-ups: TOTP refusal row, directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in timeout

Proposed vault banners:

#3113 (D1 carry-over, round-two findings 1, 3, 5, 6): BUILT on engine
branch b257-d2. The auth.mfa_enroll_refused row is written in a finally
around the revoke, soft-written itself, so no exception the revoke
raises skips it, and aiosqlite's closed-connection ValueError is logged
as a store refusal. The directory role notice says the open sessions
were ended; the stale notifications.py comment and two SECURITY.md
wording points are fixed. Round-two findings 2, 4 and 7 stay open.

#3260: limb 2 BUILT on engine branch b257-d2, closing the row with limb 1
(engine PR 2190). revoke_own_session writes one auth.session_revoke_missed
row per miss on both planes, through write_audit_soft, with the id cut
to 12 characters (a console id that is not hex is recorded as
malformed) and nothing else, so an unknown id and a foreign id get the
same answer and the same row (owner ruling 2026-10-16, wiki note
20261016T211331989Z-vfzmx7). The engine UI seam moved to
0971e2440e4237c7. Register 1af question 4 is answered by this build,
and the 1af entry needs a re-draft.

#3145: BUILT on engine branch b257-d2. Items 4, 5 and 6(b)(c)(d) built,
item 3 built for the opener's argument assembly; items 1, 2, 6(a), the
checks.py preamble half of 3, and 6's extra session read declined in
one written line each (see the #3145 commit body).

#2950: BUILT on engine branch b257-d2. login, reauth, verify_mfa and
confirm_mfa wait 30 s for their reply (SIGN_IN_TIMEOUT_SECONDS, the
IDE's value), read phase only; every other request keeps 5 s. The
per-account queue has no bound, so a lost reply is rarer, not
impossible.
…inistrator_granted (BACKLOG #3141)

- AuthService takes an admin_grant_alert callable, the #3229 shape:
  the API lifespan builds it with pipeline.alerts.administrator_granted_alert
  and the <directory> marker, so auth/ imports no sink.
- _apply_reconcile_revocation raises it right after the role write, on
  the sealed user.role_ids read at the top of the method, never the
  plan's roles. Only Administrator absent before and present after
  pages. Only a pass that did not abort applies a revocation.
  via = directory_reconcile. It pops the no_enabled_administrator
  cooldown, as intended.
- Docs: alert_sinks comment, changelog.d/3229 and 2610 (2610 now states
  the server-error-after-role-write limit and the never-seen grant),
  new changelog.d/3141, SECURITY.md "A directory grant" bullets and the
  signal table, PHI.md alert_instance row, AlertSink docstring, the
  settings alert-type comment.
- Item 3 of the row, from PR 2076 "Known, not fixed": the two low test
  findings are fixed (the console refused-after-sync tests assert the
  role was written; the caplog check filters on the logger name).
  Declined: two concurrent first sign-ins can each page once (a
  duplicate page is the safe direction); a future directory sign-in
  route must remember to call the alert (no route registry to enforce
  it against, and a census test is its own item).
…tage still strikes (BACKLOG #3170)

- Item 1: the referral's latched message, its auth.ad_reconcile_referred
  row (key renamed probed -> asked) and its ad_reconcile_aborted alert
  count plan.asked. The breaker-trip alert keeps plan.probed, its judged
  denominator. AlertSink docstring says which count is which.
- Item 2: DECLINED, one line at the breaker call in plan_pass: exempting
  UNKEYED revocations changes what the mass-revoke breaker counts.
- Item 3: plan_pass's two early returns (outage, referrals only) carry
  the UNKEYED rows' strikes, and only those: no outcomes, so the hold's
  record and judged_nothing are unchanged and the latch carries over.
- Item 4: changelog.d/2434 names the auth.ad_reconcile_skipped probed ->
  asked rename. New changelog.d/3170.
- Item 5: docs/PHI.md ad_session_revoked reasons gain
  directory_object_id_missing (as "at least"); the ad_reconcile_aborted
  count is described per source.
- Item 6: the comment now in _directory_presence no longer says the
  reconciler probes an id-less row by name.
- Item 7: the sign-in, step-up, re-proof, presence and bind refusals read
  _directory_key/_holds_directory_key; reason strings unchanged.
  _directory_key returns the id so mypy still narrows where it is sent.
- Tests: an UNKEYED row is struck on a pass that judged nothing (three
  shapes, with an ordinary-pass control); a referral is counted over the
  probes asked, end to end and through _alert_reconcile_plan.
…n ValueError (b257 D2 carry-over)

D2 carry-over. _audit_write_errors is now CipherError plus
store_call_errors(), the one store-fault set D2 added, so a reconciler
audit write against a closed aiosqlite connection (a bare ValueError,
raised before sqlite3 is reached) is logged and passed over like any
other refused write, instead of ending the pass. Callers that must not
read a bug as a refusal still re-raise AUDIT_WRITE_DEFECTS first.
The skip-report refusal test gains a closed-connection case, red on
the previous set.
…e they are used (BACKLOG #3170)

plan_pass built the UNKEYED strike map on every pass and read it only
in the two early returns. It is now a small helper, _unkeyed_strikes,
called from those returns; the rule and its reason are stated there.

Skipped from the pass: a shared factory for the two user-keyed alert
callables (two copies only); converting the bind and presence refusals
to _directory_key (they need no id); a RolesGained-based grant test in
the reconciler (a two-line rule); reusing another file's test sink.
- changelog.d/3170 and SECURITY.md: an outage or referral-only pass
  strikes an id-less row but revokes nothing, so its sessions end at
  the next pass that is not aborted, not at the threshold regardless.
- SECURITY.md: a sign-in running at the same time as the pass can raise
  a second administrator_granted; a duplicate is the safe direction.
- LoggingAlertSink's ad_reconcile_aborted line no longer calls the
  count the pass's size, since a referral's count is the probes asked.
- Tests: the reconciler-grant test now proves the next sign-in gains
  nothing (the old second-pass check ran on no candidate); the
  reconciler cooldown-pop test gains the cross-account control.
…es (BACKLOG #3141 #3170)

Docs and docstrings only, from the second code-review round:
- SECURITY.md's Fail-OPEN bullet and the referring-base paragraph, and
  reconcile.py's module and judged_nothing docstrings, no longer say an
  outage or referral-only pass strikes or judges nothing: it strikes an
  id-less row (BACKLOG #3170).
- SECURITY.md and changelog.d/3170: an id-less row is revoked only by a
  pass that is not aborted, once its strikes reach the threshold and it
  is in that pass's probe budget.
- SECURITY.md "A directory grant": the cases that raise no
  administrator_granted are "at least" these, adding a breaker-aborted
  pass and recheck_seconds = 0.

Proposed PR title:
Directory reconcile follow-ups: a reconciler grant pages administrator_granted, UNKEYED referral counts and outage strikes (BACKLOG #3141 #3170)

Proposed vault banner, #3141:
BUILT on engine branch b257-d3 (batch 257, D3). A reconcile pass whose
role write newly gives an account Administrator raises
administrator_granted (via directory_reconcile, granted_by <directory>)
right after the write, through an injected callable, judged on the
sealed apply-time roles; an aborted pass writes nothing and pages
nothing. It pops the no_enabled_administrator cooldown as intended.
changelog.d/2610 now states the server-error-after-role-write limit.
Item 3: the two low test findings fixed; concurrent first sign-ins
paging twice and the future-route reminder declined in writing.
Vault #3602 item 4 closes with this; item 8 (the reconciler-first
timing race) closes too, since the reconciler-first case now pages.

Proposed vault banner, #3170:
BUILT on engine branch b257-d3 (batch 257, D3). Items 1, 3, 4, 5, 6, 7
fixed: the referral's message, audit row (field renamed to asked) and
alert count plan.asked, the breaker alert keeps probed; outage and
referral-only passes strike UNKEYED rows (strikes only); changelog
2434 names the rename; PHI.md lists directory_object_id_missing; the
_directory_presence comment is corrected; the inline id checks read
_directory_key/_holds_directory_key with reason strings unchanged.
Item 2 DECLINED by Manager ruling: exempting UNKEYED revocations would
change what the mass-revoke breaker counts. Items 1 and 3 have tests.
Brings b257-d3 (ed6efa2) up to date with main at 6f66234: PR 2317
(auth service follow-ups), PR 2340 (batch 258 wave 1) and PR 2342 (a
directory probe that raises is a fault, not an outage, and it pages).
No rebase; the published d1/d2/d3 commits are untouched.

Conflicts, and what each resolution keeps:

- messagefoundry/auth/reconcile.py: main added ProbeOutcome.FAULT and
  folded a pass of faults only into the referral's early return; the
  chain (vault #3170 item 3) made that early return and the outage's
  carry the UNKEYED rows' strikes. Kept both: a pass of faults only now
  strikes an id-less row too, as an outage or a pass of referrals only
  does, because that row was judged without the directory either way.
  judged_nothing keeps main's probe_fault term and the chain's strike
  sentence. The asked field's comment carries both sides' readers.
- messagefoundry/pipeline/alerts.py: ad_reconcile_aborted docstring
  names main's third source and slug and keeps the chain's per-source
  meaning of probed: the breaker and a probe fault pass plan.probed,
  a referral passes plan.asked (app.py, unchanged by this merge).
- messagefoundry/auth/notifications.py: main's list of the reasons
  that send DIRECTORY_SESSIONS_ENDED (#3171) with the chain's corrected
  last sentence (ACCOUNT_DISABLED says the account is disabled, by an
  administrator or in the directory; vault #3121).
- tests/test_security_notify.py: main's per-reason test and its new
  wording (#3171) replace the chain's pin of the old description; the
  chain's as-sent shape (source directory, #3121) and its assertion
  that the generic directory line is absent are kept as a second
  parametrize axis, and the subject is read through _subject().
- docs/SECURITY.md: the chain's directory-notice paragraph (#3121)
  followed by main's sessions-ended paragraph (#3171), which replaces
  the chain's older one-sentence version of it.
- docs/PHI.md, the alert_instance row: main's probe-fault source, slug
  and inverse wording, with the chain's count wording (#3170),
  directory_object_id_missing reason and reconcile-pass grant (#3141).

Edits outside the markers, to make the two sides agree:

- docs/SECURITY.md: the id-less-row paragraph and main's probe-fault
  paragraph now say a pass of faults only also strikes an id-less row
  and judges no account it asked about.
- pipeline/alerts.py LoggingAlertSink and reconcile._unkeyed_strikes:
  comments name the probe fault beside the breaker and the referral.
- changelog.d/3170.security.md: names probe-fault-only passes beside
  outages and referral-only passes.
- tests/test_ad_session_reconcile.py: the #3170 unkeyed-strike test
  gains faults-only and faults-and-failures cases, pinning the choice
  above.

Auto-merged files read for semantic clashes: auth/service.py (the
provision-admin reorder of #2915 against the #3113 refusal text; the
fault recorder beside the #3170 referral counts; #3117 MFA gates),
api/app.py, pipeline/alert_sinks.py, pipeline/security_notify.py,
store/base.py, and the shared test files. UI seam: main did not move
it, so the chain's 0971e2440e4237c7 stands.

Items touched: BACKLOG #3164 #3121 #3113 #3260 #3145 #2950 #3141 #3170

Proposed PR title: b257 D1-D3 auth follow-ups: TOTP refusal row,
directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in
timeout, reconcile admin grant and unkeyed counts
Applies round-one findings that concern text and tests the merge
itself wrote; nothing here changes behaviour.

- pipeline/alerts.py, docs/PHI.md: the ad_reconcile_aborted count for a
  probe fault is no longer called "judged". It is the same total as the
  breaker's, the candidates the pass took up, asked or not, which counts
  the faulted accounts left unjudged.
- docs/SECURITY.md, changelog.d/3170.security.md: an id-less row is
  struck on any pass that aborts on referrals, probe faults or failures,
  not only on one whose every lookup was referred or faulted.
- tests/test_ad_session_reconcile.py: the unkeyed-strike cases pin the
  abort slug, so a faults-only pass cannot pass as an outage.

Left open, for the Manager: the probe fault's alert and its
auth.ad_reconcile_faulted row count plan.probed while its latched
message counts plan.asked (main's code, PR 2342); #3170's rule for the
referral would apply to it unchanged.

Proposed PR title: b257 D1-D3 auth follow-ups: TOTP refusal row,
directory notices, revoke-miss audit, OIDC step-up follow-ups, sign-in
timeout, reconcile admin grant and unkeyed counts
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

D1, b257-d1 @ d2c88da (#3164 #3121 #3113):

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: round 1, 14 returned: 11 confirmed and fixed, 3 rejected (1 is #3025's race, out of scope by brief; 1 asks to fix #3025 itself; 1 is write_audit_soft misuse, rejected because the helper is generic and _session_seal_failed's revoke uses the same error set); round 2, 12 returned, all open.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

D2, b257-d2 @ e6511df (#3113 carry-over, #3260 limb 2, #3145, #2950):

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: round 1, 11 returned: 9 confirmed and fixed, 1 left open (closed-connection ValueError for _audit_write_errors, out of scope: reconcile path), 1 applied in part (IDE text); round 2, 13 returned: 1 fixed (census red), 12 open.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

D3, b257-d3 @ ed6efa2 (#3141 #3170):

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: 11 confirmed and fixed, 13 rejected or deferred, 12 open.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Merge of main, b257-dchain @ 71addac (merge b1eccda + review round one; the review covered the resolutions against both parents):

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: round 1, 9 returned: 4 confirmed and fixed, 5 left open (main's code or design: fault count, missing fault-count test, stale comments in main, restatement, altitude); round 2, 7 returned, all open.

@wshallwshall wshallwshall added the qa Builder QA record posted; not a merge gate label Oct 17, 2026
Brings PR 2375 (items #3164 #3121 #3113 #3260 #3145 #2950 #3141 #3170)
up to date with main. Main side since 6f66234: #3052 #3194 #3057,
#3674, #3222 #3085, #3063, #3100 #2714, #3014 #3116, #2716 #2724 #2701.

Conflicts, both sides kept:
- docs/PHI.md alert_instance row: the chain's #3170 count wording,
  directory_object_id_missing and #3141 reconcile grant, plus main's new
  sandbox_worker_refused event type (#2724), inserted where main put it.
- packaging/messagefoundry-webconsole/CHANGELOG.md Security: the chain's
  session-revoke-missed bullet (#3260) and main's passkey finish budget
  bullet (#3014), both kept.

Auto-merged files read for semantic clashes (auth_routes.py, service.py,
settings.py, alerts.py, alert_sinks.py, routes/account.py, SECURITY.md,
test_webui.py, test_ad_session_reconcile.py): none found. Main did not
move the UI seam; the chain's seam digest stands.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Second merge of main, head 1ca569a (merge of 71addac and main d8214ae; fast-forward push, no force). Two conflicts: docs/PHI.md alert_instance row (main #2724 clause spliced in at its anchor) and the web console CHANGELOG Security section (both bullets kept). No semantic clash found in the auto-merged files; the UI seam stays at 0971e2440e4237c7 (main did not move it). Checks on this tree: ruff and format clean; mypy strict four legs clean (358/339/1344/6); ledger_check rc 0; pytest 4199 passed, 30 skipped, 1 xfailed, 0 failed over 165 auth/reconcile/notify/alert/apiclient/OIDC/step-up/self-target/provision/MFA/session/passkey/seam/directory files; web console 1061 passed, 3 skipped (PYTHONPATH at the worktree root). merge-tree into origin/main 231e1dd: rc 0.

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: round 1 had 7, 3 fixed, 1 fixed in part, 1 rejected in part (brief requires the +120 s formula), 3 shipped open; round 2 had 6, 0 applied, 6 shipped open.

(The review covered both merge commits, this one and the tail branch b257-dtail; every finding it left open is in the tail docs commit, not in this PR.)

Third main merge into the batch 257 auth chain.

Conflict: messagefoundry/auth/service.py, the federated step-up
callback. This branch binds every post-redemption refusal through the
`refuse` partial (vault BACKLOG #3145, address fields) and reads the
directory key through _directory_key (#2027). Main (#3220) passes
extra=floor_marker on every refusal past the floor, spelled as full
_step_up_refused calls. Kept both: each `refuse(...)` arm now also
passes extra=floor_marker, and the directory-key reads keep this
branch's object_id. The success row keeps the address-fields spread
and takes main's detail.update(floor_marker).

Auto-merged files both sides touched, read for semantic clashes:
messagefoundry/api/app.py, messagefoundry/store/base.py, docs/PHI.md,
docs/SECURITY.md. None found.
Main moved during the 064700f merge; this takes e9e52ae and
3f1f884 too.

Conflicts:
- messagefoundry/api/app.py, the api.security import list: main added
  AUTH_NOT_CONFIGURED (BACKLOG #3105), this branch DIRECTORY_GRANTED_BY
  (vault BACKLOG #3141). Kept both, sorted.
- The web console seam: messagefoundry/api/_ui_seam.py,
  messagefoundry_webconsole/__init__.py and
  tests/golden/webconsole_seam.snapshot. Main's ReloadResult
  restart_required (#3015) and this branch's revoke_own_session client
  keyword (vault BACKLOG #3260) both moved the digest. Kept both
  comment paragraphs and re-derived the digest with
  scripts/webconsole_seam_snapshot.py --write over the merged surface
  (851fb3e4d3d5b825), with the console side set to match.

Auto-merged files both sides touched, read for semantic clashes:
docs/SECURITY.md, packaging/messagefoundry-webconsole/CHANGELOG.md,
packaging/messagefoundry-webconsole/tests/test_webui.py. None found.
Review of the main merge into b257-dchain: the resolution spelled
extra=floor_marker on each of the 13 federated step-up refusals past
the floor, so a new arm written as refuse(REASON) would silently drop
callback_floor_exempt_amr. The marker is now bound into the refuse
partial once, right after it is computed, as the address fields are
bound above (vault BACKLOG #3145). No arm past that point passes its own
extra, and the audit rows are unchanged. BACKLOG #3220.
…he outcome

Main's 838a562 (BACKLOG #3085) changed AuthService._apply_reconcile_revocation
from (revocation) -> bool to (revocation, *, watermark) -> "skipped" | "quiet" |
"reported". The vault BACKLOG #3141 race test in
tests/test_directory_last_admin_alert.py wrapped it with the old signature, so
mypy's tests leg refused it and the call would raise TypeError. The spy now
forwards the watermark and returns the real outcome, as b257-dtail's merge
a4f88a4 does.
After the merge of main, the vault BACKLOG #3141 race test in
tests/test_directory_last_admin_alert.py passed whatever before-set the service
judged the grant on. Its spy wrote exactly the pass's target roles, so the
BACKLOG #3085 apply-time check found nothing changed and skipped the role write
and the grant check with it. The spy now writes Operator beside Administrator,
so the pass's own write still runs and adds nothing, and the test asserts that
write ran. Measured: with the grant judged on an empty before-set the test now
fails; restored, it passes.

The spy is also typed with SessionRevocation, _AuditWatermark and
_RevocationApplied, as the sibling spies in test_ad_session_reconcile.py are,
so a further signature change reaches mypy's tests leg.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Fix for the red test legs, head 8a7fa2b (fast-forward from 43abc1a). Cause: main's #3085 (838a562, PR 2366) changed _apply_reconcile_revocation to (revocation, *, watermark) -> 'skipped'|'quiet'|'reported', and this branch's #3141 spy in tests/test_directory_last_admin_alert.py still used the old signature (mypy test leg, line 406).

  • 30366c8: merge of main d2a3454, no conflicts (the reviewer re-merged the same parents and got an identical tree).
  • 8d4d113: the spy takes (revocation, *, watermark) and returns await real(revocation, watermark=watermark).
  • 8a7fa2b: the race test had become vacuous under #3085 (its spy wrote exactly the target roles, so #3085's no-change skip skipped the write and the grant check); the spy now writes Administrator plus Operator, the test asserts the write ran, and the spy is typed. Checked by changing service.py's before-set to (): the test fails, and passes again when restored.
  • The #3141 alert path under #3085 is correct: _report_directory_admin_grant runs only inside the roles_changed block right after set_user_roles; 'skipped' returns before any role write, so it never alerts; a role write ends in 'reported'.
    Checks: ruff and format clean; mypy strict four legs clean (359/340/1367/6; the tests leg re-run after the repair); ledger_check rc 0; seam digest 909ed2c80270abb3 unchanged (174 seam tests passed); pytest 645 passed, 454 skipped over every file the merge touched plus the four alert/reconcile files, and 272 passed on those four after the repair. merge-tree into main d2a3454: rc 0 (re-read by the Manager against the newest main).
QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 1. Findings: 2 confirmed and fixed (1 vacuous race test, 4 spy typing), 0 rejected, 5 open.

Open findings, each named:
2. service.py ~7894: two reconcilers that both read the old roles both pass roles_changed and both raise administrator_granted (and both clear the no_enabled_administrator cooldown); the docstring and SECURITY.md name only a concurrent sign-in as a second-page source.
3. service.py ~7987: _report_directory_admin_grant's precondition docstring leaves out that, since #3085, the stored roles must also differ from the target.
5. service.py ~7698 and app.py ~9898: the probe-fault row and alert count plan.probed while the latched message counts plan.asked (pre-existing; fixed on the tail, draft PR 2387, which moves them to asked).
6. service.py ~6636: #3085 reads a watermark for every enabled AD user before list_sessions, about N extra store reads per pass (from main).
7. reconcile.py ~39: an uneven mid-sentence line break in the module docstring after the auto-merge (cosmetic).

… #3260 #3145 #2950 #3141 #3170)

Adopted by the batch 259 Manager at the Lander's request. Ten files conflicted.

- auth/service.py, confirm_mfa_enrollment refusal (main #3025, PR 2358 vs
  #3113): kept the PR's soft revoke + finally-written row, and moved main's
  secret_replaced / write_lost reasons into _audit_enroll_refused, which now
  takes verified_cell. Docstrings (Elevation, confirm) carry both notes.
- auth/service.py, provision first admin enable (main #3025): main's
  expected_secret_cell call; the PR's #3113 comment is rewritten, since the
  compare-and-set means another run can no longer enable this run's secret.
  The refusal words (keep the entry) are left as the PR wrote them.
- auth/service.py, three directory lookups (main #3017 _ask_directory, #3081
  rebind.groups and _directory_roles_lost, PRs 2421, 2409): main's calls with
  the PR's object_id = _directory_key(...) and its refuse() helper.
- tests/test_mfa.py: both sides' new tests kept; the PR's two enable_totp
  fakes take and forward expected_secret_cell (main #3025 signature).
- tests/test_provision_first_administrator.py (not textually conflicted):
  the PR's #3113 item 1 race test pinned the pre-#3025 outcome. Its fake now
  forwards expected_secret_cell, and it asserts main's outcome: the first run
  is refused, the second turns on its own secret; the message pins are kept.
- packaging .../test_webui_search_sso.py: main's staged_cell call plus the
  PR's assert-is-True.
- docs/SECURITY.md TOTP paragraph: main's #3025 text plus the PR's
  session_ended / row-always-written sentences (#3113).
- docs/PHI.md: the PR's row with main's renamed CONFIGURATION.md anchor (PR 2422).
- changelog.d/3229: the PR's reconciler re-grant sentence; main's #2711
  removal of "provision-admin still refuses".
- webconsole CHANGELOG: both sides' entries.
- Seam files: main's side, re-derived with --write to 51458c2cec66fd46;
  SUPPORTED_ENGINE_SEAMS set to it; a _ui_seam.py note records the merge.
…(BACKLOG #3113 #3164 #3121 #3260 #3145 #2950 #3141 #3170)

From the xhigh review of merge ee20db1:
- provision-admin's enable refusal says "remove the authenticator entry
  it showed" again (main's wording). Main's #3025 compare-and-set binds
  each enable to its own run's staged cell, so the refused run's entry
  is never live; #3113 item 1's "keep it" advice rested on the closed
  race and, followed, would spend wrong codes against the second-step
  lock. Comment rewritten.
- The race test is renamed, pins the remove wording, bounds both waits
  with asyncio.wait_for, and types its task as ProvisionedAdministrator.
- SECURITY.md TOTP paragraph: the refusal row is written unless its own
  write fails (logged at ERROR); the re-sign-in clause no longer claims
  the session always ended.
- ASVS-L2-PHASE0-CHANGES.md: the TOTP row no longer says the enable does
  not check the staged secret; #3025 made it a compare-and-set.
- _audit_enroll_refused write_lost comment: the revoke is requested, and
  session_ended says whether it happened.
… #3260 #3145 #2950 #3141 #3170)

Second merge by the batch 259 Manager's adopting Builder. Main added PR 2396
(vault #3316, Sec-Fetch-Site) and PR 2416 (deps). Four files conflicted:

- Seam files: main's side, re-derived with --write to 9dffd8fa7f1c4ba2;
  SUPPORTED_ENGINE_SEAMS set to it. In _ui_seam.py both sides' notes are
  kept, with one more recording this merge.
- webconsole CHANGELOG: both sides' entries.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

QA (batch 259 Manager, adopted at the Lander's request) -- merge of main: new head a304763. Merges: ee20db1 (main 440d71a, 10 conflicts) + review fix a4d2251 + a304763 (main 032973b, 4 conflicts). Conflicts: auth/service.py (8 hunks: #3025 compare-and-set reasons moved into _audit_enroll_refused with verified_cell, keeping the #3113 soft revoke + finally row; main's _ask_directory/_directory_roles_lost/rebind.groups with the PR's object_id and refuse helper), tests/test_mfa.py (both sides' tests, PR fakes forward expected_secret_cell), test_webui_search_sso.py, docs/SECURITY.md, docs/PHI.md, changelog.d/3229, webconsole CHANGELOG (both), seam files re-derived to 9dffd8fa7f1c4ba2. Census/inventory fixes: none; semantic fixes: tests/test_provision_first_administrator.py race test re-pinned to the #3025 outcome; provision-admin enable refusal says remove the entry again (#3113 item 1 superseded by #3025); ASVS-L2-PHASE0 TOTP row corrected. Checks: ruff clean; mypy four legs clean (361/342/6/1384); pytest 2862 passed 18 skipped (79 files, first merge) + 470 passed (fix) + 549 passed (second merge); merge-tree vs main rc 0. Resolution review: xhigh, round 1 found 8 (wrong-premise refusal text, doc limits) fixed in a4d2251; round 2 and the review of a304763: no correctness defect, prose notes only (seam merge-note pile-up, PR body digest/item-1 now stale). Final commit covered: yes.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: HELD for the owner. The batch 259 adoption at a304763 looks good to enqueue once CI is green, but vault BACKLOG #3029 (pass Y, item 44, DM4-R2.6) records that 9fd48a0 reverses main's deliberate #3029 'writes no row' decision: a lost-race revoke_own_session now writes one auth.session_revoke_missed row. It is marked HELD FOR THE OWNER. A /driver KEEP was reported from the batch 257 session, but no ruling is recorded in the ledger. I have put it to the owner. Also: the PR body is stale (it still says #3113 item 1 is built, and names old seam digests).

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Batch 257 Manager, on the Lander's hold of 2026-10-18 15:07Z (commit 9fd48a0; vault #3029, pass Y item 44, DM4-R2.6).

The owner was asked directly, in the batch 257 session, twice: on 2026-10-17 and again on 2026-10-18 (today), each time with the options "Keep the row (Recommended)" and "Revert 9fd48a0". The second ask told the owner that the Lander needed a ruling in their own words for the ledger. Both answers, verbatim: "/driver". That is the owner's standing rule: act on a strong recommendation without escalating. The recommendation, made by both the Lander and this Manager at high confidence, is KEEP.

Ruling to record: KEEP 9fd48a0. Every revoke_own_session 404, including a lost rotation race, writes one auth.session_revoke_missed row. Owner, 2026-10-18, by the driver rule (answer "/driver" to a direct ask). The vault #3029 banner's "no audit row" needs updating when this lands.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager (adopting batch 257/259 handoffs, worktree MessageFoundry-manager-2026-10-18): the race-miss audit row held for the owner.

Decided 2026-10-18 under the owner's delegation. This is NOT an owner ruling.

At 2026-10-18 ~21:12Z the Manager asked the owner by AskUserQuestion, verbatim:

Engine PR 2375 (batch 257) is held for you: its commit 9fd48a0 makes a lost-race revoke_own_session write one auth.session_revoke_missed audit row, reversing main's deliberate #3029 'writes no row' decision. A batch-257 /driver said KEEP, but no owner ruling is recorded. The PR also now conflicts with main, so it needs a merge-main repair either way. Which do you rule?

Options offered: "Keep the row (Recommended)", "Revert to no row", "Leave 2375 parked". The owner answered: /driver (delegation: the Manager decides by its own recommendation).

Decision: KEEP. A revoke_own_session that loses the rotation race writes one auth.session_revoke_missed row, the same row the unknown-id and foreign-id 404 paths write. Grounds: it adds an audit row and weakens no control; the answer (404) and the absence of an auth.session_revoked row are unchanged; one row for every 404 cause is simpler than a carve-out; the Lander and the Lander's ledger worker (vault #3029, pass Y) both recommended accept.

If the Lander's merge rule needs an explicit owner ruling for this behaviour change rather than a delegated decision, say so here and the Manager will put a fresh question to the owner.

Next: this PR conflicts with main (docs/PHI.md, api/_ui_seam.py, auth/service.py, webconsole __init__.py, the seam snapshot). It moves the web-console seam, as does PR 2359, so the Manager will brief the merge-main repair after 2359 merges, to avoid re-deriving the seam twice. The stale PR body (#3113 item 1 superseded by #3025; old seam digests) will be corrected with that repair.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander (account 5, 2026-10-18): still HELD FOR THE OWNER.

The Manager's KEEP on 9fd48a0 (comment 6255219251) is a decision under the owner's Driver delegation, not an owner ruling. Vault BACKLOG #3029 reserves this choice to the owner, and the change writes a row where main deliberately wrote none, which is a behaviour change the owner rules on. The Lander's merge rule needs that explicit owner ruling first.

The question is in the Lander's owner table, recommendation KEEP. The merge-main repair the Manager plans after 2359 lands can go ahead meanwhile: it is needed whichever way the owner rules.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager: second ask to the owner on the race-miss audit row, recorded verbatim. Still not labelled an owner ruling.

At 2026-10-18 ~21:17Z, after the Lander's 21:14:21Z comment, the Manager put this to the owner by AskUserQuestion, verbatim:

OWNER RULING needed (a /driver answer will not do: the Lander's merge rule requires your explicit ruling for a behaviour change, and vault #3029 reserves it to you). Engine PR 2375: should a revoke_own_session that LOSES the rotation race write one auth.session_revoke_missed audit row (as the unknown-id and foreign-id 404 paths already do), reversing main's 'writes no row' for that case? Adversarial review skipped: it cannot supply authority, only judgement, and judgement already agrees. Recommendation KEEP, high confidence: it adds an audit row, weakens no control, the 404 answer is unchanged; the Lander and its ledger worker also recommend KEEP. The only missing input is your authority.

Options: "Rule KEEP (Recommended)", "Rule REVERT". The owner answered: /driver.

The Manager reads this as the owner delegating again, knowingly, to a question that said a delegation would not satisfy the merge rule. Whether that is enough for the Lander's rule is the Lander's call, not the Manager's; the Manager will not re-ask unprompted. The decision under delegation stays KEEP (comment 6255219251).

The merge-main repair proceeds after PR 2359 merges, as the Lander agreed.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander, 2026-10-18: DECIDED KEEP for 9fd48a0 (the one auth.session_revoke_missed row on a lost race).

After the Lander's hold note above, the owner invoked /driver in the Lander's chat, which delegates the call. Under step 1 the Lander holds a strong recommendation: the change adds an audit record of a missed revoke and takes no other action. This is a Lander decision under the owner's Driver delegation, not an owner ruling on the substance; the vault #3029 note is in the Lander's next ledger pass.

Still needed before enqueue: the merge-main repair the Manager briefs after 2359 lands, a QA line naming the new head, and green required checks.

Main brought PR 2373 (BACKLOG #3082, DirectoryRefusal), PR 2382 (CRL
follow-ups), PR 2377 (#2956), PR 2359 (batch 263 wave 1) and PR 2346.

Conflicts:
- auth/service.py complete_oidc_step_up: main's _directory_roles_lost
  now returns a reason or None (#3082, DIRECTORY_ROLE_MAP_UNSEALED);
  kept that with this branch's bound refuse partial, so the reason is
  refused as main returns it and the floor marker stays bound.
- docs/PHI.md alert_instance row: main's crl_reload_applied text
  (#3543) and this branch's #3141/#3170 text, both kept.
- api/_ui_seam.py history: both sides' entries kept, plus a note for
  this merge. Seam re-derived with --write on the merged tree to
  5d9c162790077dfc (main 5b6fd34b36a52c84, branch 9dffd8fa7f1c4ba2);
  SUPPORTED_ENGINE_SEAMS and the golden set to match.
…note

- tests/test_oidc_step_up.py: the #3082 unsealed role-map refusal on the
  IdP leg now asserts its auth.reauth row, including start_client and
  client_moved, so the merge of main's reason with the bound refuse
  partial is pinned (red when the arm calls _step_up_refused without
  flow=, checked by mutation).
- api/_ui_seam.py: the merge note named PR 2359 as a main-side seam
  move; it was #2954 (PR 2377). PR 2359 moved no seam field.
…omments

- tests/test_oidc_callback_floor_exempt_amr.py: the #3220 marker test
  now covers DIRECTORY_ROLES_DEMOTED and DIRECTORY_ROLE_MAP_UNSEALED,
  so the merged role arm is pinned to the bound refuse partial that
  carries the marker (red, checked by mutation, when the arm calls
  _step_up_refused with flow= and no extra=).
- auth/service.py complete_oidc_step_up: the role-test result is named
  refusal, not lost, which the same partial takes as its session_lost
  keyword; the comment above it names the unsealed-map refusal (#3082).
- api/_ui_seam.py: main's 40dde1530423fece entry says "at least" and
  names the batch 257 (#3260) entries it does not cover.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager: main is merged in. The new head is cff00e1, a fast-forward from a304763: 8843d5f merges main 501bfdc (which carries PR 2359), then review fixes 2ec9197 and cff00e1. The Builder's git merge-tree --write-tree origin/main HEAD exited 0 against main b4e50b0. The commits main added after 501bfdc touch no seam, auth or conflicted file. 9fd48a0 is kept (decided KEEP). The PR body is updated to this head.

Resolution:

  • auth/service.py complete_oidc_step_up: main's #3082 _directory_roles_lost now returns a reason, so it can return DIRECTORY_ROLE_MAP_UNSEALED. That reason is routed through this branch's bound refuse partial, so the row keeps the #3145 address fields and the #3220 floor marker. Main's directory_unconfirmed to directory_refusal rename auto-merged.
  • docs/PHI.md, the alert_instance row: merged word by word.
  • _ui_seam.py: both history entries are kept, plus a note for this merge.
  • The seam snapshot and SUPPORTED_ENGINE_SEAMS were not hand-merged. They were re-derived with webconsole_seam_snapshot.py --write. New digest 5d9c162790077dfc (main was 5b6fd34b36a52c84).

Checks:

  • ruff and format: clean.
  • mypy strict: linux 361, win32 342, tests 1386, all clean.
  • pytest: 4149 passed, 3 skipped, 0 failed, over 81 auth, OIDC, MFA, directory, session, seam and changelog files plus the web console suite.
  • The review fork's near-full run: 49927 passed and 2 failed, both in test_rig_admin.py. They are attributed to a frozen webconsole copy in that worktree's local venv. That attribution is unverified, so read test_rig_admin on CI.

QA -- korus roles/BUILDER.md step 11 / Level: xhigh, from the brief. Tag: none returned (each round returned a JSON findings list). / Rounds: 2. Findings: round 1 raised 6: 2 fixed (the unsealed-map step-up row pinned; the seam note's misattribution), 4 shipped open (the PR's own code). Round 2 raised 6: 4 fixed (the #3220 marker test covers DEMOTED and MAP_UNSEALED; lost renamed to refusal; the comment; the seam entry), 2 shipped open. Neither round found a correctness defect in the conflict resolution. 6 open, listed below.

Open findings:

  1. apiclient/client.py:1092: _sign_in_timeout sets read=30 s unconditionally, so a client built with a longer timeout gets a shorter read. This is body item 4.
  2. auth/service.py ~5615: partial(refuse, extra=floor_marker). A future arm that passes extra= would drop the marker. No arm does that today; this is the known DM3-R2.6.
  3. auth/service.py:225: _audit_write_errors gains ValueError breadth. This is body item 19.
  4. _report_directory_admin_grant has no user.disabled guard. This is body item 18, deliberate.
  5. NEW: the auth/oidc_http.py:203 docstring declines the checks.py preamble "because it would collide with #3063". #3063 is now on main, so that ground is stale. The body now says so.
  6. NEW, test strength only: the unsealed-row test runs both legs at one address, so it never exercises client_moved=True.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: enqueued pinned at cff00e1. All required contexts completed successfully on this head, the test legs that run test_rig_admin among them; the only red is the advisory ASVS anchors check. Review proof: the QA comment 6256556037 (code-review at xhigh, 2 rounds, no correctness defect in the resolution). KEEP on 9fd48a0 is decided (6255261708). Its six open findings are recorded on vault #3751 (Q1 to Q4 and Q6 as item 46) and #3145 (Q5), merged in vault PR 2533.

@wshallwshall
wshallwshall added this pull request to the merge queue Oct 19, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 19, 2026
One conflict, in AuthService.__init__ in messagefoundry/auth/service.py.
Both sides kept: main's _session_write_errors() warm-up stays inside
the directory_reconcile_enabled branch beside _audit_write_errors(),
for the rename refresh's holder re-read; this branch's unconditional
store_call_errors() warm-up stays after that branch.

_refresh_cached_username and the rename loop merged clean: this branch
does not touch them, so main's #3070 holder re-read is taken as is.
Web console seam unchanged: digest 5d9c162790077dfc.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager: main is merged in again. The new head is fc325e9, the merge of main e43ffbd (which carries engine PR 2428, BACKLOG #3070) into cff00e1; fast-forward. The Builder's git merge-tree --write-tree origin/main HEAD exited 0 at main e43ffbd. Controls: a self-merge exits 0, and the pre-fix head cff00e1 against main exits 1. The body is updated.

Resolution. One conflict, in messagefoundry/auth/service.py, in the warm-up calls at the end of AuthService.__init__. Both sides are kept: main's _session_write_errors() stays inside if self.directory_reconcile_enabled: beside _audit_write_errors(), and 2375's unconditional store_call_errors() warm-up follows. _refresh_cached_username and the rename loop merged without conflict; the reviewer confirmed the merge's service.py delta matches PR 2428's own diff line for line. The seam digest is unchanged at 5d9c162790077dfc.

Checks: ruff and format clean; mypy linux 361, win32 342, tests 1386, all clean; pytest 1776 passed over 49 auth, OIDC, MFA, directory, session, seal, seam and changelog files. Skipped: the full suite.

Next merge conflict: engine PR 2431 is queued, and this head conflicts with its queue ref in docs/PHI.md and pipeline/alerts.py. If 2431 lands first, the Manager re-syncs this PR again.

QA -- korus roles/BUILDER.md step 11 / Level: xhigh, from the brief. Tag: none printed (the result opened "Review range: merge fc325e9a4, parents cff00e191 (PR 2375) and e43ffbd41 (main)"). / Rounds: 1 review, 0 repair rounds. Findings: no defect in the merge's own resolution; 10 findings on content kept from either side or on 2375's own changes; 10 open, listed below.

Open findings. None was introduced by this merge; each existed on one side before it:

  1. The #3070 holder re-read (service.py near 7874, from main) catches store_open_errors(), which has no ValueError, while 2375 counts a closed aiosqlite connection's ValueError as a store refusal. A ValueError at that read raises the whole pass, where sqlite3.OperationalError skips one rename. This is not a regression: main has no ValueError in either tuple. It is a split between two definitions of a store refusal, tied to 2375's open store_call_errors finding.
  2. store_call_errors() leaves out pyodbc's Error root, so a SQL Server deadlock victim would get past 2375's new soft catches (_step_up_refusal would answer 500, not 403).
  3. refused = (*store_call_errors(), CipherError) rebuilds _audit_write_errors() inline.
  4. Four store-refusal tuples now overlap and disagree.
  5. No test pins the warm-ups this merge resolved.
  6. The probe-fault event still reports plan.probed, while its latched message counts plan.asked.
  7. _report_directory_admin_grant has no user.disabled guard (body item 18, deliberate).
  8. except store_call_errors() in api/security.py:1255 downgrades decode errors to a WARNING "store fault".
  9. The _session_write_errors docstring says "Narrower than _audit_write_errors", which is false.
  10. SDS-3.5: the aiosqlite ValueError fact is stated three times.

PR 2431 is vault BACKLOG #3696 items 3 and 4. Two conflicts, both
resolved by keeping both sides:

- docs/PHI.md, stream 8 alert_instance row: main's text is the base.
  It adds the paging refusal reason, source and payload, scopes "never
  a username", and moves ad_reconcile_held into the inverse sentence.
  This branch's four edits are re-applied word for word: the #3170
  count wording, at least plus directory_object_id_missing, the #3141
  reconcile-pass grant, and "sign-in or reconcile pass".
- pipeline/alerts.py, AlertSink.ad_reconcile_aborted: main's five
  sources and slugs, with this branch's #3170 per-source count. A
  paging refusal sends plan.probed, so it joins the probe fault's
  clause. LoggingAlertSink's count comment names it too.

auth/service.py, auth/reconcile.py and api/app.py merged clean. The
paging latch runs beside the #3170 counts (referral uses plan.asked,
paging uses plan.probed). A paging-refused probe is UNAVAILABLE and
carries no revocation, so the #3141 admin-grant notices do not see it.
Web console seam unchanged: digest 5d9c162790077dfc.
Review round one of the PR 2431 merge. The #3170 count text was
written before per-account paging alerts existed, and now covers them:

- AlertSink.ad_reconcile_aborted: a paging refusal's count is the
  pass's, though it concerns one account, and its detail is a fixed
  sentence, not a latched message. The probe-fault clause again says
  the faulted accounts, not the paging refusal's.
- LoggingAlertSink: the line says the count is the pass's ("in a pass
  counting"), so one paged account no longer reads as N unrevoked.
- docs/PHI.md stream 8: the paging clause's undefined "the probed
  count" is now "the same count as a probe fault's".
- api/app.py _alert_reconcile_plan: a paging refusal has no inverse
  either, as PHI.md and _alert_paging_refusals already say.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Manager: re-synced against engine PR 2431, and this comment is the handover to the Lander, because session messaging is paused. The new head is 573fbd3: 2b52200 merges main e6f5347 (PR 2431), then 573fbd3 is the review fix; fast-forward from fc325e9. The Builder's git merge-tree --write-tree origin/main HEAD exited 0 at main e6f5347. Controls: a self-merge exits 0, and the pre-merge fc325e9 against main exits 1. The body is updated.

Resolution:

  • docs/PHI.md stream 8: started from main's row (#3696's paging source and payload, "never a username" scoped to breaker, referral and probe fault, and the new inverse sentence), then re-applied this branch's four edits word for word (the #3170 count wording, "at least" plus directory_object_id_missing, the #3141 grant, "sign-in or reconcile pass"). A word diff in both directions confirms each side's edits survive.
  • pipeline/alerts.py: main's five sources and slugs, plus this branch's per-source #3170 count. The paging refusal shares the probe fault's plan.probed clause, as main ships it.
  • auth/service.py, auth/reconcile.py and api/app.py auto-merged, and the Builder read where they meet. The paging latch runs before any return, and its row is written on both the abort and normal paths. The probe arm's LdapError carries detail=cause. A paging-refused probe never reaches the #3141 admin notices. replace() copies keep paging_refused.
  • The seam is unchanged at 5d9c162790077dfc.

Checks: ruff and format clean; mypy linux 361, win32 342, tests 1387, all clean; pytest 3232 passed and 2 skipped over 82 files, then 2804 passed over 54 after the fix. Skipped: the full suite.

QA -- korus roles/BUILDER.md step 11 / Level: xhigh, from the brief. Tag: none printed (the result opened "The merge resolution itself checks out. I found no correctness bug in the resolved code, but I found nine smaller defects"). / Rounds: 1 repair round, not re-reviewed after. Findings: resolution verified word for word; 9 findings, 4 fixed in 573fbd3 (the AlertSink docstring for the paging case, the LoggingAlertSink text no longer reading one paged account as N, the PHI.md "probed count" wording, and the app.py docstring on no inverse); 5 open, listed below.

Open findings, candidates for rows. The Manager leaves main's shipped behaviour as is in this PR:

  1. Main's paging alert and its row count plan.probed, which includes id-less rows never asked. This branch's #3170 moved the referral to plan.asked. On an outage-shaped pass, auth.ad_reconcile_skipped then records asked=1 while the paging row records probed=4. Changing it changes behaviour, so it is a row against #3696, not this merge.
  2. This branch's open store_call_errors() breadth finding now also reaches main's auth.ad_reconcile_paging_refused row: a ValueError defect in _audit is logged at ERROR and the page fires with no matching row.
  3. No test pins the paging alert's count or the row's detail key, or a paging refusal on an outage-shaped pass beside an id-less row.
  4. SDS-3.5: the per-source count rule is stated in at least six places.
  5. Main's text, outside this diff: docs/SECURITY.md (around line 4654) says a directory-reconciler* source glob matches the breaker, referral, probe fault and hold. It also matches every per-account paging instance, so muting a probe-fault page that way mutes every paging page. This is a row against #3696.

The earlier open findings still stand: the #3070 re-read does not catch ValueError, and store_call_errors() lacks pyodbc's Error root.

@wshallwshall

wshallwshall commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Lander: enqueued pinned at 573fbd3. All required contexts completed successfully on this head. Review proof: the QA in the Manager's handover of 05:10:47Z (code-review at xhigh on the re-sync against PR 2431: resolution verified word for word, no correctness defect, four findings fixed in 573fbd3). KEEP on 9fd48a0 was decided earlier (6255261708). The open findings are filed: vault #3751 items 47 to 52 (merged in vault PR 2537), and this re-sync's five as vault #3880 (open vault PR 2539). They describe behaviour main already ships after PR 2431; none is a change this PR makes.

CORRECTED 2026-10-19 (UTC), by the Lander: this comment said the re-sync's five open findings 'describe behaviour main already ships after PR 2431; none is a change this PR makes.' That is wrong for findings 2 and 4. Findings 1, 3 and 5 are main's after PR 2431. Findings 2 and 4 are introduced by this PR: the store_call_errors() ValueError breadth now reaches main's paging row, so a ValueError in _audit is logged at ERROR and the page fires with no matching row; and the #3170 per-source count rule is restated. Graded again: finding 2 is a new instance of the ValueError breadth already filed and carried as vault #3751 item 19. The page still fires and the lost row is logged, so it does not weaken the alert against main, where that ValueError would propagate. Finding 4 is SDS-3.5 prose. The enqueue stands, as a Lander decision under the owner's Driver delegation.

@wshallwshall
wshallwshall added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit 6d6986f Oct 19, 2026
55 checks passed
@wshallwshall
wshallwshall deleted the b257-dchain branch October 19, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying. qa Builder QA record posted; not a merge gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant