Two deployments, one codebase
This repository ran two deployments that share every line of service code and agree on almost nothing else. They are the clearest answer to what hookstack actually is: the graph is config, and two useful graphs look nothing alike.
Status, 2026-09-23. The alert deployment (deploy/shadow.yaml) was retired
at the operator’s decision. Its host now runs only the alerting platform it sat
behind, nothing of hookstack is left there, and its data is archived off the
host. Its configuration stays in this repository as the worked example of the
alert shape, and everything below about it is a record of how it ran. The work
deployment (deploy/work.yaml) runs on the operator’s own machine and is the
only one running.
deploy/shadow.yaml |
deploy/work.yaml |
|
|---|---|---|
| Carries | alerts from a monitoring platform | work signals from chat and a ticket tracker |
| Front door | one signed door the platform posts to | a timer, and the watcher’s own findings |
| Brains | one judge (two comparison arms ran until 2026-09-09) | none — the watcher already judged |
| Investigator | one, escalated by level | one, escalated by level AND kind |
| Destinations | one chat, cards with buttons | two chats, no buttons |
| Services | 3 containers + a bridge | 4 containers |
Neither needed a line of Python that the other did not.
The alert shape
platform ──► /hook/ww ──► to-judge ──► /hook/judge-notify ──┐
│
├──► to-me ── card, with buttons
└──► to-probe ──► investigator
└──► /hook/probe-notify ──► to-me
Two comparison arms ran here until 2026-09-09 and the fan-out that fed them is still the reason the route is shaped this way, so it is worth saying what they were for and what ended them. They judged the same traffic and disagreed in a ledger, which is labelled data at production rates. They had no return door, deliberately — an arm that answered back would be a second copy of the chain rather than a second opinion.
What ended them was reading their own numbers. hookjudge-b ran the same model
as the live judge and differed only in its price constants, so it was still
comparing a model with itself. hookjudge-c was the genuine cross-vendor arm
and did think differently — it upgraded severity where the live judge
downgrades — but it cost 25.7x the live judge for the same calls at 2.7x the
latency, and nothing read its verdicts. A second opinion nobody reads is a
subscription, not an instrument. Their ledgers are kept; restoring an arm means
a service block, a channel, and one name in the fan-out.
The quiet-wake-no filter stage drops a card when the judge said nobody needs
to act now. Measured the week it was added: 440 interruptions, 95% repeats.
The fold-repeats stage after it sends a card the judge did mark for a person
once per condition per window: an hour, widening to four for a condition that
keeps coming back. A recovery the person is waiting for always goes, and a
firing that comes back after a recovery card goes an hour later unless it ends
again. Replayed on the retired deployment’s ledger it sends 55% fewer cards. It
has not run live, because this shape runs nowhere today.
The work shape
timer ──► scan (plain code) ──► nothing new? the round ends here. No event,
│ no model, no bill — which is most rounds.
▼
/hook/watch-due ──► watcher reads findings, decides which are
│ worth interrupting a person for
▼
/hook/watch ──┬──► chat A (the signal)
│
(kind=task) ────┴──► planner ──► /hook/plan-notify ──► chat B
The scan is not the watcher. Reading a window of chat and tickets, comparing timestamps against a cursor and dropping fragments is procedure, and procedure written into a prompt is procedure a model can decline to follow — it declined once, posting a signal and advancing neither cursor. It is a script now, and the watcher is handed findings rather than instructions for finding them. The saving is not tokens: an empty round used to cost as much as a busy one, because the agent had to complete the whole scan to learn there was nothing in it.
The timer’s side of that is PATROL_PRESCAN in patrol-timer.sh, which is
generic — a prescan that exits quiet skips the round, and one that FAILS fires
anyway, carrying the failure. A prescan that broke and a prescan that found
nothing are the same silence from outside, and only one of them is a quiet day.
The cursor moves when the round is known to have landed (2026-10-19). The
scan used to write its new cursors before the model ran, so a round whose run
failed was never looked at again; a second deployment running a copy of the
scanner lost two rounds that way in one day. Now a round that fires stages its
cursors (and Jira’s state) and the next tick settles them against the node’s own
run record for the event the pipe numbered, which the timer writes down after
the POST: completed commits, failed, never-posted, never-routed or timed
out puts the cursors back and re-scans with a line saying so, a run still going
holds the tick rather than offering the same messages twice. After two replays
and an hour the first lost round’s content is skipped, named in the digest
until a round lands. Jira is not skipped: its lookback re-reports the span, and
a repeat is a nuisance where a miss is a failure.
Two chats rather than one, which is the whole reason this is not the alert shape: a signal (“someone assigned you this”) and a plan (“here is how it would be done”) are read at different moments. One bot would interleave a five-second notice with a five-minute investigation of the notice before it.
No judge. The watcher already decided what deserves attention, and a judge calibrated on alerts — severity keywords, recovery semantics, flap suppression — has no vocabulary for a colleague’s question.
The first real write
On 2026-09-30 the work shape made its first change with a write credential, end to end:
- A request came in through the watch door: a daily cleanup of application logs older than three days on the three app nodes of a test cluster.
- The planner, read-only, answered with a plan, the permission it lacked, and one question: when to run it. The operator answered in the card’s thread, 03:00 Asia/Shanghai, and pressed Act on this plan.
- The work node held a write credential of its own for the first time. It installed a cleanup script, a systemd service and a timer on each node through AWS Systems Manager, ran the cleanup once, and read every node back. About 74 GB came free across the three. Each timer was enabled with its next run at 03:00, and the files on the nodes matched its commits by hash.
- It returned the change as commits in its scratch clone, a 79-line diff, in four minutes for $0.05. The pipe’s board shows the request, the plan, the press and the work as one row.
The report also said what it could not do. There was no toolchain for a static check of the units, and its first command came back failed after the work was done, from a bug in its own check; a second command confirmed the result. It named the catch for a person: the nodes belong to a managed node group, so a replaced node comes up without the timer until the launch template carries it.
An earlier handoff the same day ran on the planner’s read-only credential. It could not run a command on its nodes, changed nothing, and said so. That is why the board grades a work report by what it changed rather than by the fact that it came back (the decision).
The credential was the operator’s own administrator profile, far wider than the
job needed. It was mounted for the run as a directory and two .env lines
(PROBE_WORK_AWS_DIR, PROBE_WORK_AWS_PROFILE) and taken off the node once the
result had been read back; the node’s posture check measured no write actions
afterwards. The next write credential is to be scoped to its job — and the run
above is the handoff path, not the allowlisted one: a plan a person acted on,
not a procedure the executor ran.
The executor’s gate files, since 2026-10-06. The write node also carries the
remediation executor’s two gate knobs (HOOKPROBE_REMEDIATION_ALLOWLIST,
HOOKPROBE_REMEDIATION_HIGH_RISK_ALLOWLIST), empty by default: wiring them arms
nothing, they make the operator’s files nameable at all. The files live in
hookprobe/deploy/operator/ (git-ignored; remediation-allowlist.example
beside them says how to write one) and are mounted read-only at
/etc/hookprobe/operator/ on probe-work alone — the write node, the one place
an approved procedure can change anything. A procedure parked there is
approved on the runner’s own card since 2026-10-08 — the pipe routes approve
by the door the card came in through, so the press goes back to probe-work —
or in its console (127.0.0.1:8090); the card offers the button only once the
runner’s allowlist has a rule. A scoped credential and one allowlisted line
complete the first real remediation; until both exist the knobs stay empty and
nothing runs.
Four decisions that differ, and why
dedup: off for alerts, on for work signals. In the alert shape the judge does the noise accounting, and two counters mean two ledgers and neither one true. In the work shape nothing else counts: the watcher dedups by TOPIC in its own state, which is a different question from “is this the identical signal again” — a crashed watcher re-running replays what it already sent.
Return doors: signed on both, since 2026-09-07. They used to differ — unsigned
on the server as “an in-network hop between two containers of one deployment”,
signed on the laptop where every process on this machine could otherwise post a
fabricated card into somebody’s chat. The server’s premise stopped holding the
day the compose network started carrying the platform’s observability containers
(Prometheus scraping, Alloy collecting): “in-network” no longer meant “ours”. Both
return doors now verify the same secret their brain signs with, and
scripts/deploy_preflight.py refuses a deploy whose .env would leave any door
unsigned — the baseline the compose comments described is enforced, not remembered.
Escalation gating: level alone, or level and kind. The investigator gates on
level and uses fields.kind only to pick which prompt it runs. So a work
deployment whose contract is “task AND high buys a plan” has to express the
kind half in its ROUTE TABLE — left as one fan-out, a note at high quietly
funds a plan nobody asked for.
Posture: read-only on both, plus one danger-only node on the work side.
Every investigator that faces an event door runs HOOKPROBE_BASH_GUARD=readonly
on both deployments, and since 2026-09-07 each one measures its mounted
credentials against that declaration at startup and refuses to start when they
are wider. The work deployment adds a node the alert deployment has no use for:
probe-work, running the same image under danger-only, reachable only through
the handoff a person clicks on a plan card. Its posture check records the
credentials’ blast radius instead of confirming a boundary. By default those
are the planner’s read-only credentials and the record is a list of zeros;
since 2026-09-30 the node can hold a write credential of its own
(PROBE_WORK_AWS_DIR, PROBE_WORK_AWS_PROFILE), and that list is what an
operator reads before approving a handoff. The alert deployment’s investigator
has no credentials mounted at all and says so (no-credentials). Since
2026-09-22 the handoff is also the first real use of the write posture: the
plan-approved door speaks kind: brief, its body is the instruction to carry
the plan out and commit locally, and probe-work mounts the plan’s tree
writable at /data/code (PROBE_WORK_CODE) — the one writable mount on any
node. The posture check measures credentials, not mounts, so what that variable
points at is a grant the operator makes by hand: a scratch copy first, the real
tree only after a run’s commits have been read back. A handoff quotes nothing;
it carries the plan’s work item, the hr-<id> the pipe minted for the request,
and the pipe’s journey and board gather the work run under that request by it.
The work run’s report says how many files its commit-backed diff changed and
how many gaps it named, so the board’s fix stage reads carried out, blocked or
failed rather than “a report came back”.
The chat’s tools are behind a gate, since 2026-09-30. The investigator’s
MCP allowlist is enforced where the agent asks for a tool and says nothing about
a socket. Measured that day: one plain HTTP request from inside probe-watch
reached the chat client’s own MCP port, with no credential, listed 34 tools —
20 of which write, including sending a message as the operator — and called one
the allowlist does not name. The watcher reads colleagues’ messages for a
living, so that is attacker-influenced text one injection away from posting as
the operator.
So the list became a place. The three probes now sit on a docker network with
internal: true: no route to the host, no route to the internet, no DNS for
either. Their way out is the egress proxy on its allowlist, as before, and their
way to the chat is mcp-gate (deploy/sidecars/gate.py), which holds the only
reach to the chat client and forwards a tools/call only when the tool’s name
is on the calling node’s list. The watcher now sees eight read tools, the
planner eleven, and every refusal is recorded in work-data/mcp-gate/calls.jsonl.
An internal network answers no published port, so the three consoles moved to
probe-ingress, a byte pump on both networks, at the same addresses as before.
Since 2026-10-06 the proxy, the gate and the ingress run in one container,
doors (deploy/sidecars/doors.py), that keeps the three names as aliases.
The watcher’s signature moved out of its reach, the same day.
post_watch_signal.py signs from a file, which is right on the operator’s
laptop and upside down inside probe-watch: the process deciding what to post
is an agent whose input is colleagues’ messages, and it could read the secret.
The door’s only check — “signed by something holding the secret” — then answered
yes to whatever an injected round chose to say, including a fabricated request
at high as a task, which buys a paid planner run and a card that reads as
real. the watch signer (deploy/sidecars/signer.py) holds the secret now. The watcher posts unsigned to
it; it signs a signal only when the signal names a conversation the scanner
offered that round, with a level and kind from a closed set, text cut to a
length and a ceiling per round. The refusals are in
work-data/watch-signer/signals.jsonl.
The pipe’s ledger is a bind mount, and only the container opens it.
work-data/hookrelay/hookrelay.db runs in WAL (hookrelay/store.py) on a
directory the desktop container runtime shares from the host, and WAL’s
shared-memory file is not coherent across that boundary: a sibling deployment
on this machine records it as a silent-corruption hazard and runs its databases
in rollback mode. Nothing on the host opens this one while the pipe runs, and
that is the rule rather than a habit. Read it with the container stopped and
the file opened immutably (?immutable=1); the integrity check of 2026-10-19
was clean at 446 events.
What a review of those two days found, fixed on 2026-10-05. The timer’s
contract-violation signal had signed from the file the watcher’s copy was
deleted from, so every violation since the redeploy failed to post with one
log line nobody read; the timer now signs from work-data/watch-timer-secrets/watch.env,
a directory only it mounts. The signer refused the scanner’s own fault notes
(a source that cannot be read was becoming a quiet round); the brief now posts
them as scanner / scanner-notes, which the signer admits without an offer and
forces to low/note. A deployment with no prescan hit a lifetime ceiling of
twenty signals; the ceiling now counts per twenty-minute window when the scan
states no round. The plan-approved door’s secret was never on the list of
secrets withheld from the agent, so the planner’s own Bash step inherited the
one credential that starts a work run with the write credential — it is
withheld now, which is what handoff.py had claimed all along. The gate and
the signer refuse a bearer with a non-ASCII byte instead of dropping the
connection, bound and validate the request length before reading it, report an
upstream that dies mid-answer as a 502 rather than a closed socket, pass the
client’s JSON-RPC responses through, and write forwarded only after the chat
server answered. probe_net sets the isolated gateway mode, so “no route to
the host” holds on a Linux engine and not only on this laptop; the probes’
MCP config, which is not tracked, has a tracked shape in
deploy/sidecars/mcp.example.json. The same day, second pass: the scanner
keeps the round before alongside the current offer, and the signer and the
watch wrapper admit a conversation from either, counted against its own round,
so a run that outlasts a twenty-minute tick is no longer checked against the
offer that replaced its own; and the producer half of an origin is a closed set
(the chat tool, Jira, the scanner), so a round cannot label a chat finding as a
Jira one. Third pass: the signer’s ledger is the record of what was reported.
Every signed signal carries the conversation and the cursor the scan offered it
at, written by a process the agent cannot reach, and the watch wrapper writes no
state at all. The next day the before/after contract check retired with it:
once the signer held the record, two of its three promises held by construction,
and the third is the one check the timer still runs each tick
(scripts/assert_watch_signed.py): a watch signal in the pipe with no signer
row went around the boundary.
The boards on a phone, since 2026-10-05. All three boards are web apps
(the judge’s and the investigator’s since 2026-10-06): a manifest, icons and a
service worker, linked with the page’s own base so a board behind a path
prefix installs under it; an investigator’s manifest carries its node’s name,
so the planner, the watcher and the work runner are three icons, not one. A
browser installs an app only over https, so a board has to be reachable from
the phone over https first — on a laptop, a tailnet (tailscale serve https /
http://127.0.0.1:8100) or a tunnel; the work stack’s board is on
127.0.0.1:8100, its three consoles on 127.0.0.1:8088–8090, and nothing
here opens them to the network. Then: open the address, paste the read token
in Settings (the app keeps it in that browser and nothing else), and Add to
Home Screen. The worker keeps the shell and never the data, so offline the app
shows the page and says its service is out of reach. The operator runs the
pipe’s board and the watcher’s console this way, through a tunnel of their own.
iOS keeps the status-bar style an icon was added with, so after an update that
changes it the icon has to be added again; each board’s settings drawer says so
when it finds itself under the old, opaque bar.
The chat is behind a protocol, not in the pipe. Both deployments reach
Feishu through a bridge channel and the lark-bridge sidecar: the pipe sends a
card model — title, tone, summary, links, actions as plain facts — and the
sidecar renders, posts as the app, and carries presses and replies back
(docs/bridge-protocol.md). Nothing in the pipe, the judge
or the investigator names the platform; another IM is another sidecar, tested
against the same fixture. The operator’s personal watch bot is a custom-bot
webhook, so it gets a second bridge in webhook mode (lark-bridge-watch):
same image, no app, the rendered card posted to the bot’s URL. The pipe has
no Feishu channel type any more; DingTalk and WeCom markdown are a shipped
plugin.
Follow-ups from the chat thread: alert deployment only, since 2026-09-08.
A person who replies under a verdict or report card in the group continues
that alert’s investigation: the bridge forwards the reply, signed, to the
pipe’s lark-thread door; the thread_lookup stage resolves the card’s
platform id against the ledger to the chain and its investigation session
(a reply under a card the pipe never sent is skipped as unknown_thread);
the investigator continues its own engine session under the same read-only
posture and answers back through probe-notify with thread_root, which the
to-me channel turns into an in-thread reply. Three things have to be true
on the host for this to work, none of them in this repository
(deploy/lark-bridge/README.md has the
full list). The bridge needs
a Lark app of its own: Lark allows one event long-connection per app, so an
app shared with any other consumer means one of them never receives anything
(2026-09-08: the bridge moved to a dedicated app for exactly this reason). That
app must be allowed to receive the group messages that mention it
(im:message.group_at_msg:readonly; im:message.p2p_msg:readonly is what the
CLI lists for the event) and to send as a bot; a reply therefore has to
@-mention the bot to reach the bridge, unless the operator also grants
im:message.group_msg:readonly, which reads every group message. The
im.message.receive_v1 event must be enabled in the developer console, and
the bot must be a member of the group — added by a person; no bot can add
itself and the old app lacked the scope to add another. Then .env:
LARK_THREAD_SECRET (the door and the bridge share it) and
HOOKPROBE_FOLLOW_UP_SENDERS (who may spend a turn from chat; empty refuses
everyone). Switching the bridge’s app means changing LARK_APP_ID /
LARK_APP_SECRET and recreating the container: the bridge keeps no state,
so a fresh one re-derives the CLI config from the environment. A bare
docker restart reuses the writable layer, where the old config still is, and
that is the case needing lark-cli config remove first. Check the new app
against the chat BEFORE cutting over: an app and a chat in different tenants
are refused outright (232010) and no membership change fixes it, so a new app
can force a new chat id as well.
The work deployment gets the same path through its bridge profile: a
lark-bridge of its own (a third Lark app — one connection per app, and the
alert deployment’s bridge holds the second), serving both of its chats
(LARK_WATCH_CHAT_ID, LARK_PLAN_CHAT_ID; the pipe names the chat per channel
with options.chat_id). Its two chat channels are type: bridge and post
through that bridge (LARK_WATCH_URL / LARK_PLAN_URL, channel secrets equal
to LARK_BRIDGE_SECRET); a reply is routed by
return_source to the node that holds the session — plan-notify to the
planner, work-notify to the work node. And a person can open a topic
instead of waiting for a card: a top-level @-mention in the group starts a work
item on this deployment (on_new_topic: {kind: task}) and an investigation on
the alert deployment ({kind: brief}: the message is the question); the first
card lands inside the topic and the conversation continues there.
Timer: host crontab, or a container. The work deployment ships its own,
after the host version could not read its own brief: macOS keeps ~/Documents
behind TCC and cron is not allowed through, so every fire logged Operation
not permitted while the pipe had no delivery to fail, the investigator sat
healthy and idle, and the chat stayed quiet. A watcher that was never triggered
and a quiet afternoon look identical from outside. patrol-timer.sh is the
general form.
Three kinds of event, one door
POST /hooks/event dispatches on fields.kind:
kind |
The question | Body cap |
|---|---|---|
| absent, or anything else | what broke — root cause, impact, remediation | 4 KB |
task |
how would this be done — what exists, what is missing, the steps | 4 KB |
brief |
the body IS the instruction: read it and do what it says | 16 KB |
brief exists because the other two presume a SUBJECT to analyse and wrap the
body in that framing. A scheduled procedure has neither shape, and the framing
fights it: given a brief and the alert wrapper, one run did the brief’s first
step, then the wrapper’s (“open the case files first”), called no MCP tool at
all, and answered with the brief’s silence token. A blend of two instructions.
Its larger cap is not generosity. Truncating an alert loses detail about one incident; truncating a procedure deletes STEPS, and the run then does most of a job and reports success.
What to read next
docs/containment.md— every boundary an agent runs behind, and in its own column, what each one does NOT stop.STACK.md— the local demo stack, and the two assertion sets: what the DIALECT requires versus what one implementation happens to do.deploy/shadow.yamlanddeploy/work.yaml— both carry their reasoning in comments, and where a comment and this page disagree, the config is right.