Skip to content

feat(attention): GET /api/attention/summary + summary-only token scope - #385

Merged
ZacxDev merged 3 commits into
mainfrom
feat/attention-summary-api
Nov 1, 2026
Merged

ZacxDev merged 3 commits into
mainfrom
feat/attention-summary-api

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

What

  1. GET /api/attention/summary: what a status bar polls. It takes a personal token only (requireAPIToken) and is owner-scoped.
  2. Token scopes (migration 0068): full (the default; every existing token) or summary. A summary token reaches GET (and HEAD) /api/attention/summary and nothing else.

Reworked per the round-0 answers:

  • Q1: a summary-only scope replaces "read-only = every GET".
  • D1: no CLI verb; the bar GETs the endpoint over HTTPS.
  • D2: url only; the path field is gone.

Why

The bar needs one cheap, owner-scoped read of "what is waiting on me", plus a credential on disk that can do nothing but that read.

How

The summary

  • "Waiting" means exactly s.attentionItems(ctx). It is the same function the Asks page, the badge and attention_list use, and there is no second predicate. Busy and idle are context only.
  • Item text follows the push rule.
    • title is textplain.Title(Source).
    • why is the first line of attentionPushBody. Secrets are redacted, a session is summary only, and a draft is who and where only.
    • It never includes Detail, options or suggestions.
    • A reply-draft item now carries its draftPushBody, and attentionPushBody returns it for plugin items, so the summary never quotes an inbound message.
  • url is MUSTER_PUBLIC_URL + the tap route /attention/open/<key> for an ask or a session, or + the item's own link for a draft or a task. It is "" when MUSTER_PUBLIC_URL is unset. There are no per-item lookups (no ByAsk, no agent read, no focus read), so the cost is about one badge render.
  • Hosts. Each host has online, status_report, and busy/idle counts of Claude (or agent-runtime) windows. An offline host counts 0/0.
  • Global fields. nudge_mode is off, or chiefNudgeMode(owner) when chief nudges are on. ts is server time. partial is set when a queue source failed.

Scopes

  • The check. One allowlist, tokenScopeRoutes (scope → mux patterns, matched on http.Request.Pattern), is checked in the single admit path admitAPIToken. Every token wrapper goes through that path, including the hook-tier door at auth.go:438. The current table is summary → {"GET /api/attention/summary"}; net/http serves HEAD through the GET pattern.
  • Fails closed. Only full is unlimited. An unknown or blank scope reaches nothing, and a request with no matched pattern is refused to every scope but full. A route added later is refused to a summary token until someone adds it to the table.
  • GET /api/whoami is denied to a summary token, on purpose. A bar doesn't need it, and the summary's own 200/401/403 already tells you whether the token works. There was no strong reason to allow it.
  • Minting. A radio choice: "Full access" (checked by default) or "Bar summary only (can read the Attention summary and nothing else)". The token page, GET /api/tokens (muster token ls, "scope") and whoami show the scope. An unknown scope at mint is a 400, and api_tokens_scope_check backs that up in the database.
  • Rollback. An older image would admit a summary token as full access, so the migration header says to revoke all non-full tokens first.

Contract (docs/attention.md#the-summary-api)

{"ts":1800000000,"nudge_mode":"off","partial":false,"hosts":[{"host":"host-a","online":true,"status_report":true,"busy":1,"idle":0}],"items":[{"key":"ask-5","kind":"ask","host":"","title":"chief","why":"Ship it?","since":1799999940,"age_secs":60,"offline":false,"url":"https://muster.example.test/attention/open/ask-5"}]}
  • kind is one of ask, session, question, plugin, task.
  • nudge_mode is one of off, shadow, live.
  • hosts and items are never null.
  • url is "" when MUSTER_PUBLIC_URL is unset.
  • Fields may be added; none is renamed or removed. The whole string is pinned by TestTheSummaryDocumentIsTheDocumentedShape.

Tests

  • Red at origin/main: the new tests do not compile (undefined: ScopeFull, undefined: attentionSummary).
  • Green at HEAD: go test -race passed under scripts/with-test-pg.sh (DB required) for internal/api, internal/apitokens, internal/db and cmd/muster.
  • Green on the merged tree with current main, which now includes fix(chief-nudges): the generic ask shows the chief's read #384: internal/chiefnudge was added to the same run, and all passed.
  • Other gates: go build and go vet pass, the leakscan is clean, and make verb-ledger reports 23/23.

Scope tests:

  • TestABarSummaryTokenReachesTheSummaryAndNothingElse: GET/HEAD summary → 200. A summary token gets 403 with the scope sentence before the handler runs (store List/Revoke never called) on these 12 routes:

    • GET /api/agents/chief/messages
    • GET /api/agents
    • GET /api/tasks
    • GET /api/tasks/1
    • GET /api/flows/jobs
    • GET /api/whoami
    • GET /api/tokens
    • DELETE /api/tokens/1
    • POST /api/tasks
    • PATCH /api/tasks/1/status
    • POST /api/flows/jobs
    • POST /api/agents/chief/messages

    The same requests with a full token are not refused, and its List and Revoke reach the store.

  • TestATokenOfAnUnknownScopeReachesNothing: "", admin and Summary are refused everywhere, the summary included.

  • TestTheScopeAllowlistIsExactlyTheSummaryRoute: pins the whole table, and checks that its pattern is in routes.golden.

  • TestTheMintPageOffersABarSummaryToken and TestAMintNamingAnUnknownScopeIsABadRequest.

  • TestATokensScopeIsStoredAndReadBack (PG): checks the record, list and Authenticate; that an unknown scope is refused before any write; the DB CHECK; the audit scope; and NOT NULL DEFAULT 'full'.

  • TestABarSummaryTokenMintedOnThePageReadsTheSummaryAndNothingElse (PG): the end-to-end loop. A summary token gets the summary, and is refused on GET /api/tasks, GET /api/whoami and revoking itself, after which it is still live. A full token minted on the same page reads /api/tasks.

Mutation battery (detached copy, real Postgres). The unmutated control was 20 PASS, 0 FAIL, 0 SKIP. "Killed" means every named test failed, counted from --- FAIL lines.

mutant killed by
allowlist dropped (tokenScopeAdmits always true) TestABarSummaryTokenReachesTheSummaryAndNothingElse, TestATokenOfAnUnknownScopeReachesNothing, PG loop
allowlist widened to every GET for summary TestABarSummaryTokenReachesTheSummaryAndNothingElse, PG loop
table gains GET /api/whoami the same 2 + TestTheScopeAllowlistIsExactlyTheSummaryRoute
door check removed 3 tests
unknown scope treated as full TestATokenOfAnUnknownScopeReachesNothing
summary pattern misspelled (bar locked out) 3 tests
store insert ignores the scope / Authenticate drops it TestATokensScopeIsStoredAndReadBack + PG loop
store accepts an unknown scope TestATokensScopeIsStoredAndReadBack
page default flips off "Full access" / page ignores the choice TestTheMintPageOffersABarSummaryToken (+ PG loop)
owner scope dropped TestTheSummaryIsTheOwnersQueueAndHosts, isolation walk TestNoRouteLetsOneUserReachAnothersData
why = raw Text TestTheSummaryItemSaysWhatItsPushSays, TestTheSummaryRedactsExactlyAsThePushDoes, PG test
tap route dropped from url 3 tests
url built without a public origin TestTheSummaryItemSaysWhatItsPushSays

The first round of this battery is in the earlier version of this description; those summary-shape mutants are unchanged.

Two harness defects, found and fixed before these numbers:

  • A -run filter (Scope|UnknownScope) did not match TestABarSummaryToken…, so those tests never ran and the mutants read as SURVIVED.
  • With the door broken, an unwired handler panicked and took the test binary down. The refused-route loops now count a handler panic as "admitted".

Not verified: nothing has been deployed. The endpoint and the scope have not been exercised against the live server or a real bar.

🤖 Generated with Claude Code

…uster attention summary`

What a status bar polls, and a credential it can hold safely.

GET /api/attention/summary (personal token only, requireAPIToken): every
item waiting on the token's owner — exactly s.attentionItems, no second
"waiting" rule — each with its host, title (textplain.Title of its source)
and why (the first line of attentionPushBody: secrets redacted, a waiting
session summary only, a reply draft who-and-where only), age, and its link
(the tap route /attention/open/<key> for an ask or a session, the item's own
page otherwise; absolute on MUSTER_PUBLIC_URL). Per host: online,
status_report, busy and idle Claude windows (an offline host counts
nothing). Global: nudge_mode (off | shadow | live for this owner), ts, and
partial when a queue source could not be read. No per-item lookups, no
stream. Contract in docs/attention.md#the-summary-api.

A reply draft's item now carries its draftPushBody, and attentionPushBody
returns it for a plugin item, so "the push body" is defined for every kind
and the summary never quotes an inbound message.

Read-only personal tokens (migration 0068, api_tokens.read_only default
false): minted with a checkbox on /account/tokens (unchecked = full, as
before); the list, /api/tokens and whoami show the scope. The check is at the
one token door (admitAPIToken), by an allowlist of METHODS (GET, HEAD): any
other method on any route a personal token reaches is 403 before its handler
runs. Existing tokens stay full access. Rollback note in the migration.

CLI: `muster attention summary` (verb 24; ledgers moved), JSON passthrough,
existing exit codes (401/403 -> 3). A poller keeps its own token in its own
file via the existing --env-file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ZacxDev

ZacxDev commented Nov 1, 2026

Copy link
Copy Markdown
Owner Author

Audit round 0 (requirements & deletion) at 8b7d9968. Verdict: requirement questioned (Q1), with deletion candidates D1 and D2.

The audit found 15 requirements, 5 of them unattributed. All 5 were kept apart from D1 and D2.

D1 — the `muster attention summary` CLI verb (+cli.md, verb ledger 23→24, CLI tests): the bar can GET the endpoint over https directly, removing the devrc CLI-pin/server-version coupling
D2 — the `path` item field alongside `url`: MUSTER_PUBLIC_URL is set live so `url` is always populated, and the never-remove contract makes the duplicate permanent
Q1 — Is "read-only" meant to be every GET a personal token reaches (as built: includes agent message bodies, tasks, projects, flows via the hook-tier door), or a narrower scope for the bar's on-disk token? Recommendation: keep method-scope as built. · forcing: user

@ZacxDev

ZacxDev commented Nov 1, 2026

Copy link
Copy Markdown
Owner Author

The operator's answers to the round-0 premises, relayed word for word:

Q1=answered: Summary only — the bar's token gets a scope that can call only GET /api/attention/summary, not every GET a personal token reaches
D1=deleted
D2=deleted

… only

Round-0 answers on the PR.

Q1: the read_only boolean becomes a token SCOPE. Migration 0068 is edited in
place (unreleased): api_tokens.scope TEXT NOT NULL DEFAULT 'full', CHECK
(scope IN ('full', 'summary')). A 'summary' token reaches GET (and so HEAD)
/api/attention/summary and NOTHING else: every other route a personal token
reaches, the hook-tier door included, answers 403 before its handler runs.
The rule is one allowlist, tokenScopeRoutes (scope -> mux patterns, read
from http.Request.Pattern), checked in the single admit path
(admitAPIToken); only 'full' is unlimited, and an unknown or blank scope
reaches nothing. GET /api/whoami is denied to a summary token on purpose: a
bar does not need it, and the summary's own 200/401/403 already says
whether the token works. Minting is a choice between "Full access" (the
default) and "Bar summary only (can read the Attention summary and nothing
else)"; the token page, /api/tokens (muster token ls) and whoami show the
scope. An unknown scope at mint is a 400. Existing tokens stay full.

D1: the `muster attention summary` verb and everything that only it used
are gone (verb count back to 23). The bar GETs the endpoint over HTTPS.

D2: the item's `path` field is dropped; `url` alone remains, absolute on
MUSTER_PUBLIC_URL and "" when that is unset (documented).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ZacxDev ZacxDev changed the title feat(attention): GET /api/attention/summary, read-only API tokens, muster attention summary feat(attention): GET /api/attention/summary + summary-only token scope Nov 1, 2026
Round-1 audit findings (final round).

- One Hosts() read per summary. The handler reads the hub once and hands
  the snapshot to attentionItemsIn -> waitingSessionsIn, so a flapping host
  cannot be online in hosts[] while its item says offline. attentionItems
  and waitingSessions keep their signatures and read the hub themselves.
  TestTheSummaryReadsTheHubOnce: red at 69c3b85 ("read the hub 2 times"),
  green here.
- 0068 header + docs/schema.md: mint 'summary' tokens only after the
  rollout completes. A pre-0068 pod ignores scope, so a summary token
  minted mid-rollout (RollingUpdate, maxUnavailable 0) has full access on
  every request an old pod answers.
- 0068 rollback: api_tokens is under FORCED row-level security, so the
  revoke must run with SET LOCAL muster.scope = 'all'. Measured against a
  throwaway Postgres, with counts taken as the superuser: as muster_app
  without the scope the UPDATE left 1 of 1 live summary tokens (and raised
  no error); with it, 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ZacxDev

ZacxDev commented Nov 1, 2026

Copy link
Copy Markdown
Owner Author

Audit round 1 (full, at 69c3b855): 0 🔴, so the ladder ends here. It found 2 🟡 and 2 🟢. These are fixed in 6cd34820, which is not re-audited:

  • 🟢 The summary now reads the hub once, and both hosts[] and the waiting items share that snapshot. Pinned by TestTheSummaryReadsTheHubOnce.
  • 🟡 Documented in the 0068 header and schema.md: during a forward rollout, old pods ignore scope. Mint summary tokens only after the rollout completes.
  • 🟢 The rollback revoke now runs in a transaction with SET LOCAL muster.scope = 'all' and a count check. Without the setting, forced RLS makes the UPDATE silently change 0 rows; this was measured.
  • 🟡 Left open on purpose: the title tests here will go red once fix(attention): asks and waiting sessions name the exact tmux target #388, which changes how windows are named, is merged too. The PR that lands second updates them.

No route bypasses the scope check. Every personal-token door goes through admitAPIToken, and the tap route is session-only.

1. The summary reads Hosts() once and shares the snapshot between hosts[] and the waiting items.
2. The migration header and schema.md say to mint summary tokens only after a rollout completes.
3. The rollback revoke sets muster.scope='all' inside a transaction and checks the count.

@ZacxDev
ZacxDev marked this pull request as ready for review November 1, 2026 05:45
@ZacxDev
ZacxDev merged commit b347aea into main Nov 1, 2026
19 checks passed
@ZacxDev
ZacxDev deleted the feat/attention-summary-api branch November 1, 2026 05:45
ZacxDev added a commit that referenced this pull request Nov 1, 2026
…mary's one hub read

- attentionItemsIn (#385) takes the caller's ONE read of the hub; the ask
  loop had a second, lazy read of its own for mirrored sessions' asks. It
  now names them from that same read, so a summary reads the hub once with
  a mirrored ask open too (new seam test; a second read is caught as
  "read the hub 2 times; want 1").
- #385's summary tests pin the new titles and whys: "tmux main:1 @ ws",
  "Waiting: tmux main:1 @ ws"; the unit fixture's ask is built with its
  window, "Gold:1 · tmux main:4 @ ws"; the member-leak list also refuses
  the operator's "tmux main:1", with a control that the operator's own
  summary carries it.
- An answered ask's page passes its source through textplain.Title before
  it reaches the delivery line (internal/textplain's title-sink ledger
  caught the raw read, introduced in the previous commit).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Nov 1, 2026
…388)

* fix(attention): asks and waiting sessions name the exact tmux target

An ask, a waiting session and their notifications named a mirrored window
by its host's codename ("mango:1 @ ws", which is no tmux name) or by its tmux
session alone with no window ("build @ ws"), so the operator could not find
it. Every attention surface that names a mirrored window now takes its words
from one helper, mirror.Where: "tmux <session>:<index> @ <host>", led by the
codename when the host has one ("mango:1 · tmux scratch3:5 @ ws").

- chiefnudge.Session carries the window's Index (filled by stallInputs), and
  nudgeAskQuestion builds its "where" from mirror.Where; agents keep
  "(agent <name>)".
- A waiting session / held question / Hermes item's source is e.Where().
- A mirrored session's ask resolves the window running its Claude session
  NOW (claudeSessionEntry, the rule claudeSessionWindow already used) for its
  source on the queue, its page header, its session page's asks block and
  its notification; when no window runs it, its stored name is followed by
  "(not shown in any mirrored window now)" instead of an invented target.
- Docs: attention.md "How a window is named", chief-nudges.md ask text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(attention): keep askItem's and nudgeAskQuestion's signatures for the in-flight PRs

Test-merged with the two open PRs that touch these functions: one changes
nudgeAskQuestion's parameters, the other's test calls askItem(a). So:

- askItem takes its hosts variadically: askItem(a) still compiles (a mirrored
  session's ask is then named as no window showing it); every production
  caller passes the owner's windows. The test call sites go back to main's.
- The nudge ask's "where" is its own function, nudgeAskWhere, placed away
  from nudgeAskQuestion; nudgeAskQuestion's only change is calling it. The
  unit tests pin nudgeAskWhere whole; the live-ask PG test now pins the whole
  question built through the real path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(chief-nudges): the ask's place is nudgeAskPlace, leaving nudgeAskQuestion's head untouched

Test-merged with the open PR that changes nudgeAskQuestion's parameters: a
helper replacing the function's first lines conflicted with it textually.
Only the mirror/agent branch now changes (it calls nudgeAskPlace), as in the
first commit, which merged cleanly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(chief-nudges): the generic ask's pinned head names the exact tmux target

#384 (now on main) pinned the generic ask's head as "A session (Gold:1 @ ws)";
with this branch the place is mirror.Where's "(Gold:1 · tmux main:1 @ ws)".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(attention): delivery lines and the chief's session reads name the exact tmux target

The operator's "always" is wider than the open-ask surfaces: an answered
ask's card and its delivery line ("Queued for X …", "Waiting for X to read
it …") send them to the session too, and the chief only ever saw a session
as its codename and host, so an ask the chief wrote could not name the
window.

D1, delivery: answerDeliveryText takes its asker's name, and every caller
passes askSource's — the Answered list (answeredAsksNow reads the owner's
windows once, when an answer is a mirrored session's) and an answered ask's
own page (its item's Source). A window no longer shown reads "(not shown in
any mirrored window now)", never an invented target.

D2, the chief: every session listing it reads carries the same text, as
`where` (additive; every existing field kept):
- fleet_list rows, sessions_overview rows (and the session hints built from
  the same live sessions), fleet_report rows on the chief's door only (the
  mail door names no host, so it never carries it);
- a session question's block: "where: <askSource>" inside the fence;
- route candidates and an online PR author's window line.
The supervisor prompt's shared live-reads paragraph says to give that
`where` exactly when pointing the owner at a session (its digest
re-recorded after reading the diff).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(attention): with #385 merged, asks name their window from the summary's one hub read

- attentionItemsIn (#385) takes the caller's ONE read of the hub; the ask
  loop had a second, lazy read of its own for mirrored sessions' asks. It
  now names them from that same read, so a summary reads the hub once with
  a mirrored ask open too (new seam test; a second read is caught as
  "read the hub 2 times; want 1").
- #385's summary tests pin the new titles and whys: "tmux main:1 @ ws",
  "Waiting: tmux main:1 @ ws"; the unit fixture's ask is built with its
  window, "Gold:1 · tmux main:4 @ ws"; the member-leak list also refuses
  the operator's "tmux main:1", with a control that the operator's own
  summary carries it.
- An answered ask's page passes its source through textplain.Title before
  it reaches the delivery line (internal/textplain's title-sink ledger
  caught the raw read, introduced in the previous commit).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(attention): a window's tmux target is shown verbatim, never markdown-stripped

The title rule (textplain.Title) plains markdown out of every page and
notification title; on a where it also plained the tmux target itself, so a
session named "_scratch_" read "scratch", "*dev*" read "dev" and "a`b`c"
read "abc" — places the operator cannot find.

- textplain.TitleKeeping(s, keep): Title over s, keep kept verbatim. keep is
  mirror.WhereTarget ("tmux <session>:<index> @ <host>", Where's machine
  part), carried on attentionItem (target) and RouterNotification
  (TitleKeep). Every surface that plains a Source uses it: the push body and
  the push title sink (goNotify), the queue card, the summary title/why, the
  ask page header and delivery line, the Answered list. The codename around
  the target is still plained; per-surface escaping is unchanged.
- The title-sink ledger records the exemption, counts TitleKeeping as the
  rule, tracks Where / askSource / askSourceParts reads, and refuses a plain
  Title over any value that can name a window ("where kept verbatim", with a
  positive control).
- A where from a host the hub still lists but has dropped ends
  " (host offline)" (an ask's source, the chief's fleet_report).
- The session page's asks block reads the hub only for a mirrored ask (its
  comment said so; the code did not).
- docs/attention.md: the summary's why is "Waiting: <tool> · <where>"; the
  target is verbatim; "(host offline)".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant