Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6992f08
fix(apiclient): measure the resolved URL, query included (BACKLOG #1047)
wshallwshall Aug 10, 2026
17dd58f
fix(worktree): give remove.ps1 a repo-root override so it can be exec…
wshallwshall Aug 10, 2026
b7b3d9d
fix(oidc): bound the token-exchange request line and headers (BACKLOG…
wshallwshall Aug 10, 2026
794a8c6
fix(worktree): print cleanup advice that runs as written (BACKLOG #1078)
wshallwshall Aug 10, 2026
52a20c8
backlog: reconcile the live ledger against the shipped code (BACKLOG …
wshallwshall Aug 10, 2026
5a9dcd9
fix(vault): refuse redirects on all three [vault] clients (BACKLOG #1…
wshallwshall Aug 10, 2026
fbd2178
backlog(archive): move 41 closed items out of the live ledger, verbat…
wshallwshall Aug 10, 2026
391ff8b
fix(api): BACKLOG #1045 - the PHI property gate now fails CLOSED at t…
wshallwshall Aug 10, 2026
b9246e2
docs(archive): re-derive the archive header's item count instead of r…
wshallwshall Aug 10, 2026
c526aa2
backlog: drop the clause count from #64's reconciliation (BACKLOG #64)
wshallwshall Aug 10, 2026
5ecdae2
feat(parsing): BACKLOG #1049 - $variable-bound XPath, so a Handler ha…
wshallwshall Aug 10, 2026
929d69c
fix(file): claim the archive destination name atomically (BACKLOG #1046)
wshallwshall Aug 10, 2026
1d7c5ae
fix(worktree): create the branch with --no-track so @{u} answers the …
wshallwshall Aug 10, 2026
fc6ae40
docs(worktree): say that --no-track does not reach EXISTING worktrees…
wshallwshall Aug 10, 2026
23b1f98
test: BACKLOG #1043 - the threat-model guard now announces when it is…
wshallwshall Aug 10, 2026
b6a45c3
feat(api): bound how long a handler may build a response (BACKLOG #1044)
wshallwshall Aug 10, 2026
ed13364
test(cutover): the mirror-prose ratchet counted the backlog archive b…
wshallwshall Aug 10, 2026
e3cecf8
refactor(file): drop the orphaned _unique helper (BACKLOG #1046)
wshallwshall Aug 10, 2026
0cbb0d9
test: BACKLOG #1043 - keep the mirror-prose ratchet at its ceiling
wshallwshall Aug 10, 2026
05e252f
fix(coord): the announce hook's SKIP rule reads isRunning backwards (…
wshallwshall Aug 10, 2026
817953d
fix(coord): label the session id that reads as a commit SHA (BACKLOG …
wshallwshall Aug 10, 2026
48c2d2f
fix(worktree): anchor install-gate's config-dir glob and stop it audi…
wshallwshall Aug 10, 2026
40f1029
test(gate): tie the three copies of the gate-rule text scan together …
wshallwshall Aug 10, 2026
12595cc
fix(worktree): the new -Status audit must not report a clean verdict …
wshallwshall Aug 10, 2026
09f14ef
fix(sandbox): install the PHI filter chain on the worker child (BACKL…
wshallwshall Aug 10, 2026
2a118d3
fix(logging): redact a traceback escaping a non-main thread (BACKLOG …
wshallwshall Aug 10, 2026
3eb480b
Merge branch 'w1-l2-instruments' into w1-integration
wshallwshall Aug 10, 2026
0ccbcfc
Merge branch 'w1-l3-worktree-scripts' into w1-integration
wshallwshall Aug 10, 2026
73d58da
Merge branch 'w1l4-sandbox-log' into w1-integration
wshallwshall Aug 10, 2026
5576e24
Merge branch 'w1-l5-bounds' into w1-integration
wshallwshall Aug 10, 2026
a0520bb
Merge branch 'w1-l6-failopen' into w1-integration
wshallwshall Aug 10, 2026
6ac3f4c
backlog: flip the 17 banners wave 1 landed across lanes 2-6
wshallwshall Aug 10, 2026
4edddee
fix(worktree): the account-dir glob needs -Force, or Linux wires noth…
wshallwshall Aug 10, 2026
5f856de
Merge branch 'w1-l2-instruments' into w1-integration
wshallwshall Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,754 changes: 79 additions & 1,675 deletions docs/BACKLOG.md

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions docs/PHI.md
Original file line number Diff line number Diff line change
Expand Up @@ -844,9 +844,10 @@ heuristic reminder and never blocks the gate. The existing controls — never lo
INFO+, the CR/LF log-injection filter, and silencing python-hl7's PHI-prone loggers — remain in
[logging_setup.py](../messagefoundry/logging_setup.py).

**Global log redaction + prod-DEBUG guard `[BUILT]` (Gate #1).** **Three** handler filters run, **on every record emitted in the engine process**, in this
**Global log redaction + prod-DEBUG guard `[BUILT]` (Gate #1).** **Three** handler filters run, **on every record emitted by the engine process and by the ADR 0087 sandbox worker child**, in this
order, on **every** emitted record and on **every** handler — stdout *and* the off-box forwarder —
installed by `configure_logging`'s `_install_phi_filters`
installed by `_install_phi_filters`, reached through `configure_logging` in the engine and
`configure_stderr_logging` in the child
([logging_setup.py](../messagefoundry/logging_setup.py)):

1. **`RedactionFilter`** — `redact()`-scrubs both the rendered **message** and the formatted **exception
Expand Down Expand Up @@ -983,10 +984,18 @@ WebSocket are **live telemetry, not log records** — neither retains a per-even
logging (`relay_log` — direction/leg/control id/type/size/outcome/ack code + a sanitized 500-char detail,
never a body; and `relay_capture`, whose `raw` column holds the **full message** and is written only when
`--capture-bodies` is passed). Its process logging is a bare `logging.basicConfig` to stderr with **none**
of the filters above. The **opt-in ADR 0087 sandbox worker** (`[sandbox].mode = "subprocess"`, default `"off"`) has the same shape *inside* the product: the child is spawned with `stderr=None`, i.e. it **inherits the engine's stderr** — stream 1's own sink — and configures itself with a bare `logging.basicConfig(stream=sys.stderr, level=WARNING)`, so a `WARNING`+ record emitted there by admin-authored Router/Handler code (or a library it pulls) lands in `service.err.log` **outside the filter chain**. The compensating controls are the sandbox's own `[egress]` / forbidden-import gates and the never-log-bodies rule. It is **out of scope for this section** and documented with the relay — but treat a
of the filters above. It is **out of scope for this section** — but treat a
`--capture-bodies` capture store as a PHI-at-rest location on the terms of
[§2](#2-where-phi-lives--data-at-rest-inventory).

The **opt-in ADR 0087 sandbox worker** (`[sandbox].mode = "subprocess"`, default `"off"`) is **not** an
exclusion. The child is spawned with `stderr=None`, so it **inherits the engine's stderr** — stream 1's
own sink — and it installs the three filters above on that stream itself, via `configure_stderr_logging`
(BACKLOG #1054). A `WARNING`+ record emitted in the child by admin-authored Router/Handler code, or by a
library it pulls, is therefore redacted and CR/LF-scrubbed on the same terms as an engine record.
Redaction is a property of the **handler**, so this is a second installation of the chain rather than
something the child inherits along with the file descriptor.

---

## 8. Retention & purge
Expand Down
19 changes: 16 additions & 3 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,15 @@ one place — [`api/field_authz.py`](../messagefoundry/api/field_authz.py) — a
`redact_unauthorized()` helper applied to every returned row, rather than re-implemented inline per
endpoint (where a new endpoint or field could silently leak PHI — the BOPLA risk, ASVS 8.1.2 / 8.2.3).

**The default for a mapped model denies.** Each of the six response models below is a `PhiGatedModel`
([`api/phi_gate.py`](../messagefoundry/api/phi_gate.py)) that withholds every gated property from JSON
until an authorization decision is recorded on the instance; `redact_unauthorized()` is what records
one, releasing exactly the properties the caller's permissions unlock. A route that never calls it
therefore returns `null` — a functional defect its author sees — rather than the whole model in the
clear. The gate is on JSON serialization, which is every path by which one of these models reaches a
client; a python-mode `model_dump()` stays ungated by design, because the engine composes
`MessageDetail` from a `MessageSummary` dump before any authorization decision exists.

**Read rules — one row per (response object, property).** This table is 1:1 with `PHI_FIELDS`: eleven
entries over six response models. Keying on the *object* (not just the property name) is what makes it
mechanically comparable to the map — a CI guard asserts set equality in **both** directions, so the
Expand Down Expand Up @@ -1024,9 +1033,13 @@ this gate can be forgotten (the previous claim here was overstated: the old pinn
load-bearing: keyed on names, `last_error` looked covered by `DeadLetterRow.last_error` on
`/dead-letters` while `OutboxInfo.last_error` had **zero** coverage, because the only message whose
outbox row carries a non-null `last_error` is the dead-lettered one and its detail route was not in
the surface list. It is now, and the coverage assertion is keyed on `(model, property)` pairs. This
is the only guard that catches a future PHI route shipped without its `redact_unauthorized` call —
which, given the fail-open default, no map-level test can.
the surface list. It is now, and the coverage assertion is keyed on `(model, property)` pairs.
- **The default is fail-closed** — `tests/test_field_authz_fail_closed.py` mounts a PHI-returning
route that *omits* the `redact_unauthorized` call and asserts the response carries `null` for every
gated property, each assertion paired with a released positive control. It also pins `PHI_FIELDS`
against each model's own `phi_gated_properties` in both directions, and proves class creation
refuses a gated name the serializer does not cover. The enumeration of call sites above keeps the
*shipped* surfaces honest; this is what makes the route nobody has written yet safe.

**Write side (engine → store).** Exception/disposition text is also scrubbed *before* it is stored: a
Router/Handler is user code that can `raise ValueError(f"...{raw}")`, so every value written to
Expand Down
18 changes: 15 additions & 3 deletions docs/SESSION-DRIFT-CONTROLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,21 @@ The gate's own docstring records that 29% of Edit/Write calls came from a sessio
that wrote *correctly* into a worktree by absolute path; a cwd-keyed gate would have denied all of them.
Rule 2 is the sole exception, and that exception is the source of the ultracode friction in §4.

**[`scripts/hooks/block-blanket-git-stage.ps1`](../scripts/hooks/block-blanket-git-stage.ps1)** (project
scope, [`.claude/settings.json`](../.claude/settings.json)) refuses blanket `git add -A`/`.`/`-u` and
`git commit -a`, so two sessions in one tree can't sweep each other's files into one commit.
**[`scripts/hooks/block-blanket-git-stage.ps1`](../scripts/hooks/block-blanket-git-stage.ps1)** refuses
blanket `git add -A`/`.`/`-u` and `git commit -a`, so two sessions in one tree can't sweep each other's
files into one commit.

**It does not travel, and this paragraph used to imply it did** (BACKLOG #327). The script is tracked,
but the `PreToolUse` matcher that invokes it is project-scope `.claude/settings.json` — untracked, under
`.gitignore`'s `/.claude/` rule — so a fresh clone and every `git worktree add` come up without it. It is
a local Claude Code session control, fail-open by design: real and useful inside a configured session,
and not repo-wide coverage. The publishing boundary it was cited alongside is asserted independently, by
[`tests/test_private_paths_stay_ignored.py`](../tests/test_private_paths_stay_ignored.py) in CI.

*(The link to that settings file was removed rather than repaired — it named a path no reader outside the
maintainer's own machine has. `scripts/docs/link_check.py` could not have caught it: `.claude/` is in
that script's `WITHHELD` set, so such an href is skipped before it is even counted. Measured 2026-08-10 —
an href to a missing non-withheld path fails the check, the same href under `.claude/` does not.)*

### Detection — `SessionStart` hooks

Expand Down
7 changes: 6 additions & 1 deletion docs/WORKTREE-GATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,12 @@ confirm every config dir shows hook entries.
Two structural choices worth understanding:

- **Every config dir, not just `~/.claude`.** The hook is registered into `~/.claude/settings.json` **and**
every `~/.claude-account-*/settings.json` (the VS Code launchers that set `CLAUDE_CONFIG_DIR`), mirroring
every `~/.claude-account-<N>/settings.json` (the VS Code launchers that set `CLAUDE_CONFIG_DIR`) — that
name shape **exactly**, anchored on a decimal N, because an unanchored `.claude-account-*` also matched
`~/.claude-account-2.lock`, a directory the installer then wrote gate wiring into on every run while the
wiring check read it back as evidence (BACKLOG #1024). `-Status` now enumerates `~/.claude*`
independently of that predicate, so its audit cannot agree with the writer by construction, and it names
any dir outside the wire set that still carries gate wiring. This mirrors
what `install-selfheal.ps1` already does. This is not a detail: the gate originally wired only
`~/.claude`, which left every account-N session **ungated** — and those are where the parallel VS Code
chats run. A session under an ungoverned account then checked its own branch out inside another session's
Expand Down
74 changes: 65 additions & 9 deletions docs/WORKTREES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,41 @@ Then work in it independently:
```powershell
cd ..\MessageFoundry-alerts
.\.venv\Scripts\Activate.ps1
# build / commit / push on branch 'alerts'; open a PR as usual
# build / commit; the FIRST push is:
git push -u origin alerts
# thereafter `git push`; open a PR as usual
```

### The first push needs `-u`, and that is the fix, not a rough edge

A new branch is created with **`--no-track`**, so it starts with **no upstream** and `git push -u`
is what gives it one — pointing at the branch's **own** remote ref, which is the only value that
answers the question anything keyed on `@{u}` is asking.

It used to inherit the **base**. `git worktree add <path> -b alerts origin/main` plus git's default
`branch.autoSetupMerge` set `@{u}` to `origin/main`, so **`@{u}..HEAD` reported a branch's own
commits as unpushed forever, including immediately after a successful push**. A session checking
whether its worktrees were safe to remove read **2 and 1 unpushed commits** on two branches that
were byte-identical to their remotes. The loud symptom — `git pull --ff-only` refusing — was the
harmless half; bare `git pull` did **not** fail, it merged `origin/main` in and produced a merge
commit. (BACKLOG #1087.)

> **Do not "fix" the extra `-u` with `git config push.default upstream`.** It is the first thing
> anyone reaches for and it is **strictly worse than the defect it appears to fix**: with the
> upstream pointing at `origin/main`, a bare `git push` writes the feature branch **onto `main`** —
> and push to `main` is not blocked server-side here. `push.default` being **unset** is what makes
> git use `simple`, which refuses when the upstream's name differs from the branch's. Measured under
> the old configuration, git's own remediation text read `git push origin HEAD:main`.
> `tests/test_worktree_new_no_track.py` scans every `.ps1` under `scripts/` and fails if one sets it.

**The flag only reaches worktrees created after it landed.** Every worktree made before it still
carries `@{u} = origin/main`, and nothing retroactively corrects them — so a reading taken in one of
those is still wrong. The same command fixes it, because `-u` overwrites an existing upstream:

```powershell
git push -u origin <branch> # pushed branch: point @{u} at its own remote ref
git branch --unset-upstream # never-pushed branch: leave it unresolvable
git rev-list --count '@{u}..HEAD' # confirm: 0, or a loud "no upstream configured"
```

Point a second Claude Code chat (or VS Code window / EDH) at that directory and the two sessions build
Expand All @@ -48,12 +82,19 @@ in parallel without touching each other's files.

## Remove one

Run from the **main** checkout (git can't remove the worktree you're standing in):
**Which copy you run decides which checkout it searches.** `remove.ps1` anchors on its own location,
not on your cwd, so run the copy that lives in the checkout the worktree was created **from** — which
is not necessarily the primary, because `new.ps1` anchors the same way and creates its worktree
beside *itself*. Invoked by absolute path it works from any cwd outside the worktree being removed
(git can't remove the worktree you're standing in). This page used to say "run from the main
checkout"; that was false for every worktree created from a linked one, and `new.ps1` now prints the
exact command with the root already filled in. (BACKLOG #1078.)

```powershell
scripts\worktree\remove.ps1 -Name alerts # refuses if there are uncommitted tracked changes
scripts\worktree\remove.ps1 -Name alerts -DeleteBranch
scripts\worktree\remove.ps1 -Name alerts -Force # discard uncommitted tracked changes too
pwsh -NoProfile -File <that-checkout>\scripts\worktree\remove.ps1 -Name alerts # from any cwd
```

The untracked `.venv` / `node_modules` are expected and removed automatically; only uncommitted
Expand All @@ -65,6 +106,12 @@ the two differ. It deletes losslessly: `git branch -d` first, and the forceful `
re-verifying at that moment that the branch has nothing beyond `origin/main`. A branch holding
unmerged commits is **kept** and named, with its tip printed so you can act on it deliberately.

`-RepoRoot <path>` points the script at a checkout other than its own. It exists so the most
destructive script in this directory can be **execution-tested**
([`tests/test_worktree_remove.py`](../tests/test_worktree_remove.py) drives it against a synthetic
repo); without it the only repository a test could reach was this one, so the branch-delete path was
covered by review alone. (BACKLOG #1037.)

## Prune the finished ones — `prune-merged.ps1`

Worktrees pile up. [`prune-merged.ps1`](../scripts/worktree/prune-merged.ps1) sweeps the finished
Expand Down Expand Up @@ -433,9 +480,11 @@ times the 10-minute `-MinIdleMinutes` default — while its process was alive an
consults the registry *and* mtime, and **refuses if either says live**, because nothing here can prove a
session is gone; only the positive answer is trustworthy.

**Creating a worktree is serialised.** `git worktree add -b <name> <base>` writes `.git/config`, so two
sessions creating worktrees at once race `.git/config.lock` — on Windows that surfaces as `could not
lock config file .git/config: File exists`, leaving orphaned branches behind. `new.ps1` wraps that call
**Creating a worktree is serialised.** Two sessions running `git worktree add` at once race
`.git/config.lock` — on Windows that surfaces as `could not lock config file .git/config: File
exists`, leaving orphaned branches behind. (That was measured while the add still wrote an upstream,
which is the write `--no-track` now removes; nobody has re-measured the race without it, so the lock
stays until someone does.) `new.ps1` wraps that call
in a cross-session mutex ([../scripts/coord/lock.ps1](../scripts/coord/lock.ps1)), which uses the same
atomic exclusive-create as `claim.ps1`. It **retries and never steals**: on timeout it fails loudly and
names the holder, because breaking a lock you cannot prove is abandoned re-opens the very race it exists
Expand Down Expand Up @@ -551,10 +600,17 @@ per session. It stays silent, and keeps its powder dry, when there's nobody to t
resume mints a new session id, so a 30-minute per-checkout cooldown suppresses the immediate re-announce.

**Expect about half the roster to be unreachable.** `presence.ps1` is authoritative for who **exists**;
`list_sessions` is authoritative only for who can be **messaged**, and the two disagree. Measured
2026-08-01: of 6 registry-LIVE peers, `list_sessions` reported `isRunning: true` for one. The hook cannot
call MCP and so cannot filter on that, which is why the cap is a budget of *delivered* messages the model
tops up past unreachable peers, rather than a candidate list the hook trims.
`list_sessions` is authoritative only for who can be **messaged**, and the two disagree. The cap is
therefore a budget of *delivered* messages the model tops up past unreachable peers, rather than a
candidate list the hook trims.

**Reachability is an exact `cwd` match and nothing else — never `isRunning`.** That flag means *"executing
a turn right now"*, so as a reachability test it reads **backwards**: `false` is an idle peer that answers,
`true` is one that queues. The field, its measurement and the cross-surface caveat are recorded once, in
[`scripts/coord/session-registry.ps1`](../scripts/coord/session-registry.ps1)'s header. The announce hook
instructed every session to skip on `isRunning: false` until **BACKLOG #1077**; an earlier reading of this
paragraph counted `isRunning: true` for 1 of 6 registry-LIVE peers and treated that as a reachability
rate, when it was a count of who happened to be mid-turn.

**State, receipts and the kill switch.** `<git-common-dir>/mefor-coord/announce/` holds one
`<session-id>.json` marker per session (delete it to force a re-announce), `receipts/<key>.tsv` — one
Expand Down
Loading
Loading