diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2797d757..78a832b2 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -226,7 +226,7 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal | 47 | **#132** | Fixed 'now' test-time override (frozen clock for reproducible transform tests) | 5 | 3 | _fill-in_ | DEMAND-GATE | Value 5 stands (a wall-clock-free transform or a tolerant diff gets regression comparison today — "parity/breadth with a clean workaround"), and the seam claim is verified: `route_message` takes `ingest_time` at dryrun.py:517 and the two internal call sites hardwire `time.time()` at :679 (`_dry_run_raw`) and :753 (`dry_run`). But "a --now flag threaded through two entry points" undercounts the surfaces, and the ones it misses are the ones the item is ABOUT. `checks.py:1058,1126` calls `dry_run(reg, raw, inbound=..., snapshot_on_send=...)` with no ingest_time — and checks.py is the `.expect` fixture comparator, i.e. the repo's actual deterministic-regression gate. `trace_dry_run` is a separate module (`dryrun_trace`, invoked from __main__.py:2926-2931). And the item's own Trigger names the Test Bench: ide/src/testBench.ts shells `dryrun` at five sites (:240, :325, :354, :440) and would need the flag plus an affordance. Engine + CLI + fixture gate + a TypeScript extension is D3 work, not D2's "small additive change on an existing seam". Quadrant stays fill-in; tier stays DEMAND-GATE. | | 48 | **#172** | Gzip/zip compression codec + file-connector option | 5 | 3 | _fill-in_ | DEMAND-GATE | File-feed parity breadth with a clean code-first workaround: the reusable codec shipped including `zip_compress`/`zip_decompress` (`messagefoundry/parsing/compression.py:40-48`), so a zip-delivering partner is served by a Handler call today. What remains is connector-level — widening `_SUPPORTED_COMPRESSION` (`messagefoundry/transports/file.py:88`), which forces an archive-member-to-message decision, plus REMOTEFILE, which has zero compression to extend. | | 49 | **#1014** | connscale smoke test's fixed 24-port block is not parallel-safe across worktrees; the flaky marker hides the collision | 5 | 3 | _fill-in_ | P3 | `test_connscale_smoke_end_to_end` hard-codes `base_port = 41000` and needs 24 CONTIGUOUS inbound ports, so two checkouts running the suite at once contend for the same block — which is the normal topology here (24 worktrees were live on 2026-08-04). It self-heals via `@pytest.mark.flaky(reruns=2)` commented "CI runners are noisy", so a determinate resource collision wears a noise label and the retry does work the port allocation should be doing. Value 5: it costs retries and misdiagnosis rather than correctness. Difficulty 3: allocate the block dynamically and assert contiguity, rather than widening the retry. | -| 50 | **#1020** | The first-run bootstrap Administrator is created with no email address, and the PHI notification gate cannot see it | 5 | 3 | _fill-in_ | P2 | `_ensure_bootstrap_admin` calls `create_user` with no `email=` (`auth/service.py:527-533`), so the account holding `frozenset(Permission)` has a NULL email and `SecurityEventNotifier.notify` returns before enqueueing (`pipeline/security_notify.py:130`), making all ten notice types no-op for it — including LOGIN_AFTER_FAILURES, the compromise signal. The part with teeth is gate blindness: the PHI startup gate refuses to serve without a notification channel but computes readiness from `notify_security_events` plus `email_smtp_host` plus `email_from` alone (`__main__.py:2260`), so it would report a healthy channel while no notice about the all-permission account could be delivered. The allocated title's second half is REFUTED and the body says so: lockout is time-bounded (15 minutes by default, `settings.py:1769`), an admin reset clears it (`store/store.py:7693`), the documented break-glass is a sealed file (`api/app.py:5104`), and no email-driven recovery flow exists anywhere in the code. Value 5 because every event is still an audit row surfaced by `GET /me/security-events`. Difficulty 3 with no schema change; the only judgment is which of three candidate fixes the owner wants. | +| 50 | **#1020** | The first-run bootstrap Administrator is created with no email address, and the PHI notification gate cannot see it | 5 | 3 | _fill-in_ | P2 | `_ensure_bootstrap_admin` calls `create_user` with no `email=` (`auth/service.py:527-533`), so the account holding `frozenset(Permission)` has a NULL email and `SecurityEventNotifier.notify` returns before enqueueing (`pipeline/security_notify.py:130`), making all ten notice types no-op for it — including LOGIN_AFTER_FAILURES, the compromise signal. The part with teeth is gate blindness: the PHI startup gate refuses to serve without a notification channel but computes readiness from `notify_security_events` plus `email_smtp_host` plus `email_from` alone (`__main__.py:2260`), so it would report a healthy channel while no notice about the all-permission account could be delivered. The allocated title's second half is REFUTED and the body says so: lockout is time-bounded (15 minutes by default, `settings.py:1769`), an admin reset clears it (`store/store.py:7693`), ~~the documented break-glass is a sealed file (`api/app.py:5104`)~~ **[STRUCK 2026-08-13 -- NOT AN UNLOCK PATH. `_emit_bootstrap_admin` writes that file only for a NEWLY CREATED account on the bootstrap path, gated on an empty users table, so it cannot recover an EXISTING locked admin. And for a SOLE administrator the 'admin reset clears it' limb fails too -- that needs a SECOND admin, and self-reset is refused. Only the 15-minute expiry remains, which is the lock lapsing rather than a recovery path. An amendment written to correct a premise introduced a false one; see #1236]**, and no email-driven recovery flow exists anywhere in the code. Value 5 because every event is still an audit row surfaced by `GET /me/security-events`. Difficulty 3 with no schema change; the only judgment is which of three candidate fixes the owner wants. | | 51 | **#1019** | install-selfheal.ps1 has no installed-vs-source payload-parity instrument, and it wires the most privileged hook in the estate | 5 | 3 | _fill-in_ | P3 | The installer copies `worktree-selfheal.ps1` to `~/.claude-hooks/` and wires it as a user-scope SessionStart hook with no divergence detection: no `-Status` (the surface is `-ConfigDir` plus `-HookPath`), no version stamp, a bare `Copy-Item -Force` (`:57`), and no test that reads the installed copy — every selfheal test binds `ROOT` under a synthetic home, and the repo's only installed-vs-source parity test names the gate copy alone (`tests/test_gate_installed_parity.py:44`). The title's "no parity instrument at all" is narrowed in the body: source-level guard parity DOES exist (`test_both_installers_carry_the_same_refusal`), as do the CLAUDECODE refusal, the backup-validate-rollback, and an unconditional payload refresh — what is absent is payload parity. The claimed comparator is also wrong, and PR #191 has since sharpened this: it landed payload parity on `install-git-hooks.ps1` (SHA256, IN SYNC/STALE, plus a pytest-side assertion), so `install-selfheal.ps1` is now the ONLY installer in the estate without one, with two worked examples to copy. Privilege is substantiated: user scope in every config dir, and a hook that runs `git checkout` on the shared primary unattended (`worktree-selfheal.ps1:106`). Measured 2026-08-04, installed and source agree (`c41c70ecf885`), so detection is absent rather than divergence present. Difficulty 3 with four constraints, chiefly folding CRLF exactly as the existing instruments do. | | 52 | **#1027** | The documented `pytest` command silently excludes the webconsole package, so a local green is not evidence about ~344 tests | 5 | 3 | _fill-in_ | P3 | `pyproject.toml` sets `testpaths = ["tests"]`, so the command CLAUDE.md:333 documents as the verification gate (`QT_QPA_PLATFORM=offscreen pytest -q`) never collects `packaging/messagefoundry-webconsole/tests` — while CLAUDE.md section 5 states a task is not done until it passes. Not hypothetical: on 2026-08-04 `test_webui.py::test_webauthn_rp_fail_closed_legible` was FAILING on main all day and no lane saw it, surfacing only when one lane named both paths explicitly for a webconsole-touching change (`1 failed, 10681 passed, 851 skipped`). ⚠️ NOT a CI gap, verified rather than assumed: `ci.yml:250` installs `-e ".[dev,harness,fhir,dicom,x12,xml,webauthn]" -e packaging/messagefoundry-webconsole` and runs `Web console tests (pytest)` as a separate required step, so PRs merged on real coverage — the gap is local only, which is exactly why nothing red ever reached anyone. Difficulty 3 because the naive fix reds every local run: adding the path to `testpaths` makes that same `[webauthn]` failure the default local experience, since worktree venvs bootstrap a narrower extra set than CI. ⛔ Whichever option is chosen, a skip must announce itself — trading a silent exclusion for a silent skip is not a fix. | | 53 | **#236** | Test-this-step and test-up-to-step with pinned upstream values | 5 | 4 | _fill-in_ | P2 | Real debug breadth — whole-handler traced values already fold onto rows (`mergeLiveValues`, ide/src/stepsModel.ts:544) so partial runs are a convenience, but pinning an expensive `db_lookup`/`fhir_lookup` has no equivalent at all; largely a stop condition plus state dump on ADR 0072's shipped trace, with the lookup mock and keeping `buildLensTraceArgs` (:674) incapable of emitting `--show-phi` the real work. | @@ -6326,10 +6326,16 @@ filing. > > ⚠️ **AMENDED 2026-08-13 -- THE ITEM STAYS OPEN AND ITS QUESTION IS RE-SCOPED. Three corrections, and the third is the one worth reading.** > -> **1. THE REMEDY IS STRONGER THAN THIS ITEM CREDITS, AND THE DIFFERENCE IS LOAD-BEARING.** `api/auth_routes.py:753` routes `POST /users/{user_id}/reset-password`, and `:757` takes `Depends(require_step_up(Permission.USERS_MANAGE))` -- **the admin RE-AUTHENTICATES; it is not a plain permission check.** This item's own research question says *"anything that clears a lock is itself a control an attacker wants, so an unlock route is a candidate with its own abuse surface, not an answer"* -- and the step-up is a **partial answer to exactly that abuse surface**, which nothing in the record credits. Coverage is uniform, so this is not a partial-surface finding: all three backends clear the lock in `set_password` (`store.py`, `postgres.py`, `sqlserver.py` -- `failed_attempts=0`, `locked_until=NULL`, two sites each). +> **1. THE REMEDY IS STRONGER THAN THIS ITEM CREDITS, AND THE DIFFERENCE IS LOAD-BEARING.** `api/auth_routes.py:753` routes `POST /users/{user_id}/reset-password`, and `:757` takes `Depends(require_step_up(Permission.USERS_MANAGE))` -- **the admin RE-AUTHENTICATES; it is not a plain permission check.** ~~This item's own research question says an unlock route is *"a candidate with its own abuse surface, not an answer"*, and the step-up is a partial answer to exactly that abuse surface, which nothing in the record credits.~~ +> +> ⚠️ **THAT SENTENCE WAS WRONG IN BOTH HALVES AND IS CORRECTED HERE 2026-08-13. It was mine, and I landed it.** **(a) The record is NOT silent** -- cell **6.3.8** already describes *"the only reset is administrator-initiated behind a step-up gate"*. So **6.1.1 OMITS it; the record does not LACK it** -- a narrower and different defect. **(b) THE SIGN IS INVERTED. It is not a credit on 6.1.1's axis, it CUTS THE OTHER WAY.** 6.1.1 is about **malicious lockout**, and a stronger gate on the *sole* administrative remedy makes **RECOVERY HARDER**, not easier. **Record the step-up as a CONSTRAINT on the remedy, not as a mitigating control** -- it is part of why the sole-administrator case is a dead end, now filed as **#1236**. +> +> **How the error happened, because it is the reusable part:** the step-up's EXISTENCE was verified at `auth_routes.py:757` and is real. Its INTERPRETATION -- that it mitigates 6.1.1 -- was inherited from the report and never checked against the cell's axis. **Measuring the fact and adopting the framing are two acts, and only the first was performed.** Coverage is uniform, so this is not a partial-surface finding: all three backends clear the lock in `set_password` (`store.py`, `postgres.py`, `sqlserver.py` -- `failed_attempts=0`, `locked_until=NULL`, two sites each). > > **2. THE `docs/SECURITY.md:1474` CITATION IS STALE and will cost the next reader time.** At engine `9d98f339` that line is an unrelated **AD** row in the comparative-properties table. The disputed parenthetical is now at **`:1544`**, in the three-limiter anti-automation table. **Cite the greppable phrase, not the line number.** > +> **AND THE DISCLOSURE GAP IS NOT ONE PARENTHETICAL -- it is a CONTRADICTION a reader hits FIRST.** `docs/SECURITY.md:59` reads *"never auto-disabled, so **a single-admin deployment can't be locked out**"*. In context that sentence is about auto-retirement, but **its words flatly deny the thing this item is about**, and it sits **1,485 lines BEFORE** the known parenthetical at `:1544` -- it is the one a reader greps for and stops at. Two further collisions on the same word: `:741` *"AD operators are never locked out"* (true, and for a reason worth stating) and `:782` *"admin is never locked out"* (scoped to the enroll/confirm routes). **A documentation fix that edits only `:1544` leaves the contradiction that is read first.** +> > **3. WHY THIS ITEM GOT IT WRONG -- RECORDED AS METHOD, NOT MERELY AS A CORRECTION.** The filed absence claim searched `def admin_unlock|unlock_account|clear_lockout`. Measured on `main`: that regex returns **0 hits** in `auth/service.py`, a file containing exactly **1** `def admin_reset_password`. **The regex CANNOT STRUCTURALLY MATCH THE THING THAT SHIPS.** So the zero was a **true answer to a question nobody asked**, and this item's conclusion -- *"no dedicated administrative unlock exists"* -- is **literally true and materially misleading**: an unlock ships, as a side effect of a forced password rotation. > > > **THE PRESCRIPTION: an absence claim over a CAPABILITY must be written from the STATE, not from plausible function names.** *"Find every write to `locked_until`"* would have found it; *"find every function called something-unlock"* never could. @@ -8253,3 +8259,28 @@ every worker session's handoff, which is the sentence the next session bases its > **What makes this filable rather than a style note:** there is **no gate on either side**. The allocator answers *"is this number free"*; nothing asks *"is anything already pointing at it."* And a citation in another repository is invisible to every check this one runs. The two halves are each individually correct and the gap between them is the defect. **Cluster:** Process / ledger integrity. **Priority:** P3. **Verdict:** build (the rule). **Severity:** minor and self-inflicted -- it corrupts cross-references between maintainer documents, touches no shipped code, and no deployment can observe it. + +## 1236. a sole-administrator deployment has no recovery from account lockout: every exit is closed by design + +> 🔢 **Filed 2026-08-13 -- an AVAILABILITY defect in the shipped control, NOT a documentation gap, and split out of #1131 for exactly that reason.** Value **6/10** -- Difficulty **4/10**. Each exit is individually deliberate and defensible; the defect is that **they close simultaneously for a deployment with one administrator**, and nothing in the code or the docs notices the conjunction. + +> **THE FIVE EXITS, each measured on `origin/main`:** +> ``` +> bootstrap account is literally "admin" auth/service.py:71 BOOTSTRAP_USERNAME = "admin" +> -> the username an attacker guesses first is the one that cannot recover +> created with no email -> SecurityEventNotifier returns early +> pipeline/security_notify.py:127-131 +> -> the ACCOUNT_LOCKED notice never leaves the process +> self-reset refused api/auth_routes.py:762 (own account) +> admin reset needs ANOTHER admin -- there is not one +> re-bootstrap only on an EMPTY users table auth/service.py:529 +> no argparse subcommand manages users -- no offline path either +> ``` + +> **WHY IT IS NOT A DOCUMENTATION ITEM, and why it must not ride #1131.** #1131 asks what the anti-automation documentation must SAY. This item is that the shipped system has no recovery to describe. **If it rode #1131, a documentation edit would close the item while the defect went untracked** -- the reader would see "lockout disclosure fixed" and reasonably conclude recovery exists. + +> **The step-up gate on the sole remedy is a CONSTRAINT here, not a mitigation** (`auth_routes.py:757`, `require_step_up(Permission.USERS_MANAGE)`). It is correct on its own axis and it makes this case harder, which is precisely the shape #1131's amendment first got backwards. + +> **How to prove a fix:** lock the sole administrator, then recover **without** editing the database by hand and **without** an already-authenticated second admin -- and assert the recovery path is itself gated, since an unlock affordance is a control an attacker wants. A fix that only lengthens the docs, or that opens an ungated reset, fails on opposite sides. + +**Cluster:** Security / availability. **Priority:** P2. **Verdict:** build. **Severity:** conditional per CLAUDE.md section 0 -- on a first deployment with one administrator, a lockout would be unrecoverable without direct database access; **zero deployments, so nobody is locked out today.**