Skip to content

The page names what is blocked, and can now unblock it - #16

Merged
FlagshipDev merged 1 commit into
mainfrom
publish/2026-08-07-e32ac96
Aug 7, 2026
Merged

The page names what is blocked, and can now unblock it#16
FlagshipDev merged 1 commit into
mainfrom
publish/2026-08-07-e32ac96

Conversation

@FlagshipDev

Copy link
Copy Markdown
Contributor

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 itREADY 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

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
FlagshipDev enabled auto-merge (squash) August 7, 2026 09:40
Comment thread hullwork/main.py Dismissed
Comment thread hullwork/main.py Dismissed
@FlagshipDev
FlagshipDev merged commit d73dec3 into main Aug 7, 2026
4 checks passed
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.

2 participants