Files
stack/agents/dewey/work/wui/SLICE1-VIEWS.md
T
jason.woltjeandClaude Opus 5.5 1f4882d2b4 docs(wui): Dewey's design work: brand board, five mockups, slice 1 S5 views and checks
Brand board revisions, the five dashboard mockups with their verify
script and evidence, the slice 1 S5 design note (SLICE1-VIEWS.md, rev 3
plus the S2c section 10 update), the Console mockup of inbox, tasks,
agents and trail (mockups/slice1/) and checks/slice1-verify.mjs (16
checks; check 16 pinned to bus at d27042fa). Also the CHAT-03 progress
log and the 2026-09-26 dirty-tree manifest. Design and evidence only;
no package code. Row 40 (#1522), committed on its own at Sage's
direction, 2026-10-09.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 17:54:50 -05:00

27 KiB

Slice 1 S5: WebUI views for inbox, tasks, agents and trails (design note)

Dewey, 2026-10-04. Row 40, #1522. Design only: no code, no commit. S5 follows S4, so nothing here starts the build.

Revision 2, same day: lead decision 56 (c9c1699a) answered Q1 to Q5 and accepted all five recommendations. Sections 1, 3.2, 3.5, 5 and 7 now say what was decided. Section 8 is a new finding for the Q1 module. The mockup is in mockups/slice1/, checked by checks/slice1-verify.mjs. Its fixtures follow schema v3a, and the verify script loads every row into that schema.

Revision 3, same day. Three things changed:

  • Schema v3b (179ffe35, lead decision 60) landed. It adds the task_current view and builds tasks_open on it (lead decision 59). The fixture check now loads v3b and reads current state from those two views. It agrees with the page on every task, #42 included. Section 8 records the ruling.
  • The mockup is reshaped to the Q1 module (Sage's Q1 shapes). data.js holds raw v3b rows written the way the candidate broker writes them. mockups/slice1/q1.js ports the four reader verbs, and reads(S) in app.js builds the views only from what those verbs return. A new check runs Rocko's own candidate reader over the same rows and compares. Section 1 and section 7 describe this.
  • Inbox rows now carry authorization {action, target, approvalChoice} from the decision.raise event, and the views show it (section 3.1). What the page needs and no Q1 verb returns is labelled on the page and listed for Rocko in section 9.

Sources, pinned by sha256 prefix:

  • brief docs/plans/2026-10-04_slice-1.md (655e1290, commit 43c48d7a), S5 section and the shared rules;
  • data model note (948b94ce), addendum A (0b36acb0), addendum B (14e747f5). B wins over A, and A wins over the note;
  • schema v3 agents/darkwing/work/slice1-proto/schema-v3.sql (084d0a07, commit 7ed83178, lead decision 55), replaced by schema v3a schema-v3a.sql (d55e41fd), which S2 builds on, then by schema v3b schema-v3b.sql (179ffe35, lead decision 60). The mockup follows v3b;
  • packages/bus as committed in d27042fa (S2c, row 44; S2 was 38828a2c, S2b 4afab552): broker.mjs 51e03b33, store.mjs e69cb395, views.mjs 446b6da4;
  • PRD draft 0.4 docs/prd/mosaic-stack.md (d907705a): REQ-WEB-1, REQ-DEC-1 to 4, REQ-CLI-1, REQ-EVT-1; round 3, 7B;
  • DEFERRED.md, "CHAT-03 I1 round 2 follow-ups, for the I3 brief".

Not read on purpose: today's queue. The views follow the data model.

1. What the WebUI reads, and through what

REQ-WEB-1 says the WebUI shows the same data as the CLI. The S4 brief says every CLI command reads through the broker and never opens bus.sqlite. The WebUI follows the same rule:

  • The browser never talks to the broker. The WebUI server (packages/webui) calls the broker's read verbs over the local socket and serves JSON to the page, the way it proxies the board today.
  • The WebUI never opens bus.sqlite, the business file or a token file. Bucket titles, bot names and launch limits come from the broker, which already loaded them.
  • The WebUI adds no write route for slice 1 data (section 6).

One set of read functions serves both renderers, so the two can't drift. Lead decision 56, Q1: the module lives in packages/bus, Rocko owns it, and both the CLI and the WebUI import it. The CLI formats text and the WebUI server serves JSON. The module, packages/bus/src/views.mjs, is a thin client: inbox, tasks, agents and trail(subject), each one broker call. The mockup's mockups/slice1/q1.js stands in for those four calls over fixture rows, and the verify script checks it returns what the broker at commit d27042fa returns. reads(S) in mockups/slice1/app.js builds the view objects from the stand-in alone. A second object, missing(S), holds what no verb returns. Each entry names its source, and every renderer that shows one labels it "not in the Q1 module". When Rocko pins an example fixture, it replaces data.js.

2. Routes

Console keeps its shell, its palettes and its keyboard rules. The left column gains a section list above the project tree. Hash routes work with the back button, as the mockups did.

Route View Main source
#/ Control board (unchanged) the board scan
#/inbox Decisions routed to the human, open first decisions, decision_events
#/inbox/<id> One decision, its options and its history same, plus supersedes chain
#/tasks Tasks by state latest task_snapshots per task, task.* events
#/tasks/<ref> One task and its trail same, plus the trail (section 3.4)
#/agents Role instances, holders and live sessions business file via broker, role_claims, session.* events
#/trail/task/<ref>, #/trail/decision/<id> The ordered trail events, decisions, messages, deliveries
#/conversation/... CHAT-02 history and CHAT-03 drive (existing) the board and packages/conversation

A task ref in a URL is vikunja:<project>/<task>, encoded. The #12 a person sees in Vikunja is display only (addendum A section 1).

3. The views

Every view keeps the Console states: loading, empty, error with the last good data kept and a warning, and refusal with the reason. All agent-authored text (questions, option text, recommendations, message bodies, task titles and descriptions) is shown as text under the CHAT-02 rules: Markdown stays as source, and controls and bidi marks show as visible symbols.

3.1 Inbox

  • Rows: decisions with route_to = 'human' and no closing row (resolved, withdrawn, expired). Blocking first (the urgent_inbox view), then oldest first. The note's rule is "gated first, then oldest", and every human-routed decision is gated, so blocking is the useful split.
  • Each row: question, class and action, raised by role and run, age, task_ref and requirement_ref as links, a "blocking" badge, and whether a DM went out (a deliveries row with transport discord-dm).
  • Detail: the options as a numbered list of key and text, the recommendation marked, the seen history, and earlier versions through supersedes, each shown withdrawn.
  • Approval context, from the row's authorization: the card and the detail say "Choosing “” approves on .", and the approving option carries an "approves " badge. The approving choice need not be the recommendation: in the fixture, the conflict on #48 recommends queue, and vikunja approves task.close. A decision without a recorded raise says no option is marked as approving.
  • Resolution stays in the CLI (REQ-DEC-3). The detail shows the exact command, mosaic decide <id> <key>, one per option, with a Copy button. Copy puts text on the clipboard and does nothing else. There is no Resolve button, disabled or otherwise.
  • A second tab, "Routed to roles", lists open cross-role decisions by arbiter (pm, cto). It's read-only too. Closed decisions get a "Recent" list: choice, by, via and time open.

3.2 Tasks

  • Rows: the latest snapshot of each task in the business, grouped by the five bucket titles (todo, in-progress, in-review, blocked, done). done is collapsed and shows the last 20.
  • Columns: #index and title, assignee (bot name), requirement id (from task.created, not the description line), due date, priority, percent done, queue row if the task has one, and the time of the last change.
  • Markers, each linked to its event:
    • "changed in Vikunja" for a task in task_external_changes. A task the broker never wrote is always in that view (addendum B section 5), so the marker reads "not created by the stack" when no self snapshot exists;
    • "conflict" for an unresolved task.conflict, linked to the decision raised to the PM;
    • "missing" with the reason (moved, not-found, no-access) for a tombstone, and the new project id for moved.
  • Freshness: a line under the heading, "Vikunja last read
  • Which snapshot to show: not always the latest one. Section 8.
  • "Open in Vikunja" links to the task on tracker.baseUrl. It leaves Console and changes nothing.

3.3 Agents

  • One row per role instance in the business file (pm, cto, coder, reviewer in slice 1): definition, harness, model.
  • Holder: the latest role_claims row for the instance. A claim shows the holder run, harness, address and time held. release and revoke show "not held" and who released or revoked it; a revoke links its decision.
  • A claim from a T3 seat carries "advisory" (decision 46, note 6.3).
  • Live sessions: session.launched rows with no matching session.ended, with model family and launched_by. Above the table, live counts against the business file's limits, for example "Opus 2 of 4, Sonnet 1 of 4", and the launch state from the launch_state view ("launching allowed" or "launching revoked at
  • Credentials: per instance and service, the latest of credential.expiring, credential.expired and credential.changed. Service names and dates only. There is never a value or a path.
  • Today's board seats stay on #/. The Agents view is about roles. A seat that holds a role links to its board row.

3.4 Trail

The S4 brief lists the order: request, decisions, launches, task changes, review, close. The trail view is a single time-ordered list with one row per event and a filter by kind. Each row says what happened, which role and run did it, and links to the object.

For a task, the rows I can join from schema v3:

  • events with subject = the task ref (task.created, .assigned, .state, .closed, .changed.external, .conflict, .missing);
  • decisions with task_ref = the task ref, and their decision_events;
  • messages that cite one of those decisions, with their deliveries;
  • review.requested and review.verdict, if their subject is the task ref or its queue row.

What I couldn't join from the schema alone: which launch worked on the task, and which human.input was the original request. Q3 was decided: task.created names the request in body.request, and the broker's trail follows it to the human.input and its message. Launches come in through the runs seen on the trail's rows. For a decision, the trail is the decision, its events, the messages that cite it and the events whose body cites it.

3.5 Conversations and CHAT-03

The brief puts two I3 follow-ups into S5, because the live PM session needs them:

  • the seal covers the engine command: the I3 entry point takes no engine command or preArgs from config, so only the pinned Pi starts;
  • an explicit engine environment: the engine starts with an allow-listed environment, not process.env. That matches addendum A section 4, which starts every agent with an allow-listed environment.

Lead decision 56, Q5, adds two more of the DEFERRED items to S5: clear escalating after a throw, and handle takeover plus Enter arriving in one chunk. Both sit in files S5 already changes. The test that members are listed only under freeze stays deferred.

Which session Console drives depends on S6. The PM moves off T3 there, and the stack owns its session. Console shows the PM's conversation from whatever S6 records, through packages/conversation.

4. Gate E

Round 3, 7B: Gate E is shown during this step, not separately. The brief: an interactive demonstration with all seats, then Jason's workday ruling. Live cutover still needs its own approval.

A draft script, to be fixed once S4 and S6 exist:

  1. Jason opens Console. The board shows the seats, and Agents shows the four role instances with their holders.
  2. He sends the PM a one-sentence request from the CLI, then watches the task appear in Tasks with its requirement id.
  3. A gated decision reaches the inbox and his DM. He reads it in Console, copies the mosaic decide command and runs it in a terminal. The inbox row closes on the next refresh.
  4. He follows the task's trail to review and close.
  5. He drives a conversation through CHAT-03 and takes control, then hands it back.
  6. He uses Console for the rest of his workday and rules on it.

Evidence: browser runs against a fixture broker with recorded data, and the live run's screen captures. Model calls during the demonstration are Jason's to start. I don't run them for discovery.

5. Questions, now decided

Lead decision 56 (c9c1699a) accepted all five recommendations below. Each answer is quoted after the question; the original text stays for the record.

Q1. One read layer for CLI and WebUI. Recommendation: S4 exposes the inbox, tasks, agents and trail reads as functions over the broker client, in a module packages/webui can import. The CLI formats text and the WebUI serves JSON. For Rocko, through Sage. Decided: one read module in packages/bus, owned by Rocko, imported by the CLI and the WebUI.

Q2. Freshness of task data. A quiet poll writes no snapshot (addendum B section 5), so the database can't say when Vikunja was last read, or whether the poller is failing. Recommendation: the broker answers a status read with the last successful board read and cursor read per project, and the last poll error. S3 owns the poller and S2 the read verb. For Darkwing and Rocko. Decided: the broker status read goes in S3, Darkwing's.

Q3. Trail join keys. session.launched and human.input carry no task ref in the schema. Recommendation: put the task ref in subject for any event about one task, including session.launched when the launch is for a task, and human.input when it creates one. That is a body and subject rule, not a new kind, so the closed kind list holds. For Rocko and Darkwing. Decided: the task ref goes in subject for any event about one task. task.create names the human.input that asked for it. Darkwing picks whether a trigger or the broker enforces it. Schema v3a does it with triggers: events_task_subject for every task.* event, and events_task_created_body, which requires request (the human.input id) and requirement in the task.created body. For session.launched and the other kinds, the broker sets subject.

My original Q3 text was wrong on one point. A human.input can't carry the task ref, because the task doesn't exist when the request is recorded. The join runs the other way: the task trail takes the human.input named in task.created.request. The mockup does that, and a check confirms that #41's trail shows its own request and not another task's.

Q4. seen from the browser. decision_events allows op = 'seen', and the note lists webui under via. Recommendation: the WebUI writes nothing in slice 1, not even seen. Every broker write is stamped from a launch record or comes from the CLI outside a run, and an unauthenticated loopback page is neither. seen from the WebUI comes with Pocket ID. For Sage. Decided: the WebUI writes nothing in slice 1. It shows the mosaic decide command to copy.

Q5. I3 follow-ups beyond the brief's two. Recommendation: fix escalating and the takeover-plus-Enter case in S5, since both sit in files S5 already owns. Keep the freeze test as a follow-up. For Sage. Decided: as recommended (section 3.5).

6. Boundaries

  • No write route for decisions, tasks, claims, messages or events. Copy is clipboard only.
  • The page and server stay loopback-only with the existing checks (Host, Origin, body limits, no CORS). The new routes are GET only.
  • No token value, token path or business-file path reaches the page. Credential rows show the service and dates.
  • Agent-authored text is inert.
  • No model calls. Tests use a fixture broker.

7. Before S4 lands

  • Done: a Console mockup of the four views, mockups/slice1/. checks/slice1-verify.mjs checks it at eight widths, ten palettes in three modes, and covers inert text, read-only behaviour, the inbox, tasks, agents and trail rules, the loading/empty/failed/refused states, the keyboard and long text.
  • Done: fixtures as raw schema v3b rows (data.js), written in the broker's order and with its millisecond timestamps. The Store refuses any at or read_at that isn't the 24-character toISOString() form; the schema doesn't check that, so the fixture had to change.
  • Check 15 inserts every fixture row (104) into schema v3b (179ffe35) in time order, so its CHECKs and triggers judge the fixture. It compares urgent_inbox, task_external_changes, task_current and tasks_open with the page, and requires the "stale read" marks to be exactly the snapshot rows task_current skips (#42 only).
  • Check 16 extracts packages/bus from commit d27042fa (S2c, row 44; S2 was 38828a2c, S2b 4afab552) with git archive, so working-tree edits don't count. It opens that Store and Broker on a temporary data root, inserts the same rows, binds a reader and requests inbox, tasks, agents and 21 trails (every task, decision and message). The results must equal q1.js on the same rows: 203 rows, equal, with broker.mjs 51e03b33, store.mjs e69cb395 and schema.sql 179ffe35. It checks the reader only: rows go in by SQL, not through the broker's writers, so it can't tell whether the broker would write them (section 10). It writes nothing outside its temporary directory, which it removes. Run from a copy outside the repository, it needs DEWEY_S1_REPO, or it records "not verified".
  • A mutation run in a scratch copy breaks one rule at a time, and each check has to fail on the mutant it guards against. Round 3, 21 mutants, 20 killed on the first pass. The survivor reversed the decision trail's order; check 8 only checked the task trail's order, and now checks both. M5 (stale mark on the current row) failed check 15, not check 6 as I had expected; check 15 now owns that rule. After the fix, 21 of 21 (~/dewey-scratch/s1mut2/mutants.txt, scratch only). Report: evidence/slice1/report.json.
  • Next: switch data.js to Rocko's pinned example fixture when it arrives. Section 9's gaps decide what S5-B1 needs beyond the four verbs.

8. Finding: a stale poll read becomes the latest snapshot

Ruled: lead decision 59 (e153c3a3) takes the read-side fix and puts it in the schema. The poller keeps writing the stale row as evidence. A v3b amendment (Darkwing) adds a view of each task's current snapshot that skips the stale poll row, and rebuilds tasks_open on that view. The Q1 module, the CLI and the WebUI read that view and don't reimplement the rule. When v3b lands, the fixture check moves to it and expects tasks_open to match the page on every row, #42 included. The Tasks view keeps the "stale read, not shown" marker in the snapshot list.

Landed: v3b (179ffe35, lead decision 60). Check 15 loads it and agrees with the page on every task, #42 included. The page reads the current row from the stand-in tasks verb, which reads task_current, and marks as stale the snapshot rows after it. The original finding follows.

For Rocko (the Q1 module) and Darkwing (S3 poller), through Sage.

Addendum B section 5 writes a poll snapshot whenever its digest differs from the task's latest snapshot, and ignores a stale read when it detects external changes: task_external_changes requires p.read_at > ls.at, so a board read that started before the broker's own write doesn't count as a change in Vikunja. The next tick settles it.

The stale read is still stored, though, and it is now the latest snapshot. Anything that shows "the latest snapshot per task", including tasks_open and the Tasks view, shows the old bucket until the next tick.

Example in the fixtures, task #42:

  • 14:59:40, self snapshot: the broker moved #42 to in-progress. updated stays 09:50, because a move between open columns doesn't change it (probe P3).
  • 14:59:41, poll snapshot, read_at 14:59:39: the board read started before the move and still sees todo. Its digest differs, so it is written.
  • Latest snapshot: todo. Not external, correctly. Shown as todo, wrongly.

The verify script confirms this against the real schema. After the fixture is loaded into schema v3a, tasks_open gives todo for #42, while the page shows in-progress. That is the only row where they differ.

What the mockup does: when choosing the row to display, skip a poll snapshot whose read_at is at or before the latest self snapshot's at. The task's snapshot list still shows that row, marked "stale read, not shown". The verify script checks #42 sits under in-progress and the marker.

Recommendation: the Q1 module applies the same rule, and tasks_open gets it too, so the CLI and the WebUI agree. The other fix is in the poller: don't write a poll snapshot whose read_at is at or before the latest self snapshot. That loses no information the external-change rule uses, but it drops a row from the evidence, so I'd leave the choice to Darkwing.

9. What the views need that no Q1 verb returns

Ruled: lead decision 63 (Sage, 2026-10-05) sends this list to Rocko for S4. Session events go to S6 and credential events to S3. Whatever S4 doesn't add keeps its "not in the Q1 module" label in S5.

For Rocko, through Sage. The reader (d27042fa) has four verbs: inbox, tasks, agents and trail(subject). The mockup builds everything it can from those. The rest comes from missing(S) in q1.js, and the page labels each one "not in the Q1 module". Suggestions are the smallest change I can see; the choice is Rocko's.

Inbox and decisions:

  • Decisions routed to roles, and closed decisions (the "Routed to roles" tab and "Recent"). inbox returns only open, human-routed decisions. Suggestion: an argument to inbox, or a decisions verb.
  • Seen and DM state per inbox row. The page gets them with one trail read per decision. Suggestion: seen_at and dm_at on the inbox row.
  • The forward link from a withdrawn decision to the one that replaced it. The trail's decision query matches id and task_ref only. Suggestion: add OR supersedes = ?.
  • Approval context for a decision that isn't in the inbox. The page reads it from the decision.raise event in the trail, which works, but it is the same derivation #decisionView already does.

Tasks:

  • Requirement, conflict, external-change fields, review and the missing reason all need one trail read per task. Suggestion: an external flag from task_external_changes and the requirement on each tasks row.
  • Bucket titles, bot names and the tracker baseUrl. They are in the business file and the kanban view, which the broker has loaded.
  • Freshness ("Vikunja last read"): the S3 status read, not built yet.

Agents:

  • Role instances, limits and model families, from the business file.
  • Launch state: the launch_state view exists in the schema; no verb returns it.
  • Live and ended sessions. Nothing writes session.ended yet, and session.launched carries no model and no launched_by.
  • Credential notices. Nothing writes credential events yet.
  • agents returns only roles whose latest row is a claim, so the view can't say who released or revoked a role. Suggestion: return the latest row per role and let the reader filter.

10. Finding: S2b stopped an agent message citing its decision; S2c restores it

Ruled: lead decision 65 (dd35a0ab) took none of the options below as written. For a within-role message.send, decision is a citation: the broker checks the decision exists and stores it in messages.decision, but doesn't class-match it, doesn't consume it, and leaves it out of the action.allowed event. For a send that isn't within-role, it stays the authority. messages.decision keeps its "about this decision" meaning, and messages 101 and 102 stay valid. S4 still sends and records the Discord DM. The fix is row 44, S2c (#1526, Rocko, Darkwing reviewing).

Landed: S2c is commit d27042fa (row 44 done at queue rev 157), and it restores messages 101 and 102: the broker at d27042fa writes both. The probe below, rerun on d27042fa, shows a within-role citing send is written, the decision is stored and not consumed (a second citing message is written too), an unknown decision refuses with decision-not-found, and the send's action.allowed event is {action message.send, class within-role, target human} with no decision. No action.allowed row outside decision.raise names the decision. The fixture now carries that event 1 ms before each of 101 and 102, and check 16 is pinned to d27042fa. Check 16 still inserts by SQL, so a broker-written fixture with a citing DECISION message remains the regression test it can't give. The original finding follows.

For Rocko and Sage. Found 2026-10-05 while repinning check 16 to S2b.

message.send takes decision?, and the broker stores it in messages.decision. Up to S2 (38828a2c), a role with message.send in its within-role authority returned before the decision was looked at, so decision worked as a citation: the PM could send the human a DECISION message about an open gated decision. S2b (4afab552) passes every agent send through #consumeAuthority, which now checks a supplied decision even for a within-role action. The decision's class and action must match message.send, so a citation of a role.launch decision refuses with decision-mismatch.

Probe (~/dewey-scratch/s2b-probe.*/probe.mjs, both commits extracted with git archive, PM with message.send within role, an open blocking role.launch decision routed to the human):

38828a2c 4afab552 d27042fa (S2c)
PM DECISION message with decision: <id> written refused, decision-mismatch written; decision stored, not consumed
the same message without decision written written, plus action.allowed {action message.send, class within-role, target human} with subject human written, plus the same action.allowed
citing an unknown decision refused, decision-not-found refused, decision-not-found refused, decision-not-found

What depends on the citation:

  • the trail's decision query collects messages WHERE decision = ?, and deliveries through those messages;
  • the inbox's "DM sent" state, which comes from a discord-dm delivery of a message citing the decision (section 3.1). The fixture's messages 101 and 102 are those DMs, and the broker would now refuse both.

After S2b, a non-null messages.decision can only name a decision that approved message.send itself, and that decision is used up by the send. The column now means "the decision that authorized this message". The data model and the views read it as "the decision this message is about".

Options, for Rocko and Sage:

  1. A separate citation argument (for example about), stored apart from the authorizing decision. That needs a schema column or an event.
  2. The Discord connector sends the DM from the decision itself, not from an agent message, and records its own delivery. The views then read "DM sent" from that record.
  3. Keep S2b's rule, and drop "DM sent" and decision messages from the views.

My recommendation is 2. REQ-DEC-4 and the slice 1 brief's S4 section already give the outbound DM for a blocking gated decision to S4, "through the existing connector (#1509)", so the PM doesn't need to send one. It also leaves message.send's decision meaning one thing. S4 then has to record the DM somewhere the views can read it; Darkwing's prototype has a digest.sent event with a transport for the digest side. Until a ruling, the fixture keeps messages 101 and 102 as they are, and this section marks them as rows the broker at 4afab552 would refuse. (Ruled above: they stay, and S2c restores them.)