Skip to content

router: the Public Share refusal names which of your settings declined (#1201, #1252) - #1256

Merged
gen16k merged 2 commits into
mainfrom
fix/1201-public-share-refusal-names-its-reason
Sep 6, 2026
Merged

gen16k merged 2 commits into
mainfrom
fix/1201-public-share-refusal-names-its-reason

Conversation

@gen16k

@gen16k gen16k commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

A turn refused on the Waired public share entry told the operator the
opposite of what had happened. The /model picker hides the row when Public
Share is off, but the id still arrives — a session keeps whichever entry it
last picked, and the pre-#1185 spelling is still routed for exactly that
reason — and the refusal came back as:

No mesh peer is available (routing=public-share-only: no public machine is
reachable right now); local state="ready". Pick an Anthropic model in /model …

Nobody was unreachable. The operator's own posture was off, and while it is
off the grant acquirer releases every held grant, so the map holds no provider
and the reachability arm fires. Not vague — false.

Why it read that way

peerOnlyMissNote had three arms, and publicGateFor collapsed four
causes into one zero publicGate: policy unwired, mode off, never consented,
and the per-class Main/Sub toggles. All four produce auto == false, so
the arm that names the posture was unreachable for them, and they fell through
to the two arms that describe the world.

A fifth cause was invisible too: peers dropped by the consumer's own Public
Share size floor were not counted anywhere — publicShortfall.belowFloor
counts only the operator's Inputs.MinModelSize.

What this does

publicGate records which switch refused. Five explicit returns from
publicGateFor, with consent tested before mode: EffectiveMode already
folds "never consented" into "off" before the policy reaches the router, so a
mode-first order leaves that case permanently unnameable. The reason is taken
from the gate the attempt actually used rather than re-read from the policy —
the policy is republished under an atomic pointer while a selection runs, and
a waired public use --off landing in between would explain the refusal with
a posture that did not cause it. (Same hazard effectivePref avoids by
reading the routing preference once and handing it down.)

The settings arms come before the reachability arm. That ordering is the
defect. Reachability still comes before the auto comparison, which the old code
already had right: with nobody lending, no comparison ran, so "none was better"
would be equally untrue.

The consumer's size floor is its own shortfall. Two floors, two settings,
two commands — waired public use --min-model-size and
waired worker set --min-model-size. buildMeshCandidates now returns a named
meshDrops tally rather than a bare int, and the public count deliberately
does not feed the SizeFloorError wrapper, which names the operator's
command. It is also kept off publicGate, even though the pointer travels for
free: pinnedNodeCandidates shares that gate and discards its floor count, so
a gate-carried counter would silently absorb pin-pass drops.

The refusal leads with the entry that declined. The operator picked one row
in /model; no mesh peer is available … local state="ready" answered with
two Waired-internal facts about a machine the turn was never going to run on.
ModelNotReadyError gains PublicShare, and Error() a third arm on the same
discriminant it already branches on. local state stays everywhere else —
#828 put it there deliberately, on branches where it is the next fact an
operator reads.

The operator's routing floor stands aside on a public-only turn. It
disqualified this host's engine, which this route never intended to use, so
waired worker set --min-model-size is the wrong switch to send someone to.
Owner ruling 2026-09-06, narrowing #1128's floor-first order by this one case
and recorded in
docs/decisions/20260906/0410-the-public-entry-answers-for-its-own-refusal.md.

The wording reuses only vocabulary waired public status already prints
(Use public nodes, Consented, Smallest model accepted, Main agent,
Sub agents) and names the command that changes each setting, per owner
ruling 2026-09-06. Nothing new is coined.

Also fixes #1252 — the reason could be replaced by a retry

meshMiss stamps the error with this host's local model state, and
ModelIsArriving read nothing but that field. On the branches that refuse to
run here — peer-only, public-only, pinned — a host midway through a download
therefore answered every refusal with 503 overloaded_error +
Retry-After: 30. The client retries, the reason is never shown, and the
retries end only when an unrelated download does. That is #788's defect
reintroduced through a field that means something different on the branch that
set it, and it would have made this PR's wording invisible on exactly the hosts
most likely to hit it.

ModelNotReadyError.LocalArrivalAnswers now states whether local arrival is
evidence at all. False by default, on purpose: the inverted polarity would
have touched two production sites instead of six, but its zero value is the
unsafe direction, and a future never-consults-local branch that forgets the
flag would silently restore the ten-silent-503s failure. The safe direction's
worst case is a 400 for a genuinely downloading local model, which #1180
already ruled is an acceptable end for a turn.

ModelIsArriving has four readers and they move together (#740): the Anthropic
surface (503 → 400 fail-closed), the OpenAI surface (503 → 404
model_not_served), selectionErrorReason/selectionStatus, and management's
/explain dry run.

Tests

  • internal/router/public_refusal_note_test.go (new): publicGateFor's table
    including the consent-before-mode row; every arm of the reason function plus
    an assertion that no two causes produce the same sentence; the two arms
    that had no test at all before; the public floor counted as its own
    shortfall and not as the operator's; and the floor-ordering ruling.
  • The end-to-end refusal test runs each cause against two maps — grants
    released (what a posture-off host actually looks like, and the shape that
    produced the false sentence) and a grant still held (the propagation window).
    Checked by mutation: restoring the old arm order fails three subtests.
  • internal/router/model_not_ready_test.go: ModelIsArriving per constructor,
    and a SelectK-level pair where peer-preferred must still answer true —
    that row is the regression guard for the whole change.
  • internal/gateway/public_share_refusal_test.go (new): the body a client
    actually receives, asserted verbatim, plus the absence of Retry-After.
    Nothing pinned that before — the router tests stop at err.Error().
  • internal/gateway/selection_error_test.go gains the row where the same
    arriving state answers 404 because the branch never looked locally.

This PR inverts an existing assertion. public_only_test.go's
"posture off admits nothing at all" asserted only err != nil, which is how
the wrong sentence shipped; it is now a table over the causes, each of which
must name its own switch and must not say "reachable". Its sibling keeps its
two rows and gains a note that "the two ways" was a count taken before this.

Test-after rather than test-first: the branch table could not be written until
the measurement below established which arm the posture-off case actually
lands on.

Checks

go build ./..., go test ./..., gofmt -l,
golangci-lint run --max-same-issues=0 (cache cleaned first), the decision-log
guard, i18n-pair-guard.sh, and npm run i18n:check (32 pairs in sync).

Notes for reviewers

  • ModelNotReadyError is an exported type with exported fields. A
    construction outside this repository takes LocalArrivalAnswers: false and
    answers 400 where it used to answer 503 — the fail-closed direction, but
    worth a grep on the monorepo side.
  • Not changed, and stated as today's behaviour: /anthropic/v1/models still
    advertises waired/public unconditionally, including on a host with Public
    Share off. The picker drops it; the model list does not, because the
    intercept has to be able to route an id a client still holds. Routing and
    advertising are separate questions and the second one has other readers.
  • The pinned branch (routing=pinned) gets the router: a peer-only refusal borrows this host's download and answers 503 instead of saying why #1252 status fix but no reason
    table; its note stays the fixed string it was.

Fixes #1201
Fixes #1252
Refs waired-ai/waired#1313

🤖 Generated with Claude Code

https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm

gen16k and others added 2 commits September 6, 2026 18:22
peerOnlyMissNote had three arms and publicGateFor collapsed four causes
into one zero value, so a turn refused because the operator's own Public
Share posture is off came back as "no public machine is reachable right
now" — not vague but false, since the grant acquirer releases every held
grant while the posture is off.

publicGate now records WHICH switch refused (never consented / switched
off / main-agent turns / sub-agent turns), and the settings arms are
ordered ahead of the reachability arm. Consent is tested before mode
because EffectiveMode already folds "never consented" into "off" before
the policy reaches the router. The reason is taken from the gate the
attempt actually used rather than re-read from the policy, which is
republished under an atomic pointer while a selection runs.

The consumer's own Public Share floor is counted separately from the
operator's routing floor: two settings, two commands, and folding them
would send an operator to a switch they did not set. It deliberately does
not feed the SizeFloorError wrapper.

The refusal also leads with the entry that declined instead of with the
mesh. The person picked one /model row; "no mesh peer is available (...);
local state=..." answered with two Waired-internal facts about a machine
the turn was never going to run on.

Related: the operator's routing floor no longer answers for a public-only
turn at all. That floor disqualified this host's engine, which this route
was never going to use, so `waired worker set --min-model-size` is the
wrong switch to name — narrowing waired-agent#1128's floor-first order by
this one case.

Separately (waired-agent#1252), ModelIsArriving keyed on this host's local
model state even on branches that refuse to run here, so a host midway
through a download answered every peer-only, pinned and public refusal
with 503 + Retry-After and the reason never reached the client.
ModelNotReadyError now states whether local arrival is evidence at all,
false by default so a future branch that forgets cannot restore #788
silently.

Refs #1201, #1252

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm
Signed-off-by: gen16k <gen16k@users.noreply.github.com>
The page promised the entry names "which of the two reasons it was", and
the two it named were the two that are not about the operator's own
settings. It also implied the posture-off case could not arise, because
the picker leaves the row out — but a session keeps the entry it last
picked, and the pre-#1185 spelling is still routed.

troubleshooting gains two rows in the table that is already keyed on how
the message starts: one for the settings that decline, one for the two
world-state reasons that are not a fault.

Refs #1201

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJUUSmAfbRdjER1seDdjXm
Signed-off-by: gen16k <gen16k@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

📘 Docs preview — the preview channel for this PR has been deleted now that it is closed.

@gen16k
gen16k merged commit 6e09699 into main Sep 6, 2026
22 checks passed
@gen16k
gen16k deleted the fix/1201-public-share-refusal-names-its-reason branch September 6, 2026 09:37
gen16k added a commit that referenced this pull request Sep 6, 2026
… every page in both languages (#1264)

Restructures docs.waired.ai around the order a person actually meets Waired, and rewrites every page from zero in both languages against a saved corpus of human-written documentation (Tailscale and Discord for English; LINE Developers, SmartHR, and the JTF style guide for Japanese). Owner request of 2026-09-06.

## Structure

- 32 page pairs become 56, in eight sidebar groups that follow the journey: Get started → Install and set up → Use it from your tools → Models and routing → The Waired app and console → Fix a problem → How it works → Reference. Every group and item has a Japanese sidebar label (the ja sidebar was all English before).
- The four longest pages are split so a reader lands on the thing they came for: first-run becomes Sign in / Set up in the browser / Set up in the terminal / Servers and auth keys / Run setup again; troubleshooting becomes a hub plus six symptom pages; the CLI reference becomes a hub plus five command-group pages; the Claude Code guide gains How turns are routed / Subagents / The status line.
- New pages for things that had none: Meet the Waired app, How Waired chooses a model, Choose which computer answers (routing), Share this computer, Notices, The Status… report.
- URLs the product prints are unchanged (`/quickstart/`, `/public-share/`, `/reference/cli/`, `/reference/model-catalog/`, `/reference/install-options/`). Moved pages get 301s in both `astro.config.mjs` and `firebase.json`: `/getting-started/first-run/` → `/getting-started/sign-in/`, `/guides/models/` → `/guides/choose-a-model/`, `/guides/public-share/` → `/public-share/`, and their `/ja/` twins. The first two of those were already 404s on the live site.
- The `what-is-waired` figure is now HTML and CSS instead of an SVG, so it follows the site theme and is readable in both languages.

## Prose

- English: one voice (Tailscale's), the reader is "you", UI actions are *select* / *press* / *run*, UI labels are `**bold**`, product output is quoted verbatim, no dash chains or parentheticals.
- Japanese: 敬体, no space between Japanese and Latin characters (LINE style), [操作対象] / 「表示文字列」 / screen names without brackets, dictionary-form task headings. The 2026-09-06 owner decisions are recorded in `docs-site/TRANSLATION.md` §Register, next to the 106 existing term rulings, all of which are applied.
- Japanese paragraphs are written on one line. Browsers drop a soft line break between two Japanese characters but render one next to a Latin character as a space, so a wrapped source cannot keep the no-space rule. The rule and its reason are in TRANSLATION.md.
- Retired vocabulary is gone from both trees: the AI / your AI, graphics card, graphics memory, AI engine, speed check, under-spec, AI model, tray (outside the program name `waired-tray`), and the bare 「窓」 for context window.
- The owner-ruled public wording on what reaches Anthropic (waired#1313) is on the Claude Code guide and the privacy page, with the fail-closed decision cited.

## Kept in step with main

Three commits landed on `main` while this branch was open and are carried into the pages that now hold the text: Qwen3.8 Flash Next and the never-chosen Qwen3.5 122B (#1259, on How Waired chooses a model), the `none here` form of the local window line (#1258, on the Claude Code troubleshooting page and the coding-tools CLI reference), and the Public Share refusal wording (#1256, in the base).

Two in-flight PRs were coordinated with their sessions and are written into these pages ahead of them, so their rebases keep the text: the lighter variants of Qwen3.8 27B and Qwen3.6 35B-A3B and the catalog memory-column sentence (#1268, which lands first), and the app labels that the fix for #1269 introduces (`Sign in…`, `Pause Waired` / `Resume Waired`, and the `○ Paused` top line). The app's Disconnect and `waired pause` are one switch, so the pause page now has one section for it. Until #1269 lands the app still shows `Sign in...`, `Disconnect`, and `○ Disconnected`.

## Guards and tests

- New `scripts/ci/docs-links-check.mjs` resolves every root-relative link and `#anchor` in `dist/` and runs after the build in `deploy-docs.yml`. Astro only fails on a bad sidebar slug; body links to renamed pages built green and 404'd before.
- `i18n:report`: 56 pairs, 0 out of sync. `i18n-pair-guard` and `docs-surface-guard`: OK against main.
- Two Go test comments cite the pages that now hold the sentences they quote (`internal/router/reason_lines_test.go`, `internal/gui/tray/status_report_test.go`). No pinned string changed. `go test ./internal/router/ ./internal/gui/tray/` passes.

## Screenshots

Four of the five captures are real and included:

- `setup-wizard-progress.png` and `setup-wizard-progress-ja.png`, taken with Playwright against the development console during a real 1.9 GB download, masked in the DOM (device renamed to `my-desktop`, account chip and pre-release banner hidden).
- `app-ready.png` and `app-not-signed-in.png`, taken on a Mac at 2× in the dark appearance: the menu opened and read through System Events, captured on its own rectangle, and the account row repainted in the image so no real address ships. `CAPTURES.md` records the method.

`claude-code-statusline.png` stays a labelled placeholder until a release prints the `⚡` segment the docs quote (0.0.3-rc5 prints it without the prefix); tracked in #1270. Taking the signed-out capture found that the app's **Sign out…** on macOS signs nothing out; filed as #1269. The fix for it also changes the app's `Sign in...` label to `Sign in…`, so the docs quote that form; until it lands the app shows three periods.

## Why one PR

The plan allowed two. Get-started and install pages link forward to guide, troubleshooting, and reference pages that only exist in the new structure, so landing the first half alone would have shipped 404s. Review by group is still possible: each group is one sidebar section, and each commit is one slice.

Refs waired-ai/waired#1314
Fixes #1254

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019puN41reeo6o9gXkiRTbBr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant