M1-66: Gateway focus and typing¶
Status: ready | Depends on: M1-65 | Milestone: M1
Goal¶
After this ticket a client tells the gateway with the op focus which threads are on screen, and
the gateway sends focus-bound ephemeral events only to connections that focus the thread and may
read it. People see each other typing: typing.start produces the ephemeral event
typing.started for the focused readers of the thread, throttled and rate limited, ending by
itself after sync.typing_ttl. The gateway also becomes the process's EphemeralPublisher, the
single path for ephemeral events that runs, agents and notify use from M2 and M3 on.
Read first¶
docs/PLAN.md: section "Way of working per ticket"; section "M1 Foundation", rows M1-64 to M1-68; rows M2-10 and M3-23 (later users of focus and of the publisher).docs/spec/04-sync-protocol.md: "Two kinds", "Envelope", "Ops" (rowfocuswithidle, rowstyping.start/typing.stop), "Lifecycle" step 7 (resumerestores focus), "What the client receives and does on access removal" item 2 (focus ends), "Delivery guarantee" item 4, "Ordering", "From the server to the clients" item 2 (sync.max_focused_threads), "Typing and agent presence" item 1 and item 3, "Configuration values of this chapter".docs/spec/05-actions.md: "Rate-limit classes" (rl.readkeyed by principal,rl.typingkeyed by connection), action rowsgateway.focusandtyping.start, "Binding rules" rule 8 (read-only channels block typing), "Ephemeral events" (additional condition "thread in focus"), "WebSocket ops and action".docs/spec/02-architecture.md: "Cross-cutting ports" (kernel/eventlog.EphemeralPublisher), "Ports, events and sources per module" (rowgateway), "Allowed dependencies" (gatewaymay usechat), "Ephemeral events".docs/spec/13-push-notifications.md: "Active on a device" (meaning ofidle).docs/spec/01-product.md: feature row "Typing indicator (people)".docs/api/asyncapi.yaml:receive.focus(x-rate-limit-class: rl.read),receive.typing.start,receive.typing.stop, messageevent.typing.started.docs/api/openapi.yamlschemasGatewayFocus,GatewayTyping,TypingStartedEvent,TypingStartedPayload,EventEnvelope,GatewayError.- ADR
docs/adr/0017-*and 0053 (no JSON null). docs/security/threat-model.md"Gateway (WebSocket)" (row "Focus on foreign threads").- Code you build on:
internal/gateway(M1-64, M1-65: hub, queue, phases, test client),internal/kernel/eventlog,internal/kernel/authorize(Delivery(ctx)from M1-65),internal/platform/ratelimit(M1-19), thechatport (Threadsinfo, M1-55). docs/quality/gates.md,CONTRIBUTING.md,services/server/internal/gateway/README.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behaviour change has a test that was red before the change (record the red run in the report), then green, then clean up.
- No en dash or em dash (U+2013, U+2014) in any file you write.
- White label: only the examples Alex, Sam, Robin,
example.comand the spaces Support, Marketing, Engineering, Research; no paths under/Users. - Limits and intervals only through named keys from
docs/operations/configuration.md. focus {thread_ids, idle}replaces the previous set; each thread is checked withauthorize(principal, gateway.focus, thread); unreadable threads are ignored silently; at mostsync.max_focused_threadsthreads (04 "Ops", "From the server to the clients"; 05 rowgateway.focus).typing.start {thread_id}is checked with actiontyping.startand rate classrl.typing;typing.stopuses the same action and class (05 "WebSocket ops and action").- The server fans out
typing.startat most once persync.typing_min_intervaland sendstyping.started {thread_id, principal_id, expires_at}only to connections that focus the thread and may read it; without renewal the display ends aftersync.typing_ttl(04 "Typing and agent presence" item 1). - Agents send no typing of their own; a running run shows the agent's typing
(04 "Typing and agent presence" item 1; ruling L9; 01 "Typing indicator (people)"). 05 gives
typing.startthe permission "human and write", soauthorizedenies agents and apps. - Ephemeral events have no
seqand nopermission_version, are delivered at most once and are lost during a pause (04 "Two kinds", "Delivery guarantee" item 4, "Lifecycle" step 7). - When
access.changedremoves a target, the focus on threads of that target ends (04 "What the client receives and does on access removal" item 2). resumerestores the focus (04 "Lifecycle" step 7).- A failing op answers
errorwith a problem code and keeps the connection;4008is only forrl.gateway_connect(04 "Ops", "Close codes").focusoversync.max_focused_threadsanswersvalidation_failedand keeps the previous set (04 11.3 item 2).typing.stopsends nothing and resets the throttle;typing.startednever reaches the typist's own connections (04 12 item 1). Ephemeral frames are dropped when the queue is full (never4014) and never sent beforecatchup.complete(04 7.2 item 6).
Scope¶
internal/kernel/eventlog/: the typeEphemeralEventand the interfaceEphemeralPublisher(if M1-05 or M1-16 declared them, keep their shape and extend only what is missing):
// EphemeralEvent is a short-lived event (04 "Two kinds"): no seq, never stored, at most once.
type EphemeralEvent struct {
Type string // ephemeral type from docs/spec/actions.yaml
SchemaVersion int
Actor *ActorRef // absent for system events
Scope Scope // space, channel, thread ids
Resource ResourceRef // kind, id, optional version
Audience ids.ID // set only for personal ephemeral types
Payload any // encoded with the no-null encoder
}
// EphemeralPublisher hands an ephemeral event to the gateway of this process. It never waits on
// a connection. Errors only report programming faults (unregistered type, payload over
// sync.event_payload_max_bytes).
type EphemeralPublisher interface {
PublishEphemeral(ctx context.Context, ev EphemeralEvent) error
}
internal/gateway/domain/:FocusSet(dedupe, limit check,idleflag),TypingThrottle(last fan-out per connection and thread, clock-driven), the set of focus-bound ephemeral typestyping.startedandrun.updatedwith a doc comment citing 05 "Ephemeral events".internal/gateway/app/: op handlersfocus.goandtyping.go;ephemeral.goimplementingEphemeralPublisheron top of the hub; the hub gains the index thread -> focused connections and focus pruning.internal/gateway/adapters/ws/: register the opsfocus,typing.start,typing.stopin the op register (M1-64/M1-25) with action and rate class as in 05 and asyncapi.yaml, and move them fromgateway_ops.latertogateway_ops.enabledinservices/server/api/operations.yaml(P-04).internal/wire/: provide the gateway publisher as the process'skernel/eventlog.EphemeralPublisher.- Docs:
services/server/internal/gateway/README.md(English, like the existing file),services/server/CHANGELOG.mdunder "Unreleased",docs/DECISIONS.md(only decisions beyond the specification).
Out of scope¶
run.updatedpatches, coalescing and the full document on focus (M2-10); agent presence (M2-20);notification.createdand the use ofidlefor push (M3-23);command.autocomplete_requested(M2). This ticket only provides the publisher they will use.- Cross-node distribution through
NOTIFY ephemeralforcluster.mode = multi(M7-17). Design the publisher so M7-17 can add forwarding as a decorator without changing callers. - Frame conformance (M1-67); metrics (M1-68, see the note on
bothy.delivery.durationbelow). - Any durable event, catch-up or revocation logic beyond focus pruning (M1-65).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-66-gateway-focus-typing. - Conventional Commits with the module as scope (
feat(gateway): ...) and DCO sign-off. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above, except the docs and changelog entries named here.
- Follow the host rules supplied by the launcher.
- Stop only processes you started, by PID. Temporary directories only via
mktemp, removed at the end. Tests bind127.0.0.1and port 0 only; PostgreSQL throughpgtest. - Never read or print secrets. No network calls except package downloads for pinned tools.
Design notes¶
Op focus (order of steps as in 05 "Decision": rate limit before authorize)
- Rate limit
rl.readkeyed by principal (class from asyncapireceive.focus). Over the limit: frameerror {op: "focus", code: "rate_limited"}, connection stays. - Decode
GatewayFocus; malformed body follows M1-64's decode rule (4002). - Deduplicate
thread_ids. More thansync.max_focused_threadsdistinct ids: frameerror {op: "focus", code: "validation_failed", detail: "sync.max_focused_threads"}and the previous set stays (04 11.3 item 2). - For each id: load the thread through the
chatport, thenauthorize(principal, gateway.focus, thread)in the normal request context (denials are audited per R-AZ-04 by authz; no error frame). Unknown and unreadable ids are both dropped silently, with the same code path (02 "404 instead of 403"). - Replace the connection's set atomically in the hub index; store
idle. No other code readsidlein M1 (M3-23 does); do not add an accessor without a user (P-13). focusis accepted in phasecatching_uptoo; ephemeral frames still wait forlive.
Ops typing.start and typing.stop
- Only people may send them:
authorizedenies agents and apps withforbidden(subject_kind_not_allowed, 05 permission "human and write"); they geterror {op, code: "forbidden"}and the frame has no effect (requirement 13). If the requirement step of M1-28 lacks thehumanpart of a combined permission, add it there with a test (no permission logic in the gateway). - Rate limit
rl.typingkeyed by connection (ratelimit.rl.typing.*); over the limit:error {op, code: "rate_limited"}. - Decode
GatewayTyping; load the thread;authorize(principal, typing.start, thread). A denial answerserror {op, code}with the decision's code (not_found,forbidden,channel_read_only); 05 rule 8 makes read-only channels deny typing for everyone. typing.start: if the last fan-out for this connection and thread is younger thansync.typing_min_interval, do nothing (no frame, no error). Otherwise record the time and publishtyping.startedwith payload{thread_id, principal_id, expires_at = now + sync.typing_ttl}(RFC 3339 UTC with milliseconds), envelopeid(UUIDv7),schema_versionfrom the event register,occurred_at,actor {id, kind: user},scope {space_id, channel_id, thread_id},resource {kind: thread, id: thread_id}; noseq, nopermission_version.typing.stop: clears the throttle entry for this connection and thread so the nexttyping.startfans out at once; it sends no event, because 05 and asyncapi.yaml define no "typing stopped" type. Clients end the display atexpires_ator on the next message of that person.- Recipients of
typing.started: connections in phaselivethat focus the thread, minus every connection of the typing principal (04 12 item 1), each admitted withthread.readinauthorize.Delivery(ctx)(05 "Ephemeral events").
Publisher and hub
- Candidates of an ephemeral event: focus-bound type -> thread index of
scope.thread_id;Audienceset -> connections of that principal; otherwise the scope rule of M1-65. Admission: the sameAdmitpath as durable events, receive action from the event register. - Encode once per event; frame
{"op":"dispatch","d":<envelope>}. - Ephemeral frames are never delivered to a connection before its
catchup.completeand are never buffered (at most once; 04 "Flow" item 3 forbids mixing). - If enqueuing an ephemeral frame would exceed
sync.outbound_queue_max, drop that frame; never close with4014for an ephemeral frame (04 7.2 item 6). Durable frames keep M1-65's rule. PublishEphemeralnever blocks; it validates the type against the register and the payload size againstsync.event_payload_max_bytes.- Focus pruning: on P's
access.changed(inside M1-65's revocation step, before dispatch), re-check every focused thread of P's connections withgateway.focusinauthorize.Delivery(ctx)and drop the unreadable ones; onthread.moved,channel.archivedandspace.archivedre-check the connections that focus an affected thread. resume: the focus set lives in the in-memory gateway session; when M1-64'sresumerestores the session, re-check each thread as in pruning and keep the readable ones.
Metrics: none in this ticket. M1-68 depends on this ticket and records bothy.delivery.duration
for the durable and the ephemeral path.
Configuration keys used: sync.max_focused_threads, sync.typing_min_interval,
sync.typing_ttl, sync.outbound_queue_max, sync.event_payload_max_bytes,
ratelimit.rl.typing.per_minute, ratelimit.rl.typing.burst, ratelimit.rl.read.per_minute,
ratelimit.rl.read.burst.
No rulings of its own: the behaviour of requirements 10 to 17 is in 04 ("Ops", "Flow",
"From the server to the clients", "Typing and agent presence") and 05 (typing.start). Record in
docs/DECISIONS.md only what the build had to decide beyond them.
Tests (write them first)¶
Unit tests (fake Authorizer, fake chat port, test clock, fake rate limiter):
TestFocusSet_DedupeAndLimit,TestFocus_OverLimitRejectedKeepsPrevious.TestFocus_UnreadableAndUnknownIgnoredSilently: no error frame, only readable ids kept.TestFocus_ReplacesPreviousSet,TestFocus_IdleStored,TestFocus_RateLimitedErrorFrame.TestTyping_ThrottlePerConnectionAndThread: second start insidesync.typing_min_intervalno fan-out; after the interval fan-out; aftertyping.stopimmediate fan-out.TestTyping_RateLimitedErrorFrame(classrl.typing, key connection).TestTyping_OnlyPeople: agent connection getserror forbidden, nothing published.TestTyping_DeniedCodes: table unreadable thread ->not_found,readgrant ->forbidden, read-only channel ->channel_read_only.TestTypingStarted_OnlyFocusedReaders: focused reader receives; reader without focus does not; focused connection without read permission does not; the typist's own second connection does not.TestTypingStarted_EnvelopeAndExpiry: noseq, nopermission_version,expires_at = occurred_at + sync.typing_ttl.TestEphemeral_NotDeliveredDuringCatchUp,TestEphemeral_DroppedWhenQueueFullNoClose,TestEphemeral_PublishNeverBlocks,TestEphemeral_UnregisteredTypeRejected.TestFocus_PrunedOnAccessChanged,TestFocus_RecheckedOnThreadMoved,TestFocus_RestoredOnResume.
Integration test against PostgreSQL 18 with real modules and M1-64's test client:
TestGateway_TypingEndToEnd: Alex and Sam havewriteon Support, Robin has no grant; Sam and Robin focus the same thread id (Robin's focus is dropped); Alex sendstyping.start; Sam receivestyping.startedwith the right scope andexpires_at; Robin and Alex's own second device receive nothing.
Commands that must pass:
cd services/server && go test -race ./internal/gateway/... ./internal/kernel/... ./internal/wire/...
make test
make check
Acceptance criteria¶
-
focuskeeps at mostsync.max_focused_threadsthreads, ignores unreadable and unknown ids silently, replaces the previous set and storesidle. -
typing.startfans out at most once persync.typing_min_intervalper connection and thread and is limited byrl.typing. -
typing.startedreaches only live connections with focus on the thread and read permission, never the typist's own connections, and carriesexpires_at = occurred_at + sync.typing_ttl. - Agents and apps cannot produce typing.
- Focus ends for targets removed by
access.changedand survivesresumewhere still readable. - The gateway is the process's
EphemeralPublisher; ephemeral frames never close a connection and never interleave with catch-up. -
make checkgreen.
Definition of done¶
- Code and tests as above; doc comment on every public symbol (purpose, parameters, return value, errors); comments explain why.
- Gateway README updated (ops, config keys, ephemeral rules); rulings in
docs/DECISIONS.md;services/server/CHANGELOG.mdentry under "Unreleased". Write an ADR only if you take an architecture decision beyond this brief. make checkgreen.- Independent review (
make reviewplus a code review over the diff) and the red team gate (docs/quality/red-team-gate.md) over the diff, verdict recorded in the report. - Merge locally into the base branch only if review and red team are green and
make checkis green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then
commits, files with line counts, test results with counts (including the red runs), what is not
proven, and follow-ups that could not be done in scope with the reason. Then write
{{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.