Repository navigation
stack: close the two open doors, and make the headline numbers reproducible - #4
Conversation
…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
left a comment
There was a problem hiding this comment.
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
@sha256pins equal the OCI index digests GHCR serves for the0.6.0tags (multi-arch, so both CPU families resolve to tested bytes). - The bridge fail-closed change does not break the running deployment: its
.envalready setsLARK_BRIDGE_SECRET. The retired shadow stack is the only other user. The tests cover bothis_authentic*halves andmain(). - Coverage floors, the three
test_gate_matches_cicontracts and the workflows move together;assert_lockspasses. - 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
docs/blog/proving-the-boundaries-run.mdcalls 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 ofdocs/(or drop it) until it is a post.README.zh-CN.mdlost 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.docs/releasing.mdstep 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=enforceis spelled out in the deploy composes; it is onprobe-workand 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.lockedits are by hand and out of order (pytest-covbeforepytest-asyncio); the nextscripts/relock.shwill 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
- Bridge fail-closed: accepting it costs the running deployment nothing.
- 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"}) |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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)
| ``` | ||
|
|
||
| 演示会跑完整条闭环:告警被判定,一次录好的调查经过真实的只读门回放,报告变成卡片,按下批准后跑两条允许清单里的命令,告警恢复。要换成真实模型,在 `.env` 里设置 `HOOKPROBE_RUNTIME=claude`、`HOOKPROBE_MODEL` 和模型 key。 | ||
| ## 从这里开始 |
There was a problem hiding this comment.
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)
| 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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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)
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.
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)
BRIDGE_INBOUND_SECRET. It usedto accept every card, and
docs/containment.mdrecorded that as the shippeddefault. A deployment that genuinely has nobody untrusted on the wire now says
BRIDGE_ALLOW_UNSIGNED=1, out loud. The two deploy composes require the secretat compose time (
${LARK_BRIDGE_SECRET:?…}), whichseed_requiredinstack-smoke.shalready knows how to satisfy.security.replay_token+Store.signature_seenanswer the second copy of asigned 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_signaturerecords the signature only after the pipeline hasanswered, 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_SECONDSis the window (default 300; 0 restores theold 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=enforceis spelled out on the one node it decidesbehaviour for —
probe-work, the write node whose declared blast radius thecheck judges — and in the production compose. The planner and the watcher in
the work compose rely on the code default, which is already
enforce; onlythe 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 thewindow 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.mdper the rule that a reversal needs a note.
2. A lower cost of entry
README.mdwas a shop window with the whole catalogue behind it. It now opens onStart 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.mdis the single entry point; three of the four READMEimages moved off the first screen.
scripts/gen_front_pages.pygrew the fix this needed: a body link to arepository-root file (
STACK.md) was a dead relative link on the site frontpage. Root-level
.mdlinks are now rewritten to absolute repository URLs, thesame answer the tail already gave.
check-docs.pyis what found it.3. Evidence, not adjectives
docs/evidence.md+scripts/reproduce_claims.pysplit every headline numberinto 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 thegate's contract test enumerates it so it cannot drift.
4. A coverage floor, and a weight report
pytest --cov=<pkg> --cov-fail-under=<floor>, floorsset just below today's measurement (hookrelay 93→90, hookjudge 92→90,
hookprobe 88→85).
ci.yml,ci-hookjudge.yml,ci-hookprobe.ymland the threetest_gate_matches_cicontract tests move in lockstep — the repo's own testcaught me when they did not.
assert_weight.pyprints the heaviest modules on the passing line, not onlyon 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.CEILINGS['hookrelay']to 6,450 is required by §1: 6,400 for thedoors, 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.mdand theroot 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 thequickstart's images pinned by digest, not tag alone, so
docker compose upisreproducible. 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.hookrelay/tests/test_signature_replay.py(14),deploy/lark-bridge/tests/test_fail_closed.py(5).investigator's image is deliberately absent from that step.
scripts/stack-smoke.sh(compose upand a live stack).CI's
stackjob covers it.Reviewer asks
deliberate deployment choice rather than an oversight,
BRIDGE_ALLOW_UNSIGNED=1is the one-line way to keep 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 moretests, and the draft post moved from
docs/blog/toblog/, out of the Pagesroot),
7111245(both front pages keep the three runnable demo lines inlineunder "10 minutes";
docs/releasing.mddrops the GitHub Release step),d6dbff9(the hand-added lock pins sit wherescripts/relock.shwould put them,no version changes) and
1dfd449(the ceiling above). The original commit is notsplit, because that would rewrite pushed history.