Skip to content

Batch 257 tail: refused search base pages, supersession follows a retired-hash lineage, lock-notice follow-ups (BACKLOG #3010 #3170 #3120 #3028) - #2387

Open
wshallwshall wants to merge 74 commits into
mainfrom
b257-dtail
Open

wshallwshall wants to merge 74 commits into
mainfrom
b257-dtail

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Batch 257 (auth), the last PR: the #3010 and #3170 reconciler follow-ups, vault BACKLOG #3120 (lock-notice follow-ups and the audit actor index), and vault BACKLOG #3028 (login supersession follows a time-bounded retired-hash lineage, ADR 0210).

Status, 2026-10-19 (batch 257 Manager): READY. Engine PRs 2347 (#3010) and 2375 (the D1-D3 chain) have both landed, and main 6d6986f is merged in at head fd7884f. The diff against main is now only this tail's own work: 59 files, +3028/-525 (#3010/#3170 reconciler follow-ups, #3120, #3028). Two changes from the earlier description: main's #3017 _service_session now does the service-connection release that D3b built (the duplicate was removed); and main's PR 2431 paging-refusal latch is kept beside D3b's misconfigured alert, because they cover different conditions (an engine-side paging bound per account, versus a directory result code 32/34/50 per pass). One merge-emergent behaviour, accepted by the Manager: a service-account bind refused with 32, 34 or 50 now reads MISCONFIGURED and pages directory_misconfigured. It adds a page and weakens nothing; finding 1 below names the alert-text gap. Full suite on this tree: 50082 passed, 1232 skipped, 151 xfailed, 0 failed (run twice).

Merge danger

Reversible: yes, but a revert leaves an orphan table and indexes on a server store that has already run provision-schema. The new session_lineage table and the ix_audit_actor / ix_audit_actor_id indexes move the server schema hash. A revert of the code leaves them in place: the table goes unused and the indexes stay as harmless extras. SQLite rebuilds from code. No data migration of existing rows.

Blast radius:

  • The directory reconciler and auth/ldap.py: a new MISCONFIGURED probe outcome and its alert; a referral in a bind reply now reads as a referral; the service connection is released explicitly.
  • auth/service.py: login supersession, session rotation, the session reaper, and the lock-notice throttle.
  • auth/notifications.py and pipeline/security_notify.py: notify now returns a bool.
  • The store, all three backends: the audit table gains two indexes, and there is a new session_lineage table.
  • api/app.py: lifespan wiring, and the misconfigured alert.
  • Docs: docs/SECURITY.md, docs/PHI.md, docs/CONFIGURATION.md, ADR 0210, and changelog fragments.

Evidence (per item)

  • #3010 follow-ups (D3b). Before, with PR 2347 alone: a deleted or moved user search base reads as an outage on every probe and pages nobody. After: result codes 32, 34 and 50 set a new MISCONFIGURED outcome, and that account is never revoked. A pass with such a refusal and no referral aborts as directory_misconfigured, latches an alert, audits auth.ad_reconcile_misconfigured, and pages ad_reconcile_aborted. A bind reply carrying a referral reads REFERRED, decided by where in ldap3 the error was raised; a test pins the ldap3 internals it relies on. authenticate and probe_principal unbind their service connection; a local-server test counts sessions opened against unbinds across 7 cases.
  • #3170 follow-ups (D3b). The probe-fault alert and its auth.ad_reconcile_faulted row count asked, as the referral does; the breaker keeps probed. New tests: the fault and refusal twins of the counted-over-asked test, and a service test that a faults-only or refusals-only pass strikes an id-less row but never revokes it.
  • #3120 (D4, D4b).
    • Before: a lock notice dropped because the queue was full still recorded mailed: true, so the 24-hour throttle suppressed the next one. After: SecurityNotifier.notify returns whether the notice was queued, and a dropped notice records mailed: false.

    • Actor index: the throttle scan seeks ix_audit_actor (actor, action, id), and ix_audit_actor_id (actor, id) keeps actor-only reads fast. Measured on SQLite 3.50.4 with 600k rows, 300k under one actor:

      Read main this PR
      actor-only page 0.059 ms 0.053 ms
      page at offset 100000 8.6 ms 2.9 ms
      export keyset 0.051 ms 0.055 ms
      wide window 82 ms 0.055 ms
      rare actor 25 ms 0.016 ms
      throttle 0.057 ms 0.057 ms
    • One accepted regression: a narrow time window over a heavy actor takes about 50 ms, against about 0.01 ms on main. No measured index shape wins every case. Filed as a row.

    • Postgres keys on left(actor, 200) with a whole-value recheck, so a long typed username cannot fail an audit write. An 8000-character actor test covers it.

  • #3028 (D5).
    • Before: an elevation that finished before a new sign-in's supersession statement left the old session live under its rotated token. tests/test_session_supersession_lineage.py::test_an_elevation_that_finished_before_the_sign_in_does_not_keep_the_session_live fails on the old path ("the session lives on under its new token"), along with 3 more tests. After: 14 of 14 pass.
    • Design (ADR 0210): each rotation records retired hash to successor hash in a session_lineage row, in the same transaction as the re-key. Supersession revokes the live end of the chain: at most 32 hops, inside a window of oidc_flow_ttl_seconds + 120 s, in one transaction. The reaper purges expired links.
    • A retired hash authenticates nothing. A behavioural test covers it, including a forged link, and a code-level (AST) test pins that only three methods touch the table. The links are deliberately unsealed: a forged link can only direct a revoke.
  • Checks on the tail tree:
    • Full suite with -n 8: 47987 passed, 1202 skipped, 154 xfailed, 1 failed. The one failure, tests/test_child_process_environment.py::test_the_worker_does_not_search_the_working_directory_and_loads_the_parents_build, comes from setting PYTHONPATH to the worktree root. It fails the same way on main 231e1dd with that setting, and passes without it.
    • D5's own full suite before the merge: 47873 passed, 0 failed.
    • mypy strict, all four legs: clean.
    • ruff: clean. ledger_check.py --ci: rc 0.
    • Docs fixes: 3448 passed over 70 doc and config files.
    • git merge-tree into main 231e1dd: rc 0.

Links

Link Branch Head Items
D3b b257-d3b d8d955a5e8b0fecce9ce03a3c52f457fae20e673 #3010 limb 5, #3170 follow-ups (merges batch/b257-3010)
D4 b257-d4 8bbae9b21ed47ddec3d522492571365fda72c477 #3120, D3b doc carry-over
D4b b257-d4b a67d10587932615f54fc2b5b5f1f5b1dffbf2233 #3120 actor-read index fix
D5 b257-d5 323c42fe2bf48374cedb1acbe406a1e03e409361 #3028 (ADR 0210)
tail b257-dtail 120eedff0eac5626d3f871ff19d80c908f8c02e8 merge of main (via the updated PR 2375 head) and three doc wording fixes

Unread legs

  • Postgres and SQL Server store legs. D5's test_session_lineage_contract and the Postgres prefix index have never run.
  • CI gap: NEITHER server leg in ci.yml names tests/test_audit_actor_index.py. Its Postgres SQL and its near-prefix test therefore run nowhere in CI, even after this PR. Row.
  • windows-service-smoke, gates (ubuntu-latest) and gates (windows-latest).

Known defects shipped open (for rows)

D3b

  1. A failed service-account bind (49, an expired password, a locked account) still reads UNAVAILABLE, so every pass is a silent outage that pages nobody.
  2. Other standing refusals still page nobody: result code 11, "too many pages", and an undecodable search.
  3. _is_bind_referral's comment is wrong about a private-class tag 3, and no test plants one.
  4. The user connection's release is not quiet, so the present and absent branches answer differently on an unbind failure.
  5. A failed quiet unbind can leave the socket open.
  6. These lists are not updated: _directory_step_up_refusal, ADR 0083, and _unjudged_others (which skips referrals).
  7. Three alert blocks and three recorders are copy-pasted.

D4 and D4b

  1. The bounded shutdown drains sum to 17 s, which is past NSSM's 15 s stop window. The comment states it; no bound was changed.
  2. The narrow-window heavy-actor read regression (above).
  3. Unmeasured write cost of the two extra indexes.
  4. The throttle's limit=50 page is shared across lock kinds.
  5. Test hygiene on a shared server database: rows left behind, and a fixed overlong-actor name.
  6. Duplicated notifier fakes and plan-capture helper; send_failed is restated in about six places.

D5

  1. The 120 s slack ignores the configured AD and IdP timeouts.
  2. The window includes the flow TTL even when OIDC is off.
  3. Engine shards with different TTLs can purge links another shard still follows.
  4. The lineage purge takes a second connection borrow on the server backends.
  5. The Postgres supersede opens a transaction even when the first revoke matches.
  6. No test pins the retired_at timing.
  7. Altitude: a rotation-stable session id instead of a lineage table.

Docs follow-up

  1. No test pins MISCONFIGURED inside the breaker's denominator, and that it dilutes the proportional half is not recorded as a decision.
  2. The service.py lineage docstring's pointer is one hop short.

Proposed vault banner text

From the commit messages. The Lander composes the final banners.

  • #3010: an addendum for D3b. Text is in commit 42dd6eb's and d8d955a's messages.
  • #3170: an addendum. Same commits.
  • #3120: CLOSED by this PR. Text is in commit a67d105's message (D4 plus D4b). Follow-ups (a) cross-shard double mail, (b) failure-log timing and (c) the uncapped queue are declined, with residuals in SECURITY.md.
  • #3028: CLOSED by this PR. Text is in commit 323c42f's message. Closing item 5, the ASVS cell 7.2.4 re-read, is vault work after merge.

…, not read as no entry (BACKLOG #3010)

_search now refuses every result code but success through a named helper,
_refuse_unanswered, which raises LdapUnansweredError (an LdapError). The
reconciler reads it as UNAVAILABLE, a sign-in as auth.login_error. This
covers the user search, the nested group search and the bind-account read.
Row item 4 is deferred to vault BACKLOG #3100 item 3.

Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010)

Proposed vault banner: BUILT on branch b257-3010. _search in auth/ldap.py
refuses any result code but success, with or without entries, through
_refuse_unanswered; the reconciler reads it as unavailable and sign-in as a
login error. Closing items 1-3 built and tested; item 4 deferred to #3100
item 3.
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.
…le error, named costs (BACKLOG #3010)

Review round one. _refuse_unanswered now refuses a referral first itself, so
no caller can reorder the two. LdapUnansweredError survives copy and
pickle. The docstring says the #3100 item 3 deferral holds only once that
item covers the reconciler's except LdapError arm. The changelog names the
costs: a standing code stops revocation unalerted, and an unpaged nested
group search over a directory's cap would refuse sign-in. New tests: a busy
account beside a departed one in a mixed pass, copy and pickle.

Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010)

Proposed vault banner: BUILT on branch b257-3010. _search in auth/ldap.py
refuses any result code but success, with or without entries, through
_refuse_unanswered; the reconciler reads it as unavailable and sign-in as a
login error. Closing items 1-3 built and tested; item 4 deferred to #3100
item 3, which must widen to the except LdapError arm. Shipped open: bind
result codes (#3169), unpaged group search, stale reconcile.py docstring.
#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.
…refused, not a KeyError (BACKLOG #3010)

Follow-up limb of #3010, findings 3, 6, 8 and the ldap side of 10 from the
first Builder's review.

- Finding 3: the nested group search goes through new _search_paged, the
  simple paged results control at 500 per page. Each page passes _search,
  so a page that does not end with success refuses the whole search and the
  pages already read are dropped. A search still paging after 20 pages is
  refused too (each page may take up to ad_receive_timeout).
- Finding 6, verified in the installed ldap3 2.9.1: the default fast decoder
  does RESULT_CODES[code] (utils/asn1.py ldap_result_to_dict_fast,
  operation/bind.py), so an unknown code raises KeyError(code) from inside
  conn.search or a bind. _unknown_result_code recognises only that shape;
  _search and _socket_faults_as_ldap_error map it to LdapUnansweredError.
  A real-ldap3 loopback test measures the premise.
- Finding 8: the test module docstring is in the conditional.
- Finding 10 (ldap side): the group-search-busy reconciler case is its own
  test, reading roles through a mapped group; the stored roles survive.
…(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.
…s share the unknown-code mapping (BACKLOG #3010)

Review round one on the follow-up limb, applied:
- A later page with no readable paged results control is refused, not
  read as the last page; only an empty cookie ends the paging. The first
  page may still lack it (the control is sent non-critical).
- The unknown-code mapping is one context manager, _unknown_code_refused,
  used by _search, by _socket_faults_as_ldap_error (binds) and around the
  Who am I extended operation, which ldap3 decodes the same way. A failed
  Who am I is reported in whoami_error and the group read still runs.
- The paging refusals carry their own code_name (too many pages, paging
  control lost); _MAX_PAGES is described as a count of pages.
- The stale "ldap3 raises on none of these codes" docstring is corrected.

Proposed PR title: An LDAP search that does not end with success is an
outage, not "no such account" (BACKLOG #3010)

Proposed vault banner, addition to the one in commit c6113d9: Follow-up
limb (branch b257-3010b): the nested group search is paged at 500 per page,
so an account in more groups than a directory's unpaged cap would no
longer fail sign-in; a page that does not end with success, a later page
that drops the paging control, or a search still paging after 20 pages
refuses the whole search as unanswered, never a shorter group list. A
result code ldap3 has no name for, which its default fast decoder raises
KeyError on inside a search, a bind or Who am I, now reads as the directory
not answering instead of an unhandled error. Measured through real ldap3
against a loopback server, not a domain controller. Shipped open: no
overall deadline across pages, no RFC 2696 abandon of a refused paged
search, the socket an unknown-code auto_bind leaves open, no static pin
that every ldap3 call reads through the mapping.
… 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).
…ged searches are abandoned (BACKLOG #3010)

_ldap3_call replaces _unknown_code_refused. Every ldap3 call that reads a
response runs inside it: _search (each page, and the abandon), the service
account's auto_bind, the user and decoy binds, and Who am I. A KeyError,
TypeError, IndexError, ValueError or AttributeError that ldap3 raised while
reading a response (innermost frame in ldap3, an outer frame in a strategy's
get_response) becomes LdapUndecodableError, an outage, labelled with the
operation and the fault type and no result code. Anything else propagates.

Fixes the two defects measured on b257-3010b: a bind response carrying a
referral (KeyError(3)) escaped bare, and a malformed paged results control
(TypeError, IndexError) escaped _resolve_groups. A stray tag [22] is no
longer called "result code 22". The service bind's socket is closed when its
response cannot be decoded. A refused paged search is abandoned (RFC 2696).

Coverage removed: the double-based tests that mapped an engine-raised
KeyError(4242) are replaced, since that mapping was the defect; the real-ldap3
unknown-code tests moved to tests/test_ldap_decode_boundary.py.
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.
…bind never masks (BACKLOG #3010)

The boundary also recognises ldap3's extended-operation decode frames, so a
Who am I value that is not text (UnicodeError in populate_result) is an
outage and the group read still runs. A search that meets an undecodable
response closes its socket, since the stream may be out of step and the
service connection is never unbound. _released keeps an unbind error from
replacing a bind's refusal, so the decoy and real binds still fail alike. A
failed RFC 2696 abandon is logged at debug level by type. The changelog
names what is not covered (ldap3's ranged memberOf follow-ups) and the
reconciler log-level cost, in shorter sentences.
…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.
…r (BACKLOG #3010)

No code change. Review round two ran on 2cae56b and is the last round; its
findings ship open, listed below and in the Builder's report.

Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010)

Proposed vault banner (consolidated, replaces the texts in c6113d9 and
6512a27): BUILT on branches b257-3010, b257-3010b and b257-3010c (stacked).
- _search in auth/ldap.py refuses any result code but success, with or
  without entries, through _refuse_unanswered; the reconciler reads it as
  unavailable and sign-in as a login error. Closing items 1-3 built and
  tested; item 4 deferred to #3100 item 3, which must widen to the except
  LdapError arm before the deferral holds.
- The nested group search is paged at 500 per page. A page that does not
  end with success, a later page that drops the paging control, or a search
  still paging after 20 pages refuses the whole search, never a shorter
  group list. A refused paged search is abandoned (RFC 2696) once the
  directory has sent a cookie.
- One boundary, _ldap3_call, wraps every ldap3 call that reads a response:
  each search and page, the service auto_bind, the user and decoy binds,
  and Who am I. A KeyError, TypeError, IndexError, ValueError or
  AttributeError that ldap3 raised while reading the response, decided by
  traceback origin and not by value, becomes LdapUndecodableError, an
  outage, with no result code claimed. Engine-raised exceptions propagate.
  This covers a bind response carrying a referral (KeyError(3)), a code
  outside ldap3's table, a malformed paged control, and a Who am I value
  that is not text. The unknown-code value mapping is gone. A static test
  pins every search, bind and Who am I call inside the boundary.
- Measured through real ldap3 2.9.1 against a loopback server, not a DC.
Shipped open: the user bind still reads busy and other non-success codes as
a wrong password (#3169); ldap3's ranged memberOf follow-up searches are not
refused (a row); the close of an undecodable search's socket is undone by
ldap3's Connection.__exit__, which reopens it (measured); a referral in a
bind response reads as undecodable, not REFERRED; standing configuration
codes and decode faults reach the reconciler at debug level only; no
overall deadline across pages; the abandon is sent after a busy page and
can reuse a spent cookie; no cap on the paged cookie's length.
… it (BACKLOG #3010)

_search no longer passes close=True to _ldap3_call. Callers hold the
service connection in a with block, and ldap3 2.9.1's
Connection.__exit__ reopens a connection that was open on entry and is
closed on exit, so the close cost one more TCP and TLS connect per
malformed search and closed nothing. The service-account auto_bind close
stays. The paged-control test now pins that a search refusal leaves the
caller's connection open; the docstrings and the changelog claim the
close for the service-account bind only.

Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010)

Proposed vault banner (consolidated, replaces the texts in c6113d9,
6512a27 and ebd88b2): BUILT on branches b257-3010, b257-3010b,
b257-3010c and b257-3010d (stacked).
- _search in auth/ldap.py refuses any result code but success, with or
  without entries, through _refuse_unanswered; the reconciler reads it as
  unavailable and sign-in as a login error. Closing items 1-3 built and
  tested; item 4 deferred to #3100 item 3, which must widen to the except
  LdapError arm before the deferral holds.
- The nested group search is paged at 500 per page. A page that does not
  end with success, a later page that drops the paging control, or a search
  still paging after 20 pages refuses the whole search, never a shorter
  group list. A refused paged search is abandoned (RFC 2696) once the
  directory has sent a cookie.
- One boundary, _ldap3_call, wraps every ldap3 call that reads a response:
  each search and page, the service auto_bind, the user and decoy binds,
  and Who am I. A KeyError, TypeError, IndexError, ValueError or
  AttributeError that ldap3 raised while reading the response, decided by
  traceback origin and not by value, becomes LdapUndecodableError, an
  outage, with no result code claimed. Engine-raised exceptions propagate.
  This covers a bind response carrying a referral (KeyError(3)), a code
  outside ldap3's table, a malformed paged control, and a Who am I value
  that is not text. The unknown-code value mapping is gone. A static test
  pins every search, bind and Who am I call inside the boundary. After an
  undecodable response the socket is closed for the service-account bind
  only; a search's socket is left to its caller, because ldap3's
  Connection.__exit__ would reopen it.
- Measured through real ldap3 2.9.1 against a loopback server, not a DC.
Shipped open: the user bind still reads busy and other non-success codes as
a wrong password (#3169); ldap3's ranged memberOf follow-up searches are not
refused (a row); after an undecodable search response the connection stays
open on a stream that may be out of step, and authenticate and
probe_principal never unbind the service connection (a row); a
referral in a bind response reads as undecodable, not REFERRED; standing
configuration codes and decode faults reach the reconciler at debug level
only; no overall deadline across pages; the abandon is sent after a busy
page and can reuse a spent cookie; no cap on the paged cookie's length.
… and what not closing costs (BACKLOG #3010)

The previous commit said a close on the search path closed nothing. It
did shut the failed socket; ldap3's Connection.__exit__ then opened a
fresh, unbound one, which is the extra connect. _ldap3_call now says so,
names authenticate and probe_principal as the callers whose with block
reopens it, and states the cost of not closing: their connection stays
open and bound on a stream that may be out of step until it is
collected, while read_bind_account unbinds its own. _search_paged says
the directory keeps the paging state after a decode fault. The test
comment no longer cites a with block the test helper does not use.

Proposed PR title: An LDAP search that does not end with success is an outage, not "no such account" (BACKLOG #3010)

Proposed vault banner (consolidated, replaces the texts in c6113d9,
6512a27, ebd88b2 and cf6b2b7): BUILT on branches b257-3010,
b257-3010b, b257-3010c and b257-3010d (stacked).
- _search in auth/ldap.py refuses any result code but success, with or
  without entries, through _refuse_unanswered; the reconciler reads it as
  unavailable and sign-in as a login error. Closing items 1-3 built and
  tested; item 4 deferred to #3100 item 3, which must widen to the except
  LdapError arm before the deferral holds.
- The nested group search is paged at 500 per page. A page that does not
  end with success, a later page that drops the paging control, or a search
  still paging after 20 pages refuses the whole search, never a shorter
  group list. A refused paged search is abandoned (RFC 2696) once the
  directory has sent a cookie.
- One boundary, _ldap3_call, wraps every ldap3 call that reads a response:
  each search and page, the service auto_bind, the user and decoy binds,
  and Who am I. A KeyError, TypeError, IndexError, ValueError or
  AttributeError that ldap3 raised while reading the response, decided by
  traceback origin and not by value, becomes LdapUndecodableError, an
  outage, with no result code claimed. Engine-raised exceptions propagate.
  This covers a bind response carrying a referral (KeyError(3)), a code
  outside ldap3's table, a malformed paged control, and a Who am I value
  that is not text. The unknown-code value mapping is gone. A static test
  pins every search, bind and Who am I call inside the boundary. After an
  undecodable response the socket is closed for the service-account bind
  only. A search's is not: authenticate and probe_principal hold the
  connection in ldap3's with block, whose __exit__ reopens a closed
  connection, so a close there cost one more connect.
- Measured through real ldap3 2.9.1 against a loopback server, not a DC.
Shipped open: the user bind still reads busy and other non-success codes as
a wrong password (#3169); ldap3's ranged memberOf follow-up searches are not
refused (a row); after an undecodable search response in authenticate or
probe_principal the service connection stays open and bound, on a stream
that may be out of step and holding any paging state, until it is
collected, because neither method ever unbinds it (a row; the fix is the
release read_bind_account uses, not a close flag); read_bind_account's
unbind is then sent on that stream; a referral in a bind response reads as
undecodable, not REFERRED; standing configuration codes and decode faults
reach the reconciler at debug level only; no overall deadline across
pages; the abandon is sent after a busy page and can reuse a spent cookie;
no cap on the paged cookie's length.
…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
Brings the third main merge on b257-dchain into the tail.

Conflict: messagefoundry/api/outlive.py, the two comments over
DRAIN_TIMEOUT_SECONDS and CANCEL_GRACE_SECONDS. This branch (vault
BACKLOG #3120) pointed them at a sum of every shutdown drain stated at
_BACKGROUND_DRAIN_SECONDS in auth/service.py. Main (#3191) now runs
every lifespan teardown step under one budget, _SHUTDOWN_BUDGET_SECONDS
in api/app.py, and passes these constants as upper limits. Took main's
text, which keeps #3120's intent: the window arithmetic is stated once.

Semantic clash, resolved in the same commit: the auto-merged comment
over _BACKGROUND_DRAIN_SECONDS (auth/service.py, #3120) listed the
drains' fixed bounds as summing to 17 s and said nothing made the
teardown fit the window, which #3191 made false. It now points at
_SHUTDOWN_BUDGET_SECONDS and names close_background as the caller. The
auto-merged DRAIN_TIMEOUT_SECONDS comment in api/approvals.py pointed
at the same stale sum; it now points at the budget too.

Also read: auth/service.py (step-up arms all carry the #3220 marker),
api/app.py, __main__.py, store/base.py, store/postgres.py,
store/sqlserver.py, docs/PHI.md, docs/SECURITY.md,
docs/CONFIGURATION.md. No other clash 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.
Brings b257-dchain's merge of main e9e52ae and 3f1f884, and its
step-up floor-marker binding, into the tail. No conflicts. The web
console seam digest re-derived on this tree is unchanged
(851fb3e4d3d5b825), so the tail's own changes move no seam surface.
Review of the merge of b257-dchain into b257-dtail found three
statements made false or left false by main's single shutdown budget:

- api/approvals.py called DRAIN_TIMEOUT_SECONDS an upper limit on what
  the lifespans pass. They pass a share of the step's bound instead,
  which can exceed it; it is only the method's default.
- That comment and the one over _BACKGROUND_DRAIN_SECONDS said
  _SHUTDOWN_BUDGET_SECONDS states the steps and their order and bounds
  every step. It states the budget arithmetic; _app_drain_steps and
  the managed teardown hold the steps, and some steps are unbounded.
- docs/SECURITY.md said pending lock notices are cut off at the
  shutdown bound, each with one line. Where the budget cuts the drain
  first, they are not cancelled and run on.

Comments and docs only. BACKLOG #3191, vault BACKLOG #3120.
Review round two of the batch 257 main merges:

- The #2216 residual in docs/SECURITY.md said the engine waits about
  two seconds and a pending notice is cut off. It now says the drain
  waits at most two seconds and cancels, that the shutdown budget
  (BACKLOG #3191) can cut it first, and what a notice left running
  then costs: the closed security notifier still reports it queued, so
  the row says mailed: true for a mail never sent. The no-cap bullet
  points at that paragraph instead of restating it.
- The _BACKGROUND_DRAIN_SECONDS comment named the wrong threshold (the
  drain plus its grace); a bound below the drain alone cuts it before
  it cancels. It now points at the SECURITY.md residual for the cost,
  and says the steps hold floors, from which _run_teardown computes
  each bound.
- DRAIN_TIMEOUT_SECONDS in api/approvals.py is described as the
  default for a caller that passes no timeout, which the lifespans do
  not use.

Comments and docs only. Vault BACKLOG #3120.
…port it queued (BACKLOG #3120)

At shutdown the teardown can cut the lock-notice drain before it cancels a
pending notice. That notice ran on, reached the security notifier after
aclose(), and was put on a queue nothing drains, so notify() answered True
and auth.lock_notice recorded mailed: true for a mail never sent, holding
the account's next notice of the kind back for 24 hours.

SecurityEventNotifier now sets a closing flag at the start of aclose(),
before the stop sentinel goes in, and notify() answers False from then on.
The lock notice's row then reads mailed: false. No undelivered audit row is
started for such a notice: one started then could outlive aclose() and meet
the closed store. The teardown step in api/app.py is unchanged; only its
comment is corrected. docs/SECURITY.md: the #2216 residual says what holds
now, and the stale "drains its queue after the store has closed" bullet
is replaced (stale since #3191).
…ine, restartable (BACKLOG #3120)

- A notice refused while the notifier closes is recorded on its undelivered
  row with reason "closing", as a full queue's drop is, so the full-queue-at-
  close window keeps the BACKLOG #2383 record it used to get.
- The refusal warning names the kind through notice_kind_log_label and the
  username through scrub_log_argument, and says "is closing".
- start() after a close clears the flag, so a restarted notifier sends again.
- Lists of why a lock notice reads mailed: false now include the closing
  notifier, or point at the SecurityNotifier contract (notifications.py,
  service.py _notify_security, docs/SECURITY.md).
- SECURITY.md: a cut close can also lose or unrecord a send under way, and
  a mailed: true row then holds the next lock notice back for 24 hours.
- Tests close the relay in finally; the end-to-end test runs a started relay
  with its drain loop, sends the control, and reads the closing record.
…CKLOG #3120)

No code change. This commit carries the handover text for the draft PR 2387.

PR title: unchanged from the b257-dtail head; this limb adds to #3120.

Ledger banner addendum for vault BACKLOG #3120:
A security notifier that has begun to close, or has closed, now refuses a
notice (notify answers False) instead of queueing it where nothing drains
it. A lock notice that a cut shutdown drain leaves running therefore
records auth.lock_notice mailed: false, not mailed: true, and the relay
records the drop on its undelivered row with reason "closing". The
teardown in api/app.py is unchanged. docs/SECURITY.md states what holds
after the fix and drops the stale "drains its queue after the store has
closed" bullet (stale since #3191). Review round two left findings open;
they are in the Builder's report.
…says what happens (BACKLOG #3120)

Review round two on the closing refusal found two defects the previous
limb introduced.

1. After a cut close, start() cleared _closing, but the stop sentinel
   stayed on the queue, so the restarted drain loop stopped on it and
   notify() answered True for events nothing sends. start() now raises
   RuntimeError once aclose() has begun, and the notifier keeps
   refusing. No engine path restarts a closed notifier: each lifespan
   builds its own. The test that a fresh start sends again is replaced
   by one that a closed notifier refuses to start, and a new test cuts
   a close mid-send, calls start(), and requires that a notice reported
   queued is sent. It failed on f7ba6d6.

2. The throttle read's fail-open line said the notice "was sent
   unthrottled". It now says the notice is offered to the notifier
   unthrottled and the notifier's answer decides whether it is recorded
   as mailed. docs/SECURITY.md no longer says a failed row write after a
   refused notice "costs at most a duplicate mail": no mail goes, and no
   row holds back the next notice.

PR title: unchanged from the b257-dtail head; this limb adds to #3120.
Ledger banner addendum: a closed security notifier now refuses to start
again, so a cut close cannot leave a drain loop that reports notices
queued and never sends them.
…KLOG #3120)

The code-review pass at xhigh found the new wording claimed more than
holds. A cut close CAN leave its sentinel (not always: a cut during a
blocked put leaves none), so the start() comment says "can". The
throttle fail-open line no longer claims a hand-off or a recorded
outcome, either of which a detach or the same store fault can prevent;
it says the throttle did not hold the notice back and the notifier
decides. docs/SECURITY.md states the outcome only, without an ordering
of the read and the write. The class docstring states the single-use
contract. The refusal test's control now asserts the task it starts.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Shutdown follow-up, b257-mailfix pushed to b257-dtail @ f7ba6d6 (#3120: notify answers False once the notifier is closing; a lock notice raised after close records mailed: false, reason closing; 3 new tests fail on the previous notifier):

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: round 1 had 11, 8 applied (one partly), 3 left open (rejected or out of scope, reasons below); round 2 had 12, 0 applied, 12 shipped open.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Its two self-introduced round-two defects, b257-mailfix2 pushed to b257-dtail @ b0d1704 (a closed notifier refuses to start again, so no stale stop marker can make notify answer True for an unsent notice; the throttle fail-open log line and the SECURITY.md cost sentence now state what happens; 2 new tests fail on f7ba6d6):

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 1. Findings: 9; 6 applied (own wording and test fixes), 3 shipped open (base _BackgroundDispatcher and NotifierAlertSink share the close hole; notify keys on _closing rather than drain-task liveness; a full queue at shutdown blocks base aclose outside its try/finally) plus the stale _lock_notice_due docstring -- all rows.

Brings main b4fab85 into b257-dchain. Main side carries BACKLOG #2858,
vault #2702, vault #3099, #3029, #3181, #3217, #3178, #3180, vault #2223.

Conflicts:
- messagefoundry/auth/service.py, revoke_own_session docstring: kept both
  paragraphs, the vault #3260 missed-revoke audit and main's #3029 note that
  a revoke matching no unrevoked row returns False with no row. Code merged
  cleanly: the miss branch audits, the supersede_session miss returns False.
- Web console seam digest (_ui_seam.py, webconsole __init__, golden
  snapshot): both sides moved it; re-derived on the merged tree with
  scripts/webconsole_seam_snapshot.py --write (41c0a8c59f83685a) and set
  SUPPORTED_ENGINE_SEAMS to match.

Semantic clash fixed: main's tests/test_session_supersession.py called
revoke_own_session without client, which this branch made required
(vault #3260); both calls now pass client=None.
Brings the fourth main merge on b257-dchain (515d1c3) into b257-dtail.
Main side carries BACKLOG #2858, vault #2702, vault #3099, #3029, #3181,
#3217, #3178, #3180, vault #2223; this branch carries vault #3028
(ADR 0210).

Conflicts:
- tests/test_session_supersession.py, _touched_first: kept main's #3029
  injection counter (touches.append) and this branch's lineage_since
  pass-through to the real supersede_session.
- docs/adr/README.md: kept both index rows, 0209 (main) before 0210 (this
  branch).

Read for semantic clashes: main's logout and revoke_own_session now call
supersede_session without lineage_since, so they do not follow the
retired-hash lineage; ADR 0210 limits the walk to the login supersession,
so the two agree. Seam digest unchanged by this merge (check passes).
Review of the fourth main merge. Main's #3029 added a second 404 path to
revoke_own_session: the revoke matched no unrevoked row, because a rotation
re-keyed the session or a concurrent revoke ended it first. That path
returned False with no audit row, while this branch's vault #3260 limb 2
(owner ruling 2026-10-16) has every miss of DELETE /me/sessions/{id} write
one auth.session_revoke_missed row, as docs/SECURITY.md states. The race
miss now writes that same row, and still no auth.session_revoked row.

- service.py: the supersede_session miss joins the unknown and foreign id
  branch; docstring says so.
- tests/test_session_supersession.py: the race test also pins the miss row.
- changelog.d/3029.security.md: no longer says the 404 writes no row.
- _ui_seam.py: a paragraph names the #3029 logout return type in the digest.

BACKLOG #3029, vault BACKLOG #3260.
Main moved during the fourth merge's checks; brings in BACKLOG #3302,
#2613 item 3 and #2812.

Conflict: docs/PHI.md, the diagnostics-stream table row. Kept main's
tls_handshake_failed cell (#3302, MLLP and HTTP listeners, any loop) and
this branch's operator-alerts cell (vault #3141, #3170 reconcile text).
Brings b257-dchain 1248224 into b257-dtail: main 0ed70dc (BACKLOG
#3302, #2613 item 3, #2812) and the review fix that has the #3029 race
miss write the vault #3260 miss row.

Conflict: docs/PHI.md, the diagnostics-stream table row. Kept main's
tls_handshake_failed cell (#3302) and this branch's operator-alerts cell,
which carries vault #3010's configuration refusal on top of #3141/#3170.

Semantic clash fixed (review finding): main's _slow stand-in in
test_a_session_live_when_the_revoke_began_is_audited_though_it_lapsed_during_it
did not take lineage_since, which this branch's login supersession passes
(vault #3028), so it raised TypeError. It now takes and forwards it.
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Oct 17, 2026
Brings in main through cc768e4 (past 13082ca; it includes PR 2352).
Conflicts, each resolved by keeping both sides:

- auth/service.py imports: main's is_idp_transport_http_exception and
  store_open_errors beside the branch's build_idp_opener_from_settings and
  store_call_errors (store_driver_errors and build_idp_opener are no longer
  named in this module).
- auth/service.py complete_oidc_step_up: main reads the floor's exempt values
  once and passes them to _oidc_callback_floor_exemption, and clears the IdP
  flag after a verified token; the branch's bound `refuse` partial (address
  fields on every post-redeem refusal) is kept on both refusal arms.
- auth/oidc_http.py __all__: main's IdpReply* errors and
  is_idp_transport_http_exception plus the branch's
  build_idp_opener_from_settings and its TYPE_CHECKING import.
- api/_ui_seam.py: both history paragraphs kept, plus one for this merge.
  ENGINE_UI_SEAM re-derived with the seam tool's --write on the merged tree
  (909ed2c80270abb3, covering main's route-gate section and release_blocked
  DTO and the branch's revoke_own_session signature); SUPPORTED_ENGINE_SEAMS
  set to match; golden rewritten by the tool.
- packaging/messagefoundry-webconsole/tests/test_webui.py: deleted on main,
  which split it into nine files. The branch's one change to it (assert that
  enable_totp returns True in
  test_reauth_both_factors_enrolled_renders_code_and_passkey) is carried into
  test_webui_search_sso.py, the test's new home; the old file stays deleted.

Backlog: vault #3260, #3029, #3145, #3221, #2709, #2509, #3192, #3193, #3236.
Brings in b257-dchain at 43abc1a, which merged main through cc768e4.
Conflicts, each resolved by keeping both sides:

- api/approvals.py, the DRAIN_TIMEOUT_SECONDS comment: this branch's text
  (a default for a caller passing no timeout; the lifespans pass a share of
  the shutdown budget stated at _SHUTDOWN_BUDGET_SECONDS) with main's scope
  (the gate's writes still running, at least the outcome and request writes)
  and its per-write cost (plus at most a read-back). Main's sentence about
  sharing the window with the upload runner's 5 s stop is not kept: on this
  branch that stop is a budgeted teardown step, which the
  _SHUTDOWN_BUDGET_SECONDS pointer covers.
- store/sqlserver.py _write_session_txn and rotate_session: this branch's
  `landed` callback (the lineage link in the re-key's transaction) and main's
  `after_commit` borrow both kept; rotate_session passes both.

Backlog: vault #3028, #3191, #3193, #3023.
…he merge

Review round one of the dchain merge into b257-dtail found the resolution
left one semantic conflict: main's SQL Server busy-pool test for the
post-commit borrow asserted that rotate_session ends on its UPDATE, and this
branch's rotation now ends on the lineage INSERT in the same transaction.
The test now asserts what its Postgres twin asserts: an UPDATE of sessions,
then the lineage INSERT last for rotate_session. Without this,
test_sqlserver_post_commit_session_writes_wait_out_a_busy_pool[rotate_session-True]
fails on the merged tree and on neither parent.

The DRAIN_TIMEOUT_SECONDS comment the merge wrote is reordered so the
"no timeout" clause cannot read as narrowing which writes are drained, and
no longer restates the graceful-stop window that _SHUTDOWN_BUDGET_SECONDS
states (SDS-3.5).

Backlog: vault #3028, #3023, #3193, #3191.
… b257-dtail

Main landed engine PR 2347 as squash 5c33135 (BACKLOG #3010, its send-back
head b4bb5b1) and 838a562 (BACKLOG #3018, #3085). This tail already carried
#3010 at b666975, so each conflicted file was resolved as the tail's version
plus main's send-back delta (24aadec..b4bb5b1) plus 9cec001 and 838a562.

- auth/ldap.py: _is_bind_referral, _referred_bind and _REFERRAL_TAG/_CONTEXT_CLASS
  are byte-identical on both sides (main ported D3b's, with the class check), so
  nothing was chosen. Main's _referral_error, LdapUndecodableError docstring and
  undecodable text are taken; the tail's configuration codes and connection
  release are kept. One duplicated _ldap3_call docstring paragraph removed.
- auth/service.py: both: main's LdapUndecodableError arm reading FAULT through
  _fault_probe (vault #3696 item 2), ahead of the tail's LdapError arm that reads
  a configuration code as MISCONFIGURED. Main's #3018 _audit_reconciler_row
  signature and docstring, naming the configuration refusal's row too. The
  LdapError arm comment no longer claims an undecodable response reaches it.
- auth/reconcile.py, api/app.py, docs/SECURITY.md, changelog.d/3010: both
  sides' text; the referral alert names ad_bind_dn, as the tail's code does.
- tests/test_ldap_decode_boundary.py: union; main's private-tag-3 case, renamed
  tests and reconciler FAULT tests beside the tail's session-release test.
  tests/test_ldap_search_result_codes.py: the tail's (main's equals the pre-
  send-back version). None dropped.
- tests/test_directory_last_admin_alert.py: the tail's spy on
  _apply_reconcile_revocation takes #3085's watermark and returns its outcome.
…undecodable bind an outage

The union kept a tail docstring saying an undecodable response is an outage that pages nobody; since main's FAULT arm (vault #3696 item 2) it pages as a probe fault. Its referral tests now reuse main's _bad_bind_dc and _probe_through_the_reconciler rather than a second copy of the same setup. For BACKLOG #3010.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Merge of main with #3010 landed, head 3faa362 (merge a4f88a4 of tail 9e65ba5 and main d2a3454, plus review fix 3faa362). merge-tree into main d2a3454: rc 0.

Eight conflicted files (auth/ldap.py, auth/service.py, auth/reconcile.py, api/app.py, docs/SECURITY.md, changelog.d/3010.security.md, tests/test_ldap_decode_boundary.py, tests/test_ldap_search_result_codes.py), resolved as the tail plus main's send-back delta (24aadec..b4bb5b1), 9cec001 and 838a562. _probe_principal arm order: a referral reads REFERRED; an undecodable response reads FAULT via _fault_probe (main, vault #3696 item 2); then LdapError, where a configuration code reads MISCONFIGURED and anything else UNAVAILABLE. _is_bind_referral was byte-identical on both sides. No test dropped; the decode-boundary tests are a union. Semantic clash fixed: tests/test_directory_last_admin_alert.py spied on _apply_reconcile_revocation with the pre-#3085 signature. Checks: ruff and format clean; mypy strict four legs clean (360/341/1369/6); ledger_check rc 0; seam check passed (61); full suite -n 8: 48837 passed, 1195 skipped, 151 xfailed, 1 failed (tests/test_sandbox.py::test_the_relay_thread_ends_when_the_worker_tree_is_reaped, passes alone, neither side touches the sandbox). This PR's diff still includes the D1-D3 chain until PR 2375 lands.

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: round 1 had 7, 2 applied (stale outage docstring in a referral test, duplicate test setup), 5 shipped open; round 2 had 10, 0 applied, 10 shipped open.

Open findings, each named (12 distinct after merging repeats across the two rounds):

  1. service.py _probe_principal: a service-account bind refused for its configuration (49, disabled account, bad DN) still reads UNAVAILABLE, a silent outage.
  2. ldap.py _released(user_conn) is not quiet while the decoy swallows its unbind error, so a failing unbind can tell a present account from an absent one and can fail a verified sign-in.
  3. docs/SECURITY.md referral paragraph: "a bind with the referral code and no referral field is not covered" implies other bind refusals are covered; they are not.
  4. service.py FAULT on an undecodable service bind logs one WARNING per account per pass (up to 200), and the latched alert points at accounts rather than the bind; the merged changelog no longer names the log volume.
  5. reconcile.py FAULT docstring ":attr:UNAVAILABLE takes both" contradicts the merged UNAVAILABLE docstring (merge-induced).
  6. ldap.py _refuse_unanswered docstring: "a pass where nothing else answered aborts as directory_misconfigured" leaves out "none referred".
  7. reconcile.py ~47 and the SECURITY.md probe-fault paragraph: "every probe fails and none faults is a plain outage" should say "unreachable" now that configuration refusals exist.
  8. tests/test_ldap_search_result_codes.py module docstring (~11) and comment (~183) still say every caller reads LdapError as an outage (merge-induced).
  9. tests/test_ldap_decode_boundary.py: one bad-bind test still builds _scripted_dc(..., bind_reply=...) inline, and _group_page_undecodable duplicates main's _bad_group_page_dc.
  10. service.py altitude: configuration refusals are found by an isinstance-plus-property test inside the generic LdapError arm, so a configuration code arriving other than through _search (for example auto_bind) falls through to UNAVAILABLE.
  11. SECURITY.md "Step-up refuses the account with outcome probe_fault": the password and federated step-ups read LdapUndecodableError as directory_unavailable (text as on main).
  12. main's #3085 _audit_watermark() runs once per enabled AD user before list_sessions, including users with no session, so the cost scales with all users.

…line landed) into b257-dtail

Resolved through the auth chain's own pre-squash head 573fbd3, whose tree equals main's: the
merge took that head's later merges and review fixes (BACKLOG #3164 #3121 #3113 #3260 #3145 #2950
#3141 #3170) wherever this branch carried an earlier version, and kept the tail's own changes
(#3010/#3170 reconciler follow-ups, #3120, #3028).

Paging latch overlap (vault BACKLOG #3696, PR 2431): kept both mechanisms, because they cover
different conditions. The paging latch is per account, for a group search the engine refused for
its own bound (deadline, entry cap, page backstop), read UNAVAILABLE. The configuration refusal
(#3010) is per pass, for a result code the directory returned (32, 34, 50), read MISCONFIGURED.
A probe takes one arm or the other, never both. Docs and docstrings now name both, and the
paging refusal's count (the breaker's, plan.probed) apart from the asked count of the referral,
the configuration refusal and the fault.

One release mechanism kept for the service connection: main's _service_session (#3017) replaces
the tail's _released(svc, quiet=True); quiet and its unit test are gone, the end-to-end test stays.
A duplicate no-op unbind in a test double (test_ad_directory_identity.py) is removed so main's
counting one stands. The scripted DC in test_ldap_search_result_codes.py counts unbinds in main's
_serve.
…ging and clear texts missed it

Round one of the code review on the merge commit. Text only, no behaviour change:
- CONFIGURATION.md and CONNECTIONS.md: a pass with a configuration refusal resolves no reconcile
  alert and does not re-arm the paging latch, as the code already does (BACKLOG #3010, vault
  BACKLOG #3696).
- SECURITY.md: the configuration refusal marks no alert clear, and a service-account bind refused
  with one of those codes takes the same path.
- service.py: the unjudged-others count names the paging refusal beside an unreachable directory;
  _track_reconcile_episodes lists misconfigured among the probes that end nothing.
- changelog.d/3010.security.md: drops the service-connection unbind bullet. Main's
  _service_session (BACKLOG #3017) delivers it now, not this branch.
- test_ldap_decode_boundary.py: the end-to-end unbind test says it exercises _service_session.

Proposed PR title (unchanged): the draft PR 2387 title.
Ledger banner text: unchanged from the D3b, D4, D4b and D5 handover commits.
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Merge of main after PRs 2347 and 2375 landed, head fd7884f (merge 5ffe45d of 3faa362 and main 6d6986f, plus text-only review fixes fd7884f; fast-forward, no force). Method: 2375's pre-squash head 573fbd3 has exactly main's tree and shares history with this branch, so git merge-tree origin/b257-dtail 573fbd37 was loaded into the merge (parents unchanged), applying 2375's later merges and fixes wherever this branch carried an earlier copy; 25 hunks in 11 files were resolved by hand (reconcile.py, app.py, alerts.py, service.py, ldap.py, test_directory_last_admin_alert.py, test_ldap_timeouts.py, test_ldap_search_result_codes.py, CONFIGURATION.md, PHI.md, SECURITY.md). #2431 overlap: both kept, different conditions. The duplicate service-connection release was removed in favour of main's #3017 _service_session. Semantic clash fixed: a no-op _RecordingConn.unbind in tests/test_ad_directory_identity.py shadowed main's counting one (ruff F811). Seam digest 5d9c162790077dfc matches the golden. Checks: ruff and format clean; mypy strict four legs clean (362/343/1389/6); ledger_check rc 0; full suite -n 8 no PYTHONPATH: 50082 passed, 1232 skipped, 151 xfailed, 0 failed, on both commits. merge-tree into main 6d6986f: rc 0 (re-read by the Manager).

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: none returned.
Rounds: 2. Findings: round 1 had 8, 7 fixed in fd7884fcf (all text), 1 shipped open (service bind refused with 32/34/50 reads MISCONFIGURED, documented, not changed); round 2 had 11, 0 applied, 11 shipped open.

Open findings, each named (12):

  1. Merge-emergent: a service-account bind refused with 32/34/50 now reads MISCONFIGURED and pages directory_misconfigured (#3017's _refuse_unanswered meets #3010's configuration property); no test covers it, and the alert text points at the search bases. Manager: accepted (adds a page, weakens nothing); the alert text and a test are rows.
  2. SECURITY.md (~4691), reconcile.py:47 and alerts.py:618 still say an all-failed pass is a plain outage that pages nobody; A paged LDAP search has one overall deadline, and the group cap counts entries, not pages (BACKLOG #3696 items 3 and 4) #2431 pages a paging refusal on an outage-shaped pass.
  3. No test shows a 32/34/50 refusal never takes the paging arm and never re-arms the A paged LDAP search has one overall deadline, and the group cap counts entries, not pages (BACKLOG #3696 items 3 and 4) #2431 latch.
  4. A code-49 service bind (wrong or expired password) is an LdapUnansweredError that is not a configuration code, so it is still a silent outage.
  5. SECURITY.md "a malformed ad_bind_dn takes the same path" is unmeasured (no test drives a 32/34/50 service bind; that AD answers a malformed bind DN with 34 is not measured).
  6. The unbind fix has no release note anywhere: changelog 3010's bullet was removed and main's #3017 (PR 2421) shipped no fragment.
  7. plan.unavailable mixes outages with paging refusals; the non-aborted WARNING and the outage abort still say "directory unreachable"; the root fix is a separate count.
  8. _unjudged_others says it names the other unjudged kinds but never counts referrals.
  9. test_a_probe_answered_before_the_unbind_fails_keeps_its_answer duplicates main's test_a_failed_service_unbind_does_not_change_the_answer.
  10. With the quiet test dropped, nothing tests that the block's exception survives a failing unbind inside _service_session.
  11. changelog 3170's list of aborted passes that still strike an id-less row omits the directory_misconfigured abort, and its headline omits the refusal.
  12. ldap.py _paging_refused still says "the directory not answering" for the deadline and cap, while the reconciler pages for them.

@wshallwshall
wshallwshall marked this pull request as ready for review October 19, 2026 19:42
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Reading on the red web console tests (ubuntu-latest, py3.14) at head fd7884f: 1 failed, 1095 passed. The failure is packaging/messagefoundry-webconsole/tests/test_ui_dr_outlive.py::test_a_console_release_cut_off_by_the_deadline_still_completes (assert coord.active is False and dr_engine.dr_active is False). The Manager ran that file 5 times against this head with PYTHONPATH at the worktree root (so the branch code, not an installed console copy): 5 of 5 passed, about 1 s each. This PR does not touch DR, api/outlive.py or the web console. The test races a 0.6 s drain against a 0.2 s request deadline, so a loaded runner is the likely cause. Not proven unrelated on the runner; a re-run is the cheap discriminator.

This branch has not been deployed

No deployments
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