Skip to content

docs(security): reconcile five operator docs with the enforced posture - #83

Merged
wshallwshall merged 14 commits into
mainfrom
claude/documentation-inventory-c16092
Jul 30, 2026
Merged

docs(security): reconcile five operator docs with the enforced posture#83
wshallwshall merged 14 commits into
mainfrom
claude/documentation-inventory-c16092

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What this is

Four rounds of adversarial security review of the five operator-facing docs, landing as their own PR. (The prior documentation pass merged as #69 while this review was still in flight, so these fixes were never part of it.)

The review judged each claim by what a reader would do with it, not whether it is accurate. Every finding is a true sentence that misleads about the posture — accurate about the mechanism, but pointing the operator somewhere they cannot go: an instruction that contradicts the gate it configures, or a recipe that starts the engine in a state the doc says it prevents. Plain correctness review found almost nothing; adversarial review found 34 → 19 → 7 → 5 → 4 defeatable claims across the rounds, the blocking count falling as each round found a new class.

Files

docs/CONFIGURATION.md, docs/CONNECTIONS.md, docs/DEPLOYMENT.md, docs/REMOTE-CONSOLE.md, docs/SYSTEM-REQUIREMENTS.md — 969 insertions / 205 deletions total, plus a routine integration of main.

The blocking fixes

Each was confirmed by executing the shipped recipe through load_settings() / load_config() in the venv, not by reading it:

  1. Browser-console walkthrough could not serve the console it documents. It omitted [security].serve_web_console = true; every posture it instructed (off-loopback bind, declared terminator, public origin) independently trips the ADR 0143 auto-degrade — yielding a cleanly-started engine, one stderr warning, and a silent 404 at /ui. Added a non-optional Step 0 and two complete, runnable postures (declared-terminator and in-process-TLS), each verified to serve /ui and verified to silently degrade with the line removed.
  2. Refuse-vs-warn documented against the wrong axis. security_notifications_required, require_managed_identity, and the cleartext-hop disposition were described as "refuses in production / warns in non-production". The gates read [security].enforcement, which is enforce by default on dev and staging too — so a team standing up staging is promised a warning and gets exit 2. (Measured: dev/staging/prod all resolve enforcing=True.) Rewrote every affected row.
  3. DICOM SCP peer control named a key that is silently discarded. source_ip_allowlist is an inbound(...) keyword, not an [inbound] TOML key — and every section model is extra="ignore", so the TOML spelling is accepted and dropped at load. Because the construction gate counts controls, an operator who sets a (spoofable) calling_ae_allowlist plus the discarded key passes the fail-closed check believing they have an IP restriction they do not have. Rewrote the section, added the honest read-back disclosure (a code-first DICOM connection has no read-back surface), and fixed the "hardened SCP" example that bound loopback and configured no IP gate at all.
  4. Open-egress gate counts only six of eight allow-lists. allowed_smtp and allowed_direct appear nowhere in the predicate, so a mail-only or Direct-only PHI instance that declares its destinations still exit 2s with "outbound egress is UNRESTRICTED". Retitled the behaviour-table rows and told mail-only/Direct-only deployments to set [security].block_unlisted_outbound = true.

Plus sweeps: the §Example config now starts clean against all four gates it documents (it previously sat in three of them); alert routing rules were silently inert with zero configured transports; allowed_tcp/allowed_http under-described the connectors they gate; the /health network-gate exemption, the MFA-enrolment exemption, and a missing Loopback import were all corrected.

Coverage evidence

Not just fixes — the reviewer listed the instructional passages checked and found already correct (36 verified alongside the changes), ran all 11 Python snippets + 4 connections.toml blocks from CONNECTIONS.md through the real loader (15/15 load after one import fix), and re-ran the doc-drift / ledger test selection green after every edit.

Deliberately not in this PR (owner call)

A rewrite had added a section describing the real messagefoundry import corepoint command using estate terminology the leak-gate flags (2 hits, source and PDF). It was absent from main, so the rewrite would have introduced it. It is withheld here and preserved verbatim in the paused session's scratchpad. A real shipped feature is not automatically a publishable one — flagging for an owner decision on whether to reword + republish.

Follow-up code defects (filed separately, not fixed here)

The doc fixes treat symptoms whose root causes are in code and will re-seed the drift:

  • transports/dicom.py refusal message + docstring name the non-existent [inbound].source_ip_allowlist.
  • __main__.py egress_open predicate omits allowed_smtp / allowed_direct — arguably a gate bug (a mail-only site is forced onto block_unlisted_outbound for an invisible reason).
  • __main__.py:1135-36 and :2081-82 comments still say "refuse (production)/warn (non-production)" — the stale comments the two blocking doc defects were copied from.
  • pipeline/alert_sinks.py returns before rule/transport cross-validation, so a rule on a zero-transport instance is silently never applied.

✅ RESOLVED — the inherited main breakage is fixed

(Kept for the record; no longer blocking.) When this PR was opened, main was red and every open PR inherited it. PR #80 had added BACKLOG items #232–#239 to docs/BACKLOG.md without the mandatory status banner, which tests/test_backlog_status_check.py enforces against the real file:

BACKLOG.md:6905: item #232 declares no status. …
BACKLOG.md:6929: item #233 …  6951: #234 …  6967: #235 …
BACKLOG.md:6983: item #236 …  6997: #237 …  7013: #238 …  7027: #239 …
8 error(s). Every backlog item must declare exactly one status banner.

Two independent proofs that this is inherited, not introduced:

  1. Running the checker against pristine origin/main (this branch not involved) reproduces the identical 8 errors.
  2. git diff --name-only origin/main...HEAD lists only the five doc files — this branch never touches docs/BACKLOG.md.

#82 ("docs(backlog): items 232-239 declared no status, turning main red", docs/BACKLOG.md only, +16/−0) fixed it — authored by the session that owns those items, since the correct banner was a content call this PR should not guess. It merged as c786577e, and this branch has been re-synced onto it. The backlog checker now reports OK — 237 backlog items, each declaring exactly one status, and tests/test_backlog_status_check.py is 15 passed.

Nothing here was ever blocked on this PR's own contents.

Verification

  • Local unfiltered pytest — no -k filter, because a narrow filter is exactly what let a guard go red in CI last time:

    1 failed, 9495 passed, 817 skipped, 22 warnings in 879.07s (0:14:39)
    FAILED tests/test_backlog_status_check.py::test_the_real_backlog_satisfies_the_invariant
    

    That run predates the re-sync: the single failure was the inherited main breakage described above, now fixed by docs(backlog): items 232-239 declared no status, turning main red #82 and confirmed green here. Everything else passed, which is the clean confirmation that nothing in these five documents is at fault.

  • Branch integrated with origin/main (contains docs(architecture): the stated Python floor was three minors behind pyproject #79, backlog: eight Steps-view items from the Windmill/Kestra evaluation #80); satisfies strict branch protection.

  • Merge was verified lossless: the five docs are byte-identical to the pre-merge commit, and ARCHITECTURE.md/BACKLOG.md are byte-identical to origin/main.

  • No .py in the branch delta, so the ruff …--fix rewriting hooks never fired (venv ruff is 0.15.22, matching the constraints.lock pin).

  • CI runs the authoritative real-token forbidden-content scan (the local pre-commit scan used the synthetic token set).

🤖 Generated with Claude Code

wshallwshall and others added 11 commits July 29, 2026 18:47
A documentation accuracy pass over the public set: 191 corrections across 81
files, each verified against the code rather than against the prior prose.

Content corrections
- The retired PySide6 desktop console is swept out of FEATURE-MAP, DEPLOYMENT,
  SYSTEM-REQUIREMENTS, SERVICE, docker/, and ide/. The web console at /ui is the
  sole operator surface; PySide6 is harness-only. FEATURE-MAP's cluster/leader
  row moved to shipped rather than being deleted with the desktop console -- the
  capability lives in the web console (monitoring.py renders it from
  GET /cluster/status).
- CONNECTIONS.md documented 12 of 17 registered connectors. Settings sections
  added for the missing ones from the registry and config models, and the
  SFTP/SMTP self-contradiction resolved against transports/remotefile.py --
  those rows were marked "planned" while REMOTEFILE registers both and
  pyproject ships an [sftp] extra.
- CONFIGURATION.md's "(proposed)" framing dropped where settings.py implements
  the mechanism, and the implemented vs accepted-but-ignored key lists corrected
  against the model. That table is load-bearing: an adopter setting an ignored
  key currently gets silence.
- DEPLOYMENT.md's TLS/exposure guidance aligned to ADR 0148/0153.
- SYSTEM-REQUIREMENTS.md no longer claims no published baseline exists;
  TUNING-BASELINE.md is published. Three prose capacity ceilings that asserted
  capability as fact were corrected; the labelled projection table was left
  alone and its caveat hardened.
- MENTAL-MODEL.md off its v0.1.0 banner; USER-GUIDE.md walkthroughs checked
  against the real CLI surface.
- The four throughput plans ADR 0107 closed now carry superseded banners. The
  bodies stay -- they are the record of why.

Cross-references
- ADR slug links repaired. Where a renamed ADR meant the citing sentence was
  quoting the superseded position, the sentence is flagged for review rather
  than silently repointed -- a working link to a reversed conclusion is worse
  than a dead one.
- Links into the deleted messagefoundry/console/ tree and into the withheld
  docs/security/ tree converted to plain-text references, so they stop reading
  as rot. No public path was invented and no policy document was created;
  publishing docs/Secure_Development_Standards.md remains an owner decision, so
  its 44 citations are likewise plain text. adr/TEMPLATE.md fixed first, since
  it propagated the dead citation into every new ADR.
- All 26 internal links in ASVS-L2-PHASE0-CHANGES.md repaired.

What did NOT change, and why it matters
60 leads from the inventory were rejected after checking the code. Among them:
docker/ never had a [console] extra or console entrypoint; the Dockerfile's
PySide6-in-runtime warning is accurate (requirements.lock pins pyside6);
FHIR-IN really is planned (only a destination is registered); and
SYSTEM-REQUIREMENTS.md never carried the 45M/day figure, so it was deliberately
not introduced -- ADR 0052 is a target, never quotable as capability.

Verification: leak gate exit 0 with the real roster (names=7, estate=13,
site_prefixes=1); 967 passed / 0 failed across the doc, ledger, ADR-index,
link and connector guards, with test_no_links_into_private_paths and
test_the_real_backlog_satisfies_the_invariant confirmed executed, not skipped.
BACKLOG.md changes are link-to-text only -- no numbered heading added or
removed, banner glyphs untouched.

43 questions that need an owner decision are recorded separately rather than
guessed at.
Owner decision. The document licenses itself as publishable and adopter-facing,
messagefoundry.org already served it as both PDF and Markdown, and citations
across the repo -- including adr/TEMPLATE.md, which propagated the dead
reference into every new ADR -- pointed at a file that did not exist here.

Source and de-branding
Three copies existed. The newest and richest lived in the private vault as a
customer-branded fork, and its own revision history records that it was
"re-versioned and re-branded from the prior generic Secure Development
Standards (which had reached v2.0)". So the generic lineage is the original and
de-branding restores it rather than inventing it. That copy is the base: it
carries ten sections the un-branded copies lack -- SSDF PW practices,
spec-driven development, NIST SP 800-115 testing tiers, ASVS 5.0 L3 scope,
NIST SP 800-66 Rev. 2 safeguards, and References.

Four transformations, each auditable in scratch publish_sds.py: de-brand the
title and Document row; carry the public version forward to 2.1 from the v2.0
the history names; rewrite the two revision rows to describe the content
changes without naming a customer fork; de-link references into docs/security/,
which stays withheld.

Checked before publishing, not after
- Leak gate exit 0 with the real roster. Note it also passed on the BRANDED
  copy -- the initialism is not in the token roster -- so the de-brand was
  verified by direct inspection, not by the gate. Zero occurrences remain.
- Every other customer-shaped term run down by hand: the one Corepoint mention
  is a competitor list ("Corepoint, Mirth Connect, Rhapsody, Cloverleaf"),
  which is public positioning; no hostnames, IPs, or internal identifiers.
- docs/ is absent from the sdist allowlist (only-include = messagefoundry,
  README, CHANGELOG, LICENSE, NOTICE), so this does NOT undo the #1020
  packaging remediation. test_release_pipeline passes.

Citations
8 restored across .github/SECURITY.md, three docs/ standards companions, three
ADRs and adr/TEMPLATE.md -- all verified to resolve. The inventory's "44 links"
was an overcount; the real figure is 23 mentions, most by title rather than
filename, and linking every occurrence would be noise, so the convention is the
first mention per file plus any section-qualified citation.

653 passed / 0 failed across the doc, link, ADR, backlog and release-pipeline
guards.
97 changes across 11 file groups, each verified against code rather than against
the prior prose. 60 further leads were rejected after checking; they are the
reason to trust the 97.

Corrections that changed what the docs claim
- SYSTEM-REQUIREMENTS said "No native transport TLS" and "no MLLP-over-TLS".
  Both are false: settings.py:694-712 is an in-process API/WebSocket TLS block
  (WP-13a, ADR 0002) and mllp.py:505-524 builds per-connection MLLP SSL
  contexts with opt-in mTLS (WP-13b). The doc was understating shipped
  capability and telling adopters to buy a reverse proxy they may not need.
- CONNECTIONS' ASVS 5.1.1 block claimed the file surface accepts "HL7 v2 text
  only". The code sniffs against the DECLARED content_type (file.py:514) and a
  non-HL7 drop bypasses HL7 handling entirely (file.py:644,673, ADR 0004). It
  now describes declared-type conformance. An overstated control is the kind an
  assessor finds and then distrusts the rest of the document over.
- The eight sizing tiers had no measurement behind them. Re-derived from the
  published baseline with the arithmetic shown, and daily figures now state
  their duty-cycle assumption instead of implying 86400s of peak.
- ~40 implemented settings keys had no row in CONFIGURATION. Added from
  ServiceSettings.model_fields.
- NOT_DEPLOYED, a seventh disposition genuinely set by the finalizer
  (store.py:319, 912, 926, 955), was documented nowhere. Added to MENTAL-MODEL,
  USER-GUIDE and ARCHITECTURE.
- MENTAL-MODEL never mentioned engine sharding, though it is the built default
  scaling axis, while asserting active-active scale-out was dropped -- together
  reading as "this does not scale". Both corrected, and the two are now
  distinguished rather than conflated.
- SERVICE now documents that a custom -Environment name also requires
  [ai].data_class and [ai].production (install-service.ps1:66-71). Following the
  old text produced a service that would not start.

Classification and structure
- New docs/SECURITY-DOCS-POLICY.md states what is kept private and why, using
  the operative rule: private only if it discloses an OPEN, un-remediated
  weakness in exploitable detail, or names a customer. A closed audit is
  transparency. The ~145 plain-text references into the withheld tree now have
  something to point at instead of naming documents a reader cannot find.
- Four docs self-marked "internal" while sitting in a public repo. Re-sorted
  under that same rule; the labels now match reality.
- The five throughput plans ADR 0107 closed moved to docs/archive/throughput/
  with a README. Bodies intact -- they are the record of why.

ADRs, treated as historical records
- ADR 0101 gains the estimand reporting rule it was already being cited for.
  ADR 0074:146 cited it; ADR 0101 did not contain it. The rule is real: the
  intake-vs-delivery conflation caused the Arm 0 "sustains 26" retraction.
  Added with a dated amendment note, since 0101 is Accepted.
- ADR 0090's PySide6 residual cannot be built. Annotated with a dated note
  rather than repointed -- repointing would rewrite what was decided.

Verification
Leak gate exit 0 with the real roster (names=7, estate=13, site_prefixes=1).
972 passed / 0 failed across the doc, link, ledger, ADR, feature-map, connector
and release-pipeline guards, with test_feature_map_claims and
test_backlog_status_check confirmed EXECUTED (23 PASSED), not skipped.

The archive move was the risk here, because moving a file breaks inbound links
without any test going red. All 4,593 relative links across 414 markdown files
were resolved: zero real breakages. (Two `command:` VS Code URIs in an IDE ADR
are legitimate, not file paths.)

57 security-posture passages are held back for owner review before the PR.
Ten commits landed on main during this documentation pass. Two files conflicted;
both resolutions take the other session's content and keep this branch's link fix.

docs/ASVS-L2-PHASE0-CHANGES.md — the sibling ASVS-TLS session corrected a real
error: `SSLContext.set_groups` is a Python 3.15 API, not 3.13, so the key-exchange
pin currently pins NOTHING and those contexts inherit OpenSSL's default group
list. That correction wins over this branch's older "pins the approved groups"
text, which was wrong. Kept this branch's relative-path fix (../messagefoundry,
not ../../, which escapes the repo root).

docs/Secure_AI_Development_Standards.md — kept their added honesty note that
CODEOWNERS is documentation rather than enforcement here, and kept this branch's
de-linking of RELEASE-GATE.md, since docs/security/ stays withheld from the
public repo.

Verified after integrating: leak gate exit 0 with the real roster; 976 passed /
0 failed; 4,605 relative links across 414 files resolve with zero breakages.
CI went red on a documentation-only branch. The cause was a precision bug in
the guard, not rot in the docs.

`_PROSE` alternated on the bare string `private repo`, so it matched INSIDE
longer words. Two lines counted as present-tense mirror prose while being
nothing of the kind:

  .github/workflows/release.yml:271   a quoted GitHub error about
                                      "user-owned private REPOsitories"
  docs/Secure_Development_Standards.md:284
                                      "a private REPOrting channel"

The second arrived with the newly published standards doc and took the count to
56 against a ceiling of 55.

The fix is a word boundary on that one alternative. Measured before changing it,
because the temptation here is to widen a guard until your own change passes:
the boundary suppresses exactly those two lines and keeps every genuine
"private repo" hit -- about twenty, across the workflows, INSTALL-GUIDE,
VERSION-CONTROL, SECURITY and the ADRs. No true positive is lost.

The ceiling therefore moves 55 -> 54. That is the ratchet working as documented
("lower it when you fix some; NEVER raise it") -- the honest count fell because
two false positives were removed, so the ceiling follows it down. It also keeps
test_the_ratchet_is_not_slack satisfied, which fails if the gap exceeds 8.

Proven, not assumed: appending "This repository is the public mirror of the
private repo." to a tracked doc makes the guard go RED at 55 vs 54, and removing
it makes it green again. A guard changed without demonstrating it can still fail
is a guard nobody should trust.

Also worth recording: the module's failure message labels its tail `hits[-12:]`
as "New or changed", which it is not -- it lists the last twelve hits in scan
order. That is what made the failure look like it came from test files nobody
had touched. The delta had to be computed against origin/main by hand.
Six documentation findings today were all the same shape, and none was a false
statement: true about the mechanism, misleading about the posture. A sentence
that survives every spot-check while pointing the reader somewhere they cannot
go. A correctness review terminates at "yes, that is accurate" and finds none of
them.

Secure_Development_Standards.md §3 gains "Reviewing security prose: ask what a
reader would DO with it" as the governing instruction, with three rules under it
and the evidence for each:

- State a load-bearing fact ONCE and link to it. A repo that states a fact twice
  will eventually state it two ways, and the stale copy is the one that gets
  cited, because a reader who finds A statement stops looking for the other.
  Three instances in one day: harden_kex_groups described as pinning
  key-exchange groups across FIVE live documents when SSLContext.set_groups is a
  Python 3.15 API that pins nothing; a superseded Transit/unkeyed-audit-chain
  claim left in PHI.md after the code closed it, from where it reached a public
  page AND a security review; and PHI.md §5 asserting "no PHI is placed in a
  URL" while §7 documented the query parameters that carry it. Each time the repo
  held both the right and the wrong version.
- A completeness claim is a liability. "Two configurations do X, and a reviewer
  should hear both" invites the check and survives it. Twice this set shipped
  such a sentence wrong in BOTH directions -- naming a case that no longer
  existed while omitting one that did.
- A compensating control must not rest on a false premise. A Referrer-Policy
  relaxation was justified by "console URLs carry opaque ids only (never PHI)",
  untrue of the console's own search route. The control was sound; the reason was
  not, and the next person reasons from the comment.

CLAUDE.md §11 carries these as four bare one-line imperatives pointing at that
section. The duplication is deliberate and is the rule applied to itself: the
FACT lives once, because evidence, dates and line numbers drift; the INSTRUCTION
is repeated because it is one line, cannot meaningfully drift, and a pointer
nobody follows mid-task changes no behaviour. If the two ever disagree it will be
about the wording of an instruction, not about what is true.

CONFIGURATION.md's audit_days row is aligned with docs/PHI.md §8, which is now
named in the row as the source of record. Both say keep-forever rests on the
retention requirement -- 45 CFR 164.316(b)(2)(i), against framework floors far
below it -- and NOT on chain-breakage. That argument was inverted: deleting the
OLDEST rows does break the walk, but an attacker hides by truncating the NEWEST,
and the surviving prefix still verifies (store/store.py:7385). The anchor that
would catch it is not reachable from the shipped CLI, so audit-verify returns
clean after a truncation; that gap is filed separately.

Provenance is recorded in the standards section because the provenance is the
evidence: the state-it-once rule came from the parallel ASVS session, which also
supplied two of the three instances.

Verified: leak gate exit 0 with the real roster; 72 passed across the ratchet,
feature-map, backlog and security-doc-drift guards; 4,606 relative links across
414 files resolve. CI was GREEN on all 35 checks for the preceding commit.
Reported by the messagefoundry-website session, which hit it for real: a peer's
PR merged mid-session and the gate kept blocking 31 files, a third of that site.

The repo squash-merges everything ("title (#NN)"). A squashed commit never
becomes an ancestor of the branch it came from, so the merge-base never advances
and `diff origin/main...HEAD` keeps reporting the branch's files after its work
has landed. Its session then blocks that whole file set until someone prunes the
worktree.

Neither diff form is right alone, and each fails in the opposite direction:
three-dot credits a landed branch forever; two-dot blames a merely-behind branch
for every file main moved underneath it. The intersection -- authored AND not yet
landed -- is correct, and self-clears on squash, rebase and merge-commit alike.
A two-dot failure falls back to the authored set, so a git hiccup over-blocks
rather than silently under-blocking.

Measured here before and after, on real worktrees:

  landed, claiming phantoms   asvs-15-2-4-name-claimed   3dot=8   intersect=0
                              gate-version-stamp         3dot=4   intersect=0
                              pins                       3dot=16  intersect=0
  genuine outstanding work    documentation-inventory    101 -> 101
                              master-test-plan            21 ->  21
                              asvs-14-2-7-retention       11 ->  11
                              ci-suite-wiring              4 ->   4
                              prunefix                     7 ->   7

`pins` was live: it held zero uncommitted files and zero outstanding committed
files, and was still blocking 16. After the fix it drops off the map entirely
while every branch with real work is unchanged. Verified with -Refresh, since the
cache is TTL-bounded and served the old map on the first run.

Landing here rather than in its own PR because this branch's merge is exactly the
event that triggers the bug: 101 files would have stayed blocked for every other
session until the worktree was pruned.
Seven commits landed on main. One file conflicted, .github/SECURITY.md, and both
sides had independently fixed the SAME defect: two markdown links into
docs/security/, a directory absent from the public repo.

main (#78) removed the references outright. This branch converted them to plain
text and pointed at docs/SECURITY-DOCS-POLICY.md, a page that exists only on this
branch and that the owner ratified precisely so those ~145 withheld-path mentions
have somewhere to point instead of naming documents a reader cannot find.

Resolution takes both: main's tighter sentence structure (em-dash rather than a
parenthetical), and this branch's named document plus the policy pointer. A
reader now learns the analysis exists, that it is maintainer-internal, and what
they can request -- which is the whole point of the policy page.

main's other change to the file, a working security@ address replacing the dead
"email the maintainer at the address on the GitHub profile" fallback (#77), came
through untouched and is strictly better than what either side had.

Note the five docs/ files a review workflow is currently editing were NOT touched
by main and remain uncommitted and intact; this merge deliberately stages only
the conflicted file.

Verified: 0 conflict markers; the new security@ address present; the policy
pointer present twice; 4,639 relative links across 414 files resolve.
Four rounds of adversarial security review of the operator-facing docs, judging
each claim by what a reader would DO with it, not merely whether it is accurate.
Every fix targets a TRUE sentence that pointed the reader somewhere they could
not go: an instruction contradicting the gate it configures, or a recipe that
fails silently. Each was verified by executing the shipped recipe through
load_settings()/load_config() in the venv, not by reading it.

Files:
- docs/CONFIGURATION.md
- docs/CONNECTIONS.md
- docs/DEPLOYMENT.md
- docs/REMOTE-CONSOLE.md
- docs/SYSTEM-REQUIREMENTS.md

Highlights:
- Browser-console walkthrough now names [security].serve_web_console = true as a
  non-optional Step 0; without it every posture it instructs trips the ADR 0143
  auto-degrade to a silent /ui 404. Two complete, runnable postures added.
- The refuse-vs-warn split for security_notifications_required,
  require_managed_identity and the cleartext-hop disposition is
  [security].enforcement, not the production tier. Corrected across the rows
  that told a staging operator they would be warned when the gate exits 2.
- DICOM SCP peer control: source_ip_allowlist is an inbound(...) keyword, not an
  [inbound] TOML key (which is accepted and silently discarded). The
  construction gate counts controls, so the wrong spelling passes fail-closed.
- The [egress] open-egress gate counts only six of eight lists; a mail-only or
  Direct-only PHI instance must set [security].block_unlisted_outbound = true.
- Alert routing rules are silently inert with zero configured transports; the
  worked [cert_monitor]/[secret_rotation] recipes and the full §Example config
  now start clean against every gate they document.

Filed as code defects (task chips), not fixed here: the stale
"refuse (production)/warn" code comments that seeded the drift, the DICOM
refusal message naming a discarded key, the egress gate omitting
allowed_smtp/allowed_direct, and the alert-rule validation asymmetry.

The round-4 fixer's empirical verification is complete and the doc-drift/ledger
suite is green. Local leak scan uses the synthetic token set; CI runs the
authoritative real-token forbidden-content check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o claude/documentation-inventory-c16092

# Conflicts:
#	docs/CONFIGURATION.md
#	docs/CONNECTIONS.md
#	docs/DEPLOYMENT.md
#	docs/REMOTE-CONSOLE.md
#	docs/SYSTEM-REQUIREMENTS.md
wshallwshall added a commit that referenced this pull request Jul 30, 2026
)

The documentation pass (#83) corrected five operator docs against the enforced
posture. Four of those corrections were treating symptoms: the code itself named
a settings key that does not exist, described its own gate against the wrong
axis, silently discounted two allow-lists, and validated alert routing only in
the state an operator is least likely to be in. Left alone, each re-seeds the
drift the moment someone reads the source.

1. DICOM peer-control refusal named a key that is silently discarded
   transports/dicom.py told the operator to set `[inbound].source_ip_allowlist`.
   InboundSettings has no such field and section models ignore unknown keys, so
   an operator following the engine's OWN error message writes a key into
   messagefoundry.toml that is accepted and dropped — leaving a non-loopback SCP
   with no peer-IP gate while believing it has one. Aggravated by the gate
   COUNTING controls, so a spoofable AE-title list plus the discarded key passes.
   The message now names the working surface (an `inbound(...)` keyword, the ONLY
   one for a DICOM SCP since DICOM() is not authorable in connections.toml),
   disambiguates it from the connections.toml `[[inbound]]` key that IS real, and
   leads with mTLS. Same spelling corrected in the module docstring, the gate
   comment, wiring.py, docs/SECURITY.md and docs/ASVS-L2-PHASE0-CHANGES.md.

2. Open-egress gate counted six of eight allow-lists
   `egress_open` omitted allowed_smtp and allowed_direct, both of which ARE
   enforced downstream by _allowlist_for. A mail-only or Direct-only PHI instance
   could enumerate every destination it uses and still be refused as
   "UNRESTRICTED", with nothing naming the two lists that did not count — and the
   gate's own docstring already claimed otherwise.
   Fixed in the FAIL-CLOSED direction only: the two lists count only when
   [security].block_unlisted_outbound was left UNSET, which is exactly the state
   the deny-by-default flip turns ON. Measured, prod PHI: allowed_smtp-only and
   allowed_direct-only go exit 2 -> start (deny-by-default flipped ON); the
   explicit `block_unlisted_outbound = false` + smtp/direct-only case STILL
   refuses. No shipped refusal stops firing. The refusal now names that override
   when it is the reason a declared allowlist did not satisfy the gate.

3. Two gate comments described the deployment tier, not the enforcement dial
   __main__.py said "refuse (production) / warn (non-production)" over branches
   that read `enforcing` — which is `enforce` by default on dev and staging as
   much as prod, so all three REFUSE. These comments are what two BLOCKING doc
   defects were copied from. Comment-only, no behaviour change.

4. Alert rules were validated everywhere except where it matters
   notifier_from_settings returned on zero transports BEFORE cross-validating
   rules, so a [[alerts.rules]] block routing to a transport the instance does
   not configure was accepted and silently never applied — while the identical
   rule alongside one transport was a hard ValueError. Validation now runs first.
   A rule naming no transport is unaffected (still starts, now with a warning when
   rules exist that cannot page anyone).
   BREAKING: an instance that starts today with an inert rules block naming a
   transport will now refuse until the transport is configured. In that state the
   rule has never routed a single alert, so this removes no working behaviour — it
   converts a permanent silent no-op into a startup refusal that names the keys.
   Also gated at AUTHORING time: `alert add` cross-checks the rule being added, so
   the editor can no longer write a config that only fails at the next boot.
   Scoped to the added rule so a file already containing a bad rule can still be
   repaired with `alert remove` rather than being wedged shut.

Filed, not fixed: BACKLOG #252 — the DICOM gate counting a spoofable AE-title
list as a sufficient peer control. That is an ADR 0025 §9 + SECURITY.md contract
change and deserves its own decision, not a rider on a message correction.

Verified with the unfiltered suite (a narrow -k filter is what let a guard go red
in CI last time). New regression tests pin the egress narrow form (including the
loosening that deliberately did NOT happen), the alert authoring refusal, and the
no-transport rule still being allowed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@wshallwshall
wshallwshall merged commit 9e18800 into main Jul 30, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/documentation-inventory-c16092 branch July 30, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant