fix(attention): the TUI shows the person at the terminal acting on a channel's row, and a channel never acts (#2068)

With a shared channel registered and no person, OpenRig records an act on the channel's ask as the person at the terminal (`human@host`). The Feed now serves that same actor on a channel's ask or a row parked on one, so the TUI's label, its success notice and the record agree.

A request that names a shared channel as the actor, without the terminal's primary-user authority, is now refused for drop, hold, route, handoff and annotate, as it already was for answer, approve, deny and resolve. Nothing is recorded. With the authority, a channel claim is replaced by the primary user for every verb, as before. Agents act as their own seat, so their routing is unchanged.

Left as is: if `participants.yaml` can't be read, no shared channel is known, so only asks and replies from before the file broke are exposed. The remedy is repairing the file.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-26 00:51:43 -07:00
1 parent d8eea0208f
commit 801779fbb4
5 files changed
+61 -14

No files matched your search

+9 -6
View File
@@ -72,11 +72,13 @@ person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask: the act is recorded as
`human@host`, the person at the terminal, never as the channel.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
channel.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel is refused (`answer_not_recorded`) and nothing changes.
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
@@ -136,9 +138,10 @@ the thread.
For a one-person install, nothing to do: it keeps working as before, that person stays the default, pending rows keep
their addresses, and no new file is written until you use one of the commands above.
A TUI older than the daemon still works: it sends the person asked as the actor, which is still their own act. On a
shared channel's ask that actor would be the channel, so an older TUI's answer, approve, deny or resolve there is refused
(`answer_not_recorded`) and nothing changes. Use a current TUI, or let the asking seat close it.
A TUI older than the daemon still works: it sends the actor the daemon serves, the person asked, which is still their
own act. On a shared channel's ask the daemon serves `human@host`. An older TUI's approve there is recorded as
`human@host`, and its answer is refused (`answer_not_recorded`), because answering someone else's ask takes the primary
user's authority, which only a current TUI sends.
**If several people are already registered** (earlier versions listed hand-added people with an advisory), choose a
default with `rig gateway default <id>` after upgrading. Until you do, escalations report
@@ -54,7 +54,7 @@ import { resolveDefaultPerson } from "../gateway/participants.js";
/** The primary user when several people are registered and none is the default, or when no one is registered and the
* claimed actor is a shared channel: the generic human seat the web UI also records. */
const UNNAMED_PRIMARY_USER = "human@host";
export const UNNAMED_PRIMARY_USER = "human@host";
/** Verbs that settle what a person was asked. */
const SETTLING_VERBS = new Set<MissionControlVerb>(["approve", "deny", "resolve", "answer"]);
@@ -324,6 +324,9 @@ export class MissionControlWriteContract {
);
}
const source = this.requireMutableQitem(input.qitemId);
// The same actor rule as every other verb: a channel never annotates, and the terminal's authority names the primary
// user. An annotation settles no ask.
input = this.asActor(input, null).input;
const snapshot = snapshotQitem(source);
const evaluatedAt = this.now().toISOString();
let actionEntry: ReturnType<MissionControlActionLog["record"]> | null = null;
@@ -627,15 +630,15 @@ export class MissionControlWriteContract {
* several people and none chosen, the generic human seat. With no one registered it's the claimed actor, as before,
* unless that's a shared channel: then it's the generic human seat. The audit notes record the authority and the person
* asked, and the transition says so when they differ. Without it, a registered person can't settle what was asked of
* another registered person: that's someone else's answer. A shared channel never settles anything: its messages are
* correspondence, so the asking seat or the person at the terminal settles a channel's ask.
* another registered person: that's someone else's answer. A shared channel never acts on a row, for any verb: its
* messages are correspondence, so the asking seat or the person at the terminal acts on a channel's row.
*/
private asActor(input: MissionControlActionInput, asked: string | null): { input: MissionControlActionInput; note: string } {
const channel = this.queueRepo.isSharedChannel(input.actorSession);
if (input.authority !== "primary-user") {
if (channel && SETTLING_VERBS.has(input.verb)) {
if (channel) {
throw new MissionControlWriteContractError("answer_not_recorded",
`${input.actorSession} is a shared channel, and a channel's messages never settle an ask, so nothing was recorded on qitem ${input.qitemId}. The asking seat or the person at the terminal settles it.`,
`${input.actorSession} is a shared channel, and a channel never acts on a row: its messages are correspondence. Nothing was recorded on qitem ${input.qitemId}; the asking seat or the person at the terminal acts on it.`,
{ qitemId: input.qitemId, channel: input.actorSession });
}
const someoneElse = SETTLING_VERBS.has(input.verb) ? this.queueRepo.askedSomeoneElse(input.actorSession, asked) : null;
+5 -2
View File
@@ -12,6 +12,7 @@ import { readMissionReadiness } from "../domain/proof/judgments.js";
import { WorkflowInstanceStore } from "../domain/workflow-instance-store.js";
import { WorkflowStepTrailLog } from "../domain/workflow-step-trail-log.js";
import { feedPerspective } from "../domain/feed-perspective.js";
import { UNNAMED_PRIMARY_USER } from "../domain/mission-control/mission-control-write-contract.js";
export function attentionRoutes(): Hono {
const app = new Hono();
@@ -61,6 +62,8 @@ export function attentionRoutes(): Hono {
const deps = queue.db.prepare("SELECT summary, qitem_id FROM queue_items WHERE blocked_on = ? AND state IN ('pending','in-progress','blocked') ORDER BY ts_created DESC LIMIT 101").all(q.qitemId) as Array<{ summary: string | null; qitem_id: string }>;
const unblocks = isHumanSeatSessionRef(q.blockedOn ?? "") ? q.summary || "The blocked task (inspect the request)" : deps.length ? deps.slice(0, 100).map(d => d.summary || "An unnamed dependent task").join("; ") + (deps.length > 100 ? "; more dependents omitted" : "") : "No dependent task recorded; inspect the request for its intended outcome.";
const toPerson = isHumanSeatSessionRef(q.destinationSession) || asked.has(q.qitemId);
// A shared channel never acts: on its ask or park the person at the terminal does, as the daemon records it.
const actorFor = (address: string) => queue.isSharedChannel(address) ? UNNAMED_PRIMARY_USER : address;
const questions = q.humanQuestions?.length ? q.humanQuestions : null;
const unanswered = questions ? unansweredQuestions(questions, q.humanAnswers ?? {}).length : 0;
const item: AttentionItem = { id: `queue:${q.qitemId}`, kind: "action", recipient: toPerson ? q.destinationSession : q.blockedOn,
@@ -68,8 +71,8 @@ export function attentionRoutes(): Hono {
// mission control's answer (a choice, their own words, or sending a complete set's recorded answers back), which
// Approve would close without returning. They can also open the asker's terminal (Chat). A row parked on them is
// resolved with their decision in words, which unparks it for its owner.
...(toPerson && q.state === "pending" ? { answer: { verb: questions ? "answer" as const : "approve" as const, actorSession: q.destinationSession } } : {}),
...(!toPerson && q.state === "blocked" && q.blockedOn && isHumanSeatSessionRef(q.blockedOn) ? { answer: { verb: "resolve" as const, actorSession: q.blockedOn } } : {}),
...(toPerson && q.state === "pending" ? { answer: { verb: questions ? "answer" as const : "approve" as const, actorSession: actorFor(q.destinationSession) } } : {}),
...(!toPerson && q.state === "blocked" && q.blockedOn && isHumanSeatSessionRef(q.blockedOn) ? { answer: { verb: "resolve" as const, actorSession: actorFor(q.blockedOn) } } : {}),
...(toPerson ? { asker: q.sourceSession } : {}),
...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}),
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
@@ -13,7 +13,7 @@ import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { addHumanFragment } from "../src/domain/gateway/human-registry.js";
import { setDefaultPerson } from "../src/domain/gateway/participants.js";
import { setDefaultPerson, setSharedChannel } from "../src/domain/gateway/participants.js";
import { attentionRoutes } from "../src/routes/attention.js";
import { queueRoutes } from "../src/routes/queue.js";
import type { AttentionRead } from "../src/attention-surface.js";
@@ -148,4 +148,18 @@ describe("the Feed shows one person's queue, or everyone's, and acts with the pr
expect(r.perspective).toBeUndefined();
expect(asked(r)).toEqual(["A seat's ask"]);
});
it("with only a shared channel registered, the Feed serves the person at the terminal as the actor, never the channel", async () => {
const CHANNEL = "acme-customers@external";
expect(setSharedChannel("acme-customers", { displayName: "Acme customers", policy: "Customers of Acme.",
channel: { connector: "slack", connectorRef: "primary", secretsRef: "vault://slack/bot", channelId: "CACME" } }, {}, home).ok).toBe(true);
const ask = await askTo(CHANNEL, "The channel's ask");
const work = await repo.create({ sourceSession: "orch-lead@r", destinationSession: "designer@r", body: "Plan", summary: "Parked on the channel", evidenceRef: "/fixture/plan", nudge: false });
await repo.update({ qitemId: work.qitemId, actorSession: "designer@r", state: "blocked", blockedOn: CHANNEL } as never);
const r = await read();
expect(r.perspective).toBeUndefined();
const item = (qitemId: string) => r.items.find((i) => i.id === `queue:${qitemId}`);
expect(item(ask.qitemId)).toMatchObject({ recipient: CHANNEL, answer: { verb: "approve", actorSession: "human@host" } });
expect(item(work.qitemId)).toMatchObject({ recipient: CHANNEL, answer: { verb: "resolve", actorSession: "human@host" } });
});
});
@@ -239,6 +239,30 @@ describe("the primary user acts on anyone's ask, recorded as themselves on that
expect(repo.getById(work.qitemId)).toMatchObject({ state: "blocked", blockedOn: CHANNEL });
});
it("without the authority, a channel can't drop, hold, route or hand off a row either; with it, the person at the terminal acts", async () => {
addChannel();
for (const body of [{ verb: "drop", reason: "spam" }, { verb: "hold", reason: "later" }, { verb: "route", destinationSession: "support@rig" },
{ verb: "handoff", destinationSession: "support@rig" }]) {
const row = await ask(CHANNEL);
expect(await act({ qitemId: row.qitemId, actorSession: CHANNEL, ...body }), body.verb)
.toMatchObject({ status: 400, body: { error: "answer_not_recorded", channel: CHANNEL } });
expect(repo.getById(row.qitemId)?.state, body.verb).toBe("pending");
}
const dropped = await ask(CHANNEL);
expect((await act({ verb: "drop", qitemId: dropped.qitemId, actorSession: CHANNEL, authority: "primary-user", reason: "spam" })).status).toBe(200);
expect(audit(dropped.qitemId)).toMatchObject({ verb: "drop", actor: "human@host" });
});
it("annotate follows the same rule: refused as the channel without the authority, the person at the terminal with it", async () => {
addChannel();
const row = await ask(CHANNEL);
expect(await act({ verb: "annotate", qitemId: row.qitemId, actorSession: CHANNEL, annotation: "The customer called" }))
.toMatchObject({ status: 400, body: { error: "answer_not_recorded", channel: CHANNEL } });
expect(db.prepare("SELECT count(*) AS n FROM mission_control_actions WHERE qitem_id = ?").get(row.qitemId)).toEqual({ n: 0 });
expect((await act({ verb: "annotate", qitemId: row.qitemId, actorSession: CHANNEL, authority: "primary-user", annotation: "The customer called" })).status).toBe(200);
expect(audit(row.qitemId)).toEqual({ verb: "annotate", actor: "human@host", notes: { authority: "primary-user", asked: null } });
});
it("leaves the legacy human seat as it was when a channel is registered", async () => {
addChannel();
const legacy = await ask("human@host");