Skip to content

stack: close the two open doors, and make the headline numbers reproducible - #4

Merged
itswl merged 5 commits into
mainfrom
fail-closed-doors-and-evidence
Oct 12, 2026
Merged

itswl merged 5 commits into
mainfrom
fail-closed-doors-and-evidence

Conversation

@itswl

@itswl itswl commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Five changes, four of them small and one that reverses a shipped default. Each
one is a thing the repository already said about itself, moved from prose into a
check, or a gap its own containment page had named and left open.

1. Fail-closed doors (behaviour change — please read)

  • The bridge refuses to start with an empty BRIDGE_INBOUND_SECRET. It used
    to accept every card, and docs/containment.md recorded that as the shipped
    default
    . A deployment that genuinely has nobody untrusted on the wire now says
    BRIDGE_ALLOW_UNSIGNED=1, out loud. The two deploy composes require the secret
    at compose time (${LARK_BRIDGE_SECRET:?…}), which seed_required in
    stack-smoke.sh already knows how to satisfy.
  • The pipe claims each inbound signature once.
    security.replay_token + Store.signature_seen answer the second copy of a
    signed request as a duplicate before the fuse and the pipeline, so a capture
    replayed inside the 300-second skew cannot buy a second verdict.
    Store.claim_signature records the signature only after the pipeline has
    answered, so a sender's identical retry after a 5xx, a timeout or the fuse's
    429 is still processed; two concurrent identical requests fall back to the
    content dedup.
    HOOKRELAY_SIGNATURE_REPLAY_SECONDS is the window (default 300; 0 restores the
    old bounded behaviour). This closes the hole in the old argument — "a replay
    mostly arrives as a duplicate" was true only inside the 120s content window;
    at t=200s it was a fresh judge call.
  • HOOKPROBE_POSTURE_CHECK=enforce is spelled out on the one node it decides
    behaviour for — probe-work, the write node whose declared blast radius the
    check judges — and in the production compose. The planner and the watcher in
    the work compose rely on the code default, which is already enforce; only
    the documents disagreed about that, so this states it where the claim is made.
  • docs/containment.md's signature row is rewritten to say which door closes the
    window and which only bounds it, and the "no nonce cache" sentence is gone
    because it is no longer true.

The reversal is recorded as
.agents/notes/implemented/2026-10-12-the-open-bridge-and-the-replay-window-close.md
per the rule that a reversal needs a note.

2. A lower cost of entry

README.md was a shop window with the whole catalogue behind it. It now opens on
Start here — 30 seconds / 10 minutes / half a day — and the feature list moved
to docs/what-you-get.md, screenshots to the tour that already had all of them.
New docs/quickstart.md is the single entry point; three of the four README
images moved off the first screen.

scripts/gen_front_pages.py grew the fix this needed: a body link to a
repository-root file (STACK.md) was a dead relative link on the site front
page. Root-level .md links are now rewritten to absolute repository URLs, the
same answer the tail already gave. check-docs.py is what found it.

3. Evidence, not adjectives

docs/evidence.md + scripts/reproduce_claims.py split every headline number
into two honest classes: reproducible here (the script runs the check and
fails if it has stopped holding) and reported (measured on the retired
deployments, the source data is gitignored, and the row names the artefact that
would back it). The script is wired into the root gate and ci-stack, and the
gate's contract test enumerates it so it cannot drift.

4. A coverage floor, and a weight report

  • Each service's gate runs pytest --cov=<pkg> --cov-fail-under=<floor>, floors
    set just below today's measurement (hookrelay 93→90, hookjudge 92→90,
    hookprobe 88→85). ci.yml, ci-hookjudge.yml, ci-hookprobe.yml and the three
    test_gate_matches_ci contract tests move in lockstep — the repo's own test
    caught me when they did not.
  • assert_weight.py prints the heaviest modules on the passing line, not only
    on failure: a headroom of +4 was invisible until the next feature went red. No
    per-module cap — that was already declined with a reason
    (2026-09-10-the-ceiling-stays-per-service.md), and printing is not capping.
  • Raising CEILINGS['hookrelay'] to 6,450 is required by §1: 6,400 for the
    doors, then +50 for the review fix that splits the signature check into a peek
    and a claim. The doctrine answer is in the file. hookrelay/README.md and the
    root README state the new number, and the front pages were regenerated.

5. Releasing

docs/releasing.md (tag + four images + the notes-are-the-notes rule) and the
quickstart's images pinned by digest, not tag alone, so docker compose up is
reproducible. Digests read from GHCR for 0.6.0.

What I verified, and what I did not

  • bash scripts/gate.sh: run, and the verdict read — STACK GATE GREEN.
  • Added tests: hookrelay/tests/test_signature_replay.py (14),
    deploy/lark-bridge/tests/test_fail_closed.py (5).
  • The docker image builds for hookrelay and hookjudge ran as part of the gate; the
    investigator's image is deliberately absent from that step.
  • Not run here: scripts/stack-smoke.sh (compose up and a live stack).
    CI's stack job covers it.

Reviewer asks

  1. §1 reverses a shipped default. The note argues it; if the old behaviour was a
    deliberate deployment choice rather than an oversight, BRIDGE_ALLOW_UNSIGNED=1
    is the one-line way to keep it.
  2. The ceiling raise is a product decision, taken because the change forced it.
    If the pipe should shrink instead, the honest move is to move the replay cache
    behind the store's existing helpers — say so and I will.

After the review

The review on this PR is addressed in four commits on top of the original one:
44d191f (a signature is claimed only after the pipeline answers, four more
tests, and the draft post moved from docs/blog/ to blog/, out of the Pages
root), 7111245 (both front pages keep the three runnable demo lines inline
under "10 minutes"; docs/releasing.md drops the GitHub Release step),
d6dbff9 (the hand-added lock pins sit where scripts/relock.sh would put them,
no version changes) and 1dfd449 (the ceiling above). The original commit is not
split, because that would rewrite pushed history.

…ucible

Two boundaries the containment page had named and left open are closed, and the
two things that kept a newcomer out — a front page that was a catalogue, and
numbers nobody could check — are fixed.

Security (a reversal of a shipped default; see the note in .agents/notes):
  * the bridge refuses to start with an empty BRIDGE_INBOUND_SECRET. The loud
    opt-out is BRIDGE_ALLOW_UNSIGNED=1, and the two deploy composes now require
    the secret at compose time.
  * the pipe claims each inbound signature once (security.replay_token +
    Store.claim_signature), so a capture replayed inside the 300-second skew is
    folded as a duplicate BEFORE the fuse and the pipeline — outside the 120s
    content window, that used to be a fresh judge call.
    HOOKRELAY_SIGNATURE_REPLAY_SECONDS, default 300; 0 restores the old bound.
  * HOOKPROBE_POSTURE_CHECK=enforce is spelled out in the deploy composes. The
    code already defaulted to enforce; only the documents disagreed.

Onboarding:
  * README opens on Start here (30 seconds / 10 minutes / half a day); the
    feature list moved to docs/what-you-get.md; docs/quickstart.md is the single
    entry point.
  * gen_front_pages rewrites body links to repo-root files into absolute URLs —
    without it, the new README put a dead relative link on the site front page,
    which check-docs.py then found.

Evidence:
  * docs/evidence.md + scripts/reproduce_claims.py split every headline number
    into reproducible-here (the script runs it and fails if it stopped holding)
    and reported (measured on the retired deployments, source data gitignored).
    Wired into the root gate and ci-stack, and enumerated by the gate contract
    test so it cannot drift.

Ware:
  * pytest --cov-fail-under per service (90 / 90 / 85), with ci.yml,
    ci-hookjudge.yml, ci-hookprobe.yml and the three test_gate_matches_ci
    contracts moving in lockstep.
  * assert_weight.py prints the heaviest modules on the PASSING line, not only
    on failure. The ceiling moves 6,300 -> 6,400 for the replay cache; the
    doctrine answer is beside it, and both READMEs state the new number.

Release:
  * docs/releasing.md, and the quickstart pins its images by digest as well as
    tag, so `docker compose up` is reproducible.

Verified: `bash scripts/gate.sh` green — all three component gates with their new
coverage floors, every stack check, and the docker image builds for hookrelay and
hookjudge. NOT run here: `scripts/stack-smoke.sh`, which needs `compose up` and a
live stack; CI's stack job covers it.

@itswl itswl left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Claude (Claude Fable 5.1, model id claude-fable-5-1), posted on the maintainer's behalf at their request. I read the whole diff, traced the inbound door's control flow on this branch, and checked the four digests against GHCR. The two calls at the end are the maintainer's, not mine.

Verdict: four of the five changes are ready. The replay cache has one defect that loses events on a retry and should be fixed before merge. Three items I would take out or adjust.

What checks out

  • The four @sha256 pins equal the OCI index digests GHCR serves for the 0.6.0 tags (multi-arch, so both CPU families resolve to tested bytes).
  • The bridge fail-closed change does not break the running deployment: its .env already sets LARK_BRIDGE_SECRET. The retired shadow stack is the only other user. The tests cover both is_authentic* halves and main().
  • Coverage floors, the three test_gate_matches_ci contracts and the workflows move together; assert_locks passes.
  • The ceiling raise is argued where the rule lives, and the heaviest-modules line on the passing output is a good addition.

Defect: the signature is spent before the pipeline and never released

hookrelay/hookrelay/app.py, the replayed_signature return. The row is inserted right after adapter.verify, before JSON parsing, the storm fuse and handle_hook. If handle_hook raises (a 5xx) or the client times out, every sender this pipe is built for retries the identical bytes, and the retry is now answered 200 {"outcome": "skipped", "skip_code": "replayed_signature"}. The event is lost and both sides believe it was handled. The old bounded window tolerated retries; this door does not. Same shape after the fuse's 429 reject: it stores nothing, and a retry inside 300 s folds instead of being processed once the storm passes.

Smallest fix: wrap the pipeline in try/except and delete the row on an exception (and on the 429 path), or check without claiming first and claim only after handle_hook returns. The race between two concurrent identical requests then degrades to the old behaviour, which is acceptable. Either way the ten tests want one more: a retry after a failed first attempt is processed.

Worth a sentence in the docstring: a body-only (legacy) signature of an identical body is identical, so a legitimate identical re-send between the content window (120 s) and the replay window (300 s) now folds; with timestamps it does not.

Take out or adjust

  1. docs/blog/proving-the-boundaries-run.md calls itself a DRAFT outline, and the PR description does not mention it. docs/ is the Pages root, so merging publishes a draft. Move it out of docs/ (or drop it) until it is a post.
  2. README.zh-CN.md lost the three runnable demo lines and the Chinese feature list, and the pages it now points to are English only. The English front page getting shorter is right; the maintainer asked for short front pages on 2026-09-30. Taking the only runnable commands off both front pages is a judgement I would reverse: keep the three lines inline under "10 minutes" and link the rest.
  3. docs/releasing.md step 5 asks for a GitHub Release object; this repository's practice is that the annotated tag's message is the release note and no Release objects exist. Change the practice deliberately or drop the step.

Small:

  • The description says HOOKPROBE_POSTURE_CHECK=enforce is spelled out in the deploy composes; it is on probe-work and the prod compose only. The planner and the watcher in the work compose still rely on the code default, which is fine, but say which.
  • The three requirements.lock edits are by hand and out of order (pytest-cov before pytest-asyncio); the next scripts/relock.sh will reorder them.
  • One commit for five concerns is hard to revert selectively. The behaviour reversal (bridge + replay) would be easier to review and to roll back as its own commit.

The two calls the PR asks for

  1. Bridge fail-closed: accepting it costs the running deployment nothing.
  2. Ceiling 6,300 to 6,400: the reasoning holds (door code, not judgement code), and the alternative offered would not save much.

Merge locally with --no-ff, not the button.

if token is not None and not await app.state.store.claim_signature(
token, app_settings.signature_replay_seconds, now_ts()
):
return JSONResponse({"outcome": "skipped", "skip_code": "replayed_signature"})

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claimed before the pipeline and never released: a retry of the same bytes after a 5xx, a client timeout, or the fuse's 429 is answered as a replay and the event is lost while both sides believe it was handled. Release the row on an exception (and on the 429 path), or check first and claim only after handle_hook returns. See the review summary. — Claude (claude-fable-5-1)

@@ -0,0 +1,60 @@
# DRAFT — Proving the boundaries actually run

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A self-described DRAFT under docs/ is published by Pages on merge, and the PR description does not mention this file. Move it out of docs/ or drop it until it is a post. — Claude (claude-fable-5-1)

Comment thread README.zh-CN.md
```

演示会跑完整条闭环:告警被判定,一次录好的调查经过真实的只读门回放,报告变成卡片,按下批准后跑两条允许清单里的命令,告警恢复。要换成真实模型,在 `.env` 里设置 `HOOKPROBE_RUNTIME=claude`、`HOOKPROBE_MODEL` 和模型 key。
## 从这里开始

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The three runnable demo lines and the Chinese feature list left this page, and the targets are English only. Short is right; losing the only runnable commands from both front pages is not. Keep the three lines inline under “10 分钟” and link the rest. — Claude (claude-fable-5-1)

Comment thread docs/releasing.md Outdated
4. **Re-run the quickstart the way a stranger does** — `curl` the published
compose, `docker compose up`, `bash scripts/demo.sh`. The file's own comment
records three releases that were wrong until somebody actually ran it.
5. **A GitHub Release** whose body is the notes since the last tag, linking the

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repository's practice is the annotated tag's message as the release note, with no GitHub Release objects (0.5.0 and 0.6.0 were released that way). Either change the practice deliberately here or drop this step. — Claude (claude-fable-5-1)

return constant_time_eq(expected, provided)


def replay_token(source_name: str, signature: str | None) -> str | None:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth one sentence here: a body-only (legacy) signature of an identical body is identical, so a legitimate identical re-send between the content window (120 s) and the replay window (300 s) now folds; with a timestamp it does not. — Claude (claude-fable-5-1)

itswl added 4 commits October 12, 2026 23:36
The replay cache wrote its row immediately after `adapter.verify`, before JSON
parsing, the storm fuse and `handle_hook`. A retry of identical bytes after a 5xx,
a client timeout, or the fuse's 429 was then answered as a replay, and the event
was lost while both sides believed it had been handled — the bounded window this
replaced had tolerated exactly those retries, and the fuse's reject stores nothing
at all.

The door now PEEKS (`Store.signature_seen`) before the pipeline and CLAIMS
(`Store.claim_signature`) only once the pipeline has returned, so a failed attempt
leaves nothing behind for the retry to trip over. The cost is the race between two
concurrent identical requests, which degrades to the content dedup — the behaviour
before this cache existed, and acceptable.

Tests: a retry after a 5xx and a retry after a 429 are both processed, the peek
has no side effect of the claim, and the success path still folds. The docstring
names the legacy body-only case: an identical body carries an identical token, so
a legitimate identical re-send between the 120s content window and the 300s replay
window now folds, where a timestamped signature would not.
… Pages

Three adjustments from review:

  * the three demo commands come back INLINE under "10 minutes" / "10 分钟" on
    both front pages. Short front pages are right; taking the only runnable
    commands off both of them is not, and the pages they pointed at are English.
  * the blog outline was a self-described DRAFT under `docs/`, which is the Pages
    root, so merging would publish it. It moves to `blog/`, content unchanged,
    until it is a post.
  * docs/releasing.md loses the step that asked for a GitHub Release object. This
    repository releases with an annotated tag whose message is the note and has no
    Release objects (0.5.0 and 0.6.0 shipped that way); the page now says so.
`pytest-cov` sat before `pytest-asyncio` in two locks, and the next
`scripts/relock.sh` would reorder them. Position only — no version moves. The
investigator's lock has no `pytest-asyncio`, so its order was already right.
… fix

Splitting the replay cache into a read before the pipeline and a claim after it
answers is thirty-odd source lines the ceiling set earlier in this branch does not
have. Raising is the sanctioned path; trimming the docstring that explains WHY a
peek and a claim are two calls is not, so this adds a second entry for the same
day with its split (+~30 source, +~8 code) and the same doctrine answer as the
entry above — the door's business, no payload read, no judgment about an alert.

hookrelay/README.md and both front pages state the new number.
@itswl
itswl merged commit 7c90824 into main Oct 12, 2026
8 checks passed
@itswl
itswl deleted the fail-closed-doors-and-evidence branch October 12, 2026 16:08
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