Split from #16744 (its AC-2), the OQ8 empty-state trap — graduated scope of D#16720 (body v12 @ 2026-08-08T19:52:47Z). Split reason: every other #16744 AC is a render-layer deliverable; THIS one needs a wire projection that does not exist yet — the roster read result is {rows} only (apps/agentos/view/fleet/FleetCockpit.mjs:2264 destructures rows alone), so the viewer cannot know the plane-side count the honest line requires. Filing it separately keeps #16744's PR one-PR-resolvable.
Context
D#16720's OQ8: the default-private roster's empty state must be distinguishable from dead-plane emptiness (June RLS anchor). A plane that answers "0 agents shared with you" while N operators are present must render "plane alive · N operators present · 0 agents shared with you · request access" — the operator action (request access) differs categorically from both "start the server" and "define agents".
The Problem
Today the only answered-empty reason is 'server connected · fleet registry empty — define agents to go live' (FleetCockpit.mjs:2297). For an access-scoped empty that line is a LIE in the actionable direction: it tells the viewer to define agents on a plane that HAS agents they cannot see. The viewer literally cannot distinguish "empty plane" from "plane that shares nothing with me" — the wire does not carry the count.
The Architectural Reality
- Wire schema:
ai/services/fleet/fleetWireMethods.mjs with the browser twin apps/agentos/config/fleetWireMethods.mjs — parity-linted twin binding (husky pre-commit lint-fleet-vocabulary-parity).
- Roster read path:
FleetCockpit.mjs loadRoster (:2240–:2330) — the answered-empty branch at :2289–:2299 is where the two empty kinds must part.
- Render:
gridDegradedReason → deriveSpineBanner retained-reason discipline (shipped in PR #16721) — the new reason rides it, no fork.
- Sibling precedent for the two-substrate shape: PR #16721 (merged).
- BLOCKED ON #16737 (viewer-scoped roster projection, @neo-fable-clio). Not merely a producer dependency — the scoped-empty STATE is currently unreachable:
FleetRegistryService.listAgents() returns all agent definitions, so nothing filters rows per viewer and rows: [] can only mean the registry is empty. This ticket becomes implementable when #16737 lands, and its wire row is the contract that projection should meet.
The Fix
- Wire: the roster read success result MAY carry the empty CAUSE, authored by the scoping authority — never a bare liveness count. Superseded design, kept for the record:
presence.operatorsPresent: Number was prescribed here and falsified in PR #17736 review. Operator presence and roster emptiness are independent axes: rows: [] with operatorsPresent: 0 serialises both a plane with hidden definitions and nobody online AND a plane with no definitions at all, and the consumer must select opposite actions for them. A positive count proves operators are present, never that a definition exists the viewer cannot see. The privacy boundary from the original design SURVIVES — whatever the field carries is a cause or a count, never identities (a bare-private boundary this feature serves). Twin-bound both sides + parity-lint fixtures.
- Cockpit:
loadRoster's answered-empty path distinguishes registry-truly-empty from scoped-empty and writes the matching reason; the banner renders it through the existing retained-reason channel.
- Tests: unit matrix rows in the
spineBanner.spec.mjs idiom + the cockpit empty-path spec (fleetCockpit.spec.mjs).
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
fleetRoster wire result shape |
#16737's scoping authority (result shape lives in createFleetCockpitStatus; the parity-linted wire twins bind method NAMES only, and fleetRoster is already in both) |
success result MAY carry the empty CAUSE — presence.operatorsPresent: Number struck: operator presence is a different axis from roster emptiness (PR #17736 review) |
field absent → current "registry empty" line, unchanged |
module JSDoc |
unit matrix + a production writer, which is the check PR #17736 lacked |
Decision Record impact
none — inside D#16720's graduated scope; no ADR touched.
Decision Record
Optional: D#16720 (graduation authority anchor; signal ledger on #16744).
Acceptance Criteria
Out of Scope
- The connection-axis banner vocabulary (
connecting / refused / slow / unreachable / failed-upstream) — lands under #16744's PR.
- The request-access FLOW itself (grant UX = #16745's sharing pane).
- #16737's projection implementation itself — this ticket consumes it. It is now recorded as the BLOCKER above rather than as out-of-scope background, because without it the state this ticket renders cannot occur.
Avoided Traps
- Rendering 0-shared as "registry empty — define agents" (the current line) is the lie this ticket exists to kill.
- Disclosing identities instead of a count inverts the default-private boundary — count only.
Related
- #16744 (parent scope; its AC-2 moves here)
- #16745 (sharing pane — the "request access" destination)
- PR #16721 (shipped sibling, same substrate pair)
- D#16720 (graduated source, OQ8)
Live latest-open sweep: checked latest 20 open issues at 2026-08-09T17:53Z — no equivalent found. A2A in-flight sweep: 48-message mailbox scan over the 11:04Z–17:40Z window — no claim on this scope. KB semantic sweep: no duplicate (nearest: shipped ancestors #14560, #15284).
Retrieval Hint: "scoped-empty roster operatorsPresent default-private OQ8 D#16720"
Truth-synced 2026-08-24 after PR #17736 was dropped. That PR implemented the consumer half of the operatorsPresent design above; @neo-gpt-emmy's review established two blocking facts — no production composer writes the field, and operator-presence is the wrong axis for classifying roster emptiness. Verified both, then closed the PR rather than repairing it: repairing meant inventing a contract for a producer that does not exist and shipping a shape #16737 would have to work around.
The ticket stays OPEN and is now honestly blocked. Unassigned so whoever picks up #16737 can carry it, or take it after. The problem statement is unchanged and still correct — 0 agents shared with you is not a dead plane — only the mechanism was wrong.
Split from #16744 (its AC-2), the OQ8 empty-state trap — graduated scope of D#16720 (body v12 @ 2026-08-08T19:52:47Z). Split reason: every other #16744 AC is a render-layer deliverable; THIS one needs a wire projection that does not exist yet — the roster read result is
{rows}only (apps/agentos/view/fleet/FleetCockpit.mjs:2264destructuresrowsalone), so the viewer cannot know the plane-side count the honest line requires. Filing it separately keeps #16744's PR one-PR-resolvable.Context
D#16720's OQ8: the default-private roster's empty state must be distinguishable from dead-plane emptiness (June RLS anchor). A plane that answers "0 agents shared with you" while N operators are present must render "plane alive · N operators present · 0 agents shared with you · request access" — the operator action (request access) differs categorically from both "start the server" and "define agents".
The Problem
Today the only answered-empty reason is
'server connected · fleet registry empty — define agents to go live'(FleetCockpit.mjs:2297). For an access-scoped empty that line is a LIE in the actionable direction: it tells the viewer to define agents on a plane that HAS agents they cannot see. The viewer literally cannot distinguish "empty plane" from "plane that shares nothing with me" — the wire does not carry the count.The Architectural Reality
ai/services/fleet/fleetWireMethods.mjswith the browser twinapps/agentos/config/fleetWireMethods.mjs— parity-linted twin binding (husky pre-commitlint-fleet-vocabulary-parity).FleetCockpit.mjsloadRoster(:2240–:2330) — the answered-empty branch at :2289–:2299 is where the two empty kinds must part.gridDegradedReason→deriveSpineBannerretained-reason discipline (shipped in PR #16721) — the new reason rides it, no fork.FleetRegistryService.listAgents()returns all agent definitions, so nothing filters rows per viewer androws: []can only mean the registry is empty. This ticket becomes implementable when #16737 lands, and its wire row is the contract that projection should meet.The Fix
presence.operatorsPresent: Numberwas prescribed here and falsified in PR #17736 review. Operator presence and roster emptiness are independent axes:rows: []withoperatorsPresent: 0serialises both a plane with hidden definitions and nobody online AND a plane with no definitions at all, and the consumer must select opposite actions for them. A positive count proves operators are present, never that a definition exists the viewer cannot see. The privacy boundary from the original design SURVIVES — whatever the field carries is a cause or a count, never identities (a bare-private boundary this feature serves). Twin-bound both sides + parity-lint fixtures.loadRoster's answered-empty path distinguishes registry-truly-empty from scoped-empty and writes the matching reason; the banner renders it through the existing retained-reason channel.spineBanner.spec.mjsidiom + the cockpit empty-path spec (fleetCockpit.spec.mjs).Contract Ledger Matrix
fleetRosterwire result shapecreateFleetCockpitStatus; the parity-linted wire twins bind method NAMES only, andfleetRosteris already in both)struck: operator presence is a different axis from roster emptiness (PR #17736 review)presence.operatorsPresent: NumberDecision Record impact
none— inside D#16720's graduated scope; no ADR touched.Decision Record
Optional: D#16720 (graduation authority anchor; signal ledger on #16744).
Acceptance Criteria
fleetRosteris already in both. The result shape lives increateFleetCockpitStatus.)Out of Scope
connecting/refused/slow/unreachable/failed-upstream) — lands under #16744's PR.Avoided Traps
Related
Live latest-open sweep: checked latest 20 open issues at 2026-08-09T17:53Z — no equivalent found. A2A in-flight sweep: 48-message mailbox scan over the 11:04Z–17:40Z window — no claim on this scope. KB semantic sweep: no duplicate (nearest: shipped ancestors
#14560,#15284).Retrieval Hint: "scoped-empty roster operatorsPresent default-private OQ8 D#16720"
Truth-synced 2026-08-24 after PR #17736 was dropped. That PR implemented the consumer half of the
operatorsPresentdesign above; @neo-gpt-emmy's review established two blocking facts — no production composer writes the field, and operator-presence is the wrong axis for classifying roster emptiness. Verified both, then closed the PR rather than repairing it: repairing meant inventing a contract for a producer that does not exist and shipping a shape #16737 would have to work around.The ticket stays OPEN and is now honestly blocked. Unassigned so whoever picks up #16737 can carry it, or take it after. The problem statement is unchanged and still correct — 0 agents shared with you is not a dead plane — only the mechanism was wrong.