The page names what is blocked, and can now unblock it - #16
Merged
Conversation
An operator opened the read-only page and read it out loud: *"Waiting on you 2, oldest 21h — and now
what? Queued 1 — and how do we see that item's actual state? … how to act is not defined, it is all
commands, right?"* Three complaints, each true for a different reason.
**What already existed and he did not find.** The item view says state, lane *and the reason for that
lane*, kind, occurrence counts, the forge issue, the tracker permalink, and for each attempt the
artefact it published. It was one click away — but **the board's counts were not links**, so a number
led nowhere. Answering "which two" meant leaving the page, opening the list and scanning 28 rows.
The counts are links now, and a count of zero deliberately is not: a link that lands on "nothing
here" teaches a reader the page is broken.
**Where the page hedged and the state did not.** On an amber item it printed *"Either this item is
waiting for the dispatcher, or its lane says a human takes it"* — on an item whose state answers that
exactly. Every state now says what it is waiting for, in the second person, in one sentence.
**And an item can be permanently unreachable without the page mentioning it.** Disable a project and
its `ready` items stay `ready`: `work.py` selects on `Project.active`, so the board goes on counting
them under *Queued* with an age that climbs and nothing to drain it. That is now said where it is
displayed.
## The decision this reverses
`approve` carried its own argument against being an endpoint: *"the operator already has the host,
and an approval endpoint would be a permanent attack surface for something done by one person a
handful of times."* That was right when it was written and the ground moved under it — "already has
the host" now means ssh to a VPS, `docker compose exec` and a CLI, from a page read on a laptop. The
measured cost: two items waiting **21 hours** on an instance somebody was actively looking at.
The attack surface it warned about is real, so most of this change is about not widening it.
**The read link gains no authority whatsoever.** `…/page/<token>/` is a bearer credential in a URL — a
saved page, a screenshot, a link mailed to a colleague — and it keeps reading everything and can spend
nothing. Every mutation requires a second credential that never appears in a URL: `hullwork
operator-key` prints 32 random bytes once, stores a SHA-256 hash, and the key is pasted into a form
and exchanged for a session. Against 32 random bytes a KDF buys nothing, which is the argument already
written beside the page token; a human-chosen password would have needed scrypt, a new dependency and
a guessing-rate story.
**With no operator key configured, nothing changes.** The page renders what it rendered before and
every mutating route answers `404` — the same `404` an unknown path gets, so probing cannot learn
whether an instance can be acted on at all.
**Sessions are rows, not signatures**, so revoking is deleting and `--rotate` ends every session at
once. The cookie is `HttpOnly`, `SameSite=Strict`, and `Secure` **read off the request scheme**:
hardcoding it on would silently break a plain-HTTP deployment behind a VPN, hardcoding it off would be
wrong the day a TLS proxy arrives. CSRF is checked in addition to `SameSite`, and mutations are `POST`
only — a `GET` that approves is a URL that approves, from an image tag or a chat unfurl.
## The two buttons, decided by the state machine
`LEGAL[WAITING_APPROVAL]` is `{READY, HUMAN_ONLY, DONE}`, and **`REJECTED` is not reachable from
there**: that state means a reviewer closed a pull request and it feeds `counted.rejected` keyed by
the label they used. So the buttons are *let the agent try it* → `READY` and *I will take this one* →
`HUMAN_ONLY`. Calling the second "reject" would have filed a decision about **whether to attempt**
into the tally that counts **review** decisions, and the number would have drifted with nobody able
to see why.
`approve()` moved out of `cli.py`: a route importing a command from there is the shape item 162 spent
an item removing from `sandbox/`. Both callers import it from a module that owns neither.
## Measured
`ruff` and `mypy` clean on 193 files, suite green, **22 new tests** — each one a sentence of the threat
model rather than a click.
Two refusals worth keeping. **The upstream payload's operation enumeration rejected the new
subcommand** until it was declared, which is the closed-enumeration rule catching a new door the day
it was cut. And **the test asserting `GET`-only under the page prefix failed** — correctly, since it
asserted the decision being reversed. It was not deleted: the invariant it protected was *no
accidental mutation surface*, so it now names the four routes that may take a `POST` and fails on a
fifth.
No new dependency in the receiver, either: FastAPI's `Form()` wants `python-multipart`, and the forms
here post urlencoded, which the standard library has always parsed — length-capped before the body is
read, the way the webhook endpoint does it.
Signed-off-by: Javier Miralles Rancaño <68760931+FlagshipDev@users.noreply.github.com>
FlagshipDev
enabled auto-merge (squash)
August 7, 2026 09:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An operator opened the read-only page and read it out loud: "Waiting on you 2, oldest 21h — and now
what? Queued 1 — and how do we see that item's actual state? … how to act is not defined, it is all
commands, right?" Three complaints, each true for a different reason.
What already existed and he did not find. The item view says state, lane and the reason for that
lane, kind, occurrence counts, the forge issue, the tracker permalink, and for each attempt the
artefact it published. It was one click away — but the board's counts were not links, so a number
led nowhere. Answering "which two" meant leaving the page, opening the list and scanning 28 rows.
The counts are links now, and a count of zero deliberately is not: a link that lands on "nothing
here" teaches a reader the page is broken.
Where the page hedged and the state did not. On an amber item it printed "Either this item is
waiting for the dispatcher, or its lane says a human takes it" — on an item whose state answers that
exactly. Every state now says what it is waiting for, in the second person, in one sentence.
And an item can be permanently unreachable without the page mentioning it. Disable a project and
its
readyitems stayready:work.pyselects onProject.active, so the board goes on countingthem under Queued with an age that climbs and nothing to drain it. That is now said where it is
displayed.
The decision this reverses
approvecarried its own argument against being an endpoint: "the operator already has the host,and an approval endpoint would be a permanent attack surface for something done by one person a
handful of times." That was right when it was written and the ground moved under it — "already has
the host" now means ssh to a VPS,
docker compose execand a CLI, from a page read on a laptop. Themeasured cost: two items waiting 21 hours on an instance somebody was actively looking at.
The attack surface it warned about is real, so most of this change is about not widening it.
The read link gains no authority whatsoever.
…/page/<token>/is a bearer credential in a URL — asaved page, a screenshot, a link mailed to a colleague — and it keeps reading everything and can spend
nothing. Every mutation requires a second credential that never appears in a URL:
hullwork operator-keyprints 32 random bytes once, stores a SHA-256 hash, and the key is pasted into a formand exchanged for a session. Against 32 random bytes a KDF buys nothing, which is the argument already
written beside the page token; a human-chosen password would have needed scrypt, a new dependency and
a guessing-rate story.
With no operator key configured, nothing changes. The page renders what it rendered before and
every mutating route answers
404— the same404an unknown path gets, so probing cannot learnwhether an instance can be acted on at all.
Sessions are rows, not signatures, so revoking is deleting and
--rotateends every session atonce. The cookie is
HttpOnly,SameSite=Strict, andSecureread off the request scheme:hardcoding it on would silently break a plain-HTTP deployment behind a VPN, hardcoding it off would be
wrong the day a TLS proxy arrives. CSRF is checked in addition to
SameSite, and mutations arePOSTonly — a
GETthat approves is a URL that approves, from an image tag or a chat unfurl.The two buttons, decided by the state machine
LEGAL[WAITING_APPROVAL]is{READY, HUMAN_ONLY, DONE}, andREJECTEDis not reachable fromthere: that state means a reviewer closed a pull request and it feeds
counted.rejectedkeyed bythe label they used. So the buttons are let the agent try it →
READYand I will take this one →HUMAN_ONLY. Calling the second "reject" would have filed a decision about whether to attemptinto the tally that counts review decisions, and the number would have drifted with nobody able
to see why.
approve()moved out ofcli.py: a route importing a command from there is the shape item 162 spentan item removing from
sandbox/. Both callers import it from a module that owns neither.Measured
ruffandmypyclean on 193 files, suite green, 22 new tests — each one a sentence of the threatmodel rather than a click.
Two refusals worth keeping. The upstream payload's operation enumeration rejected the new
subcommand until it was declared, which is the closed-enumeration rule catching a new door the day
it was cut. And the test asserting
GET-only under the page prefix failed — correctly, since itasserted the decision being reversed. It was not deleted: the invariant it protected was no
accidental mutation surface, so it now names the four routes that may take a
POSTand fails on afifth.
No new dependency in the receiver, either: FastAPI's
Form()wantspython-multipart, and the formshere post urlencoded, which the standard library has always parsed — length-capped before the body is
read, the way the webhook endpoint does it.
Signed-off-by: Javier Miralles Rancaño 68760931+FlagshipDev@users.noreply.github.com