oficina / meet Public

Calls: quality and problem report endpoints, tags and tokens in the hub (spec 15.26), WIP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c5243a1a087c39dad423b00610cf45b421c2f2dd parent de04e2d
12 files changed, +950 −6
  • cmd/calls/main.go +24 −1
  • internal/calls/app.go +16 −0
  • internal/calls/config.go +14 −1
  • internal/calls/hub/hub.go +18 −1
  • internal/calls/hub/relay.go +3 −1
  • internal/calls/hub/report.go +159 −0
  • internal/calls/quality.go +477 −0
  • internal/calls/ratelimit.go +6 −0
  • internal/calls/stream.go +2 −2
  • internal/callstest/browser_test.go +36 −0
  • protocol/messages.go +4 −0
  • protocol/quality.go +191 −0
diff --git a/cmd/calls/main.go b/cmd/calls/main.go
index 085cf42..1cd452d 100644
--- a/cmd/calls/main.go
+++ b/cmd/calls/main.go
@@ -3,10 +3,12 @@ package main
import (
"context"
"fmt"
+ "io"
"log/slog"
"os"
"os/signal"
"strings"
+ "sync"
"syscall"
_ "time/tzdata" // the browser's zone for a call's title, wherever the host has no zoneinfo
@@ -19,7 +21,14 @@ import (
)
func main() {
- logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+ // One writer for both handlers, so a text line and a JSON line are
+ // never interleaved mid-write.
+ stderr := &lockedWriter{w: os.Stderr}
+ logger := slog.New(slog.NewTextHandler(stderr, nil))
+ // Quality and problem reports are JSON lines (spec §15.26): the
+ // platform's `carlos logs --field` reads fields only from a line that
+ // is a JSON object.
+ reportLogger := slog.New(slog.NewJSONHandler(stderr, nil))
// -env is stripped before Resolve sees argv, because Resolve parses
// os.Args itself for -socket, -addr and -db and a second flag set
@@ -63,6 +72,8 @@ func main() {
Relay: os.Getenv("CARLOS_RELAY"),
Zone: os.Getenv("CALLS_ZONE"),
Logger: logger,
+ QualityReports: os.Getenv("CALLS_QUALITY_REPORTS"),
+ ReportLogger: reportLogger,
}
// Cancelled on SIGTERM, which is how a live SSE stream learns the
@@ -158,3 +169,15 @@ func extractEnvFlag(args []string) (env string, rest []string, err error) {
}
return env, rest, nil
}
+
+// lockedWriter serializes writes from the two log handlers.
+type lockedWriter struct {
+ mu sync.Mutex
+ w io.Writer
+}
+
+func (l *lockedWriter) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.w.Write(p)
+}
diff --git a/internal/calls/app.go b/internal/calls/app.go
index a359d81..0f1de31 100644
--- a/internal/calls/app.go
+++ b/internal/calls/app.go
@@ -55,6 +55,14 @@ type app struct {
// apiLimiter is the drive surface's per-agent token bucket (spec
// §8): sixty requests a minute, burst sixty, keyed "agent:<id>".
apiLimiter *rateLimiter
+ // qualityLimiter and problemLimiter are the report endpoints' (spec
+ // §15.26), keyed on the reporting participation's tag: a quality
+ // report every ten seconds, burst six; a problem report a minute,
+ // burst three.
+ qualityLimiter *rateLimiter
+ problemLimiter *rateLimiter
+ // reportLog is where the reports go: Config.ReportLogger.
+ reportLog *slog.Logger
// tickPass is the housekeeping pass tickLoop runs, nil in production
// and everywhere but the one test that records what deadline the
@@ -411,6 +419,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
})
a.signalLimiter = newRateLimiter(30, 60, maxSignalBuckets)
a.apiLimiter = newRateLimiter(apiRatePerSecond, apiBurst, maxAgentBuckets)
+ a.qualityLimiter = newRateLimiterEvery(10*time.Second, 6, maxReportBuckets)
+ a.problemLimiter = newRateLimiterEvery(time.Minute, 3, maxReportBuckets)
+ a.reportLog = rc.ReportLogger
+ if !rc.qualityReports {
+ rc.Logger.Info("calls: quality reports off (CALLS_QUALITY_REPORTS=off)")
+ }
writer, err := d.G.DB()
if err != nil {
@@ -537,6 +551,8 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Get("/m/{sqid}/stream", a.stream)
r.Post("/m/{sqid}/signal", a.signal)
r.Get("/m/{sqid}/ice", a.ice)
+ r.Post("/m/{sqid}/quality", a.quality)
+ r.Post("/m/{sqid}/problem", a.problem)
})
})
diff --git a/internal/calls/config.go b/internal/calls/config.go
index 5903f98..6f93536 100644
--- a/internal/calls/config.go
+++ b/internal/calls/config.go
@@ -75,6 +75,15 @@ type Config struct {
// version one.
Zone string
Logger *slog.Logger
+ // QualityReports is CALLS_QUALITY_REPORTS: "off" turns the automatic
+ // quality reports off (spec §15.26), and the endpoint answers 204 so
+ // clients stop sending. Anything else, unset included, is on.
+ QualityReports string
+ // ReportLogger is where quality and problem reports are written. The
+ // deployed main hands it a JSON handler, because the platform's
+ // `carlos logs --field` reads fields only from JSON lines. Nil is
+ // Logger.
+ ReportLogger *slog.Logger
}
func (cfg Config) development() bool { return cfg.Env == EnvDevelopment }
@@ -96,13 +105,17 @@ type resolved struct {
relay []relay.Region
relayConfigured bool
zone *time.Location
+ qualityReports bool
}
func resolve(cfg Config) (resolved, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
- out := resolved{Config: cfg}
+ out := resolved{Config: cfg, qualityReports: qualityReportsOn(cfg.QualityReports)}
+ if out.ReportLogger == nil {
+ out.ReportLogger = cfg.Logger
+ }
if strings.TrimSpace(cfg.Origin) == "" {
if !cfg.development() {
diff --git a/internal/calls/hub/hub.go b/internal/calls/hub/hub.go
index 59c3619..5a6bd2e 100644
--- a/internal/calls/hub/hub.go
+++ b/internal/calls/hub/hub.go
@@ -109,6 +109,11 @@ type Participation struct {
lastSeen map[PeerID]uint64
ring []Message // targeted messages delivered to this peer, newest last, cap RingSize
evicted map[PeerID]uint64 // highest seq per sender the ring has dropped
+ // peerTag and reportToken are spec §15.26's: the random tag this
+ // participation's reports are logged under, and the token its client
+ // reports with (report.go).
+ peerTag string
+ reportToken string
}
type pairKey struct{ a, b PeerID }
@@ -125,6 +130,9 @@ type room struct {
peers map[PeerID]*Participation
gens map[pairKey]uint64
ended bool
+ // call is the call instance now running here (report.go): replaced
+ // when a participation opens in a room that held nobody.
+ call *callInstance
}
// tombstone remembers why a participation ended, for ten minutes, so a
@@ -148,6 +156,7 @@ type Hub struct {
bySession map[string]map[string]PeerID // room -> session -> peer
byPerson map[int64]int // open streams per person
tombstones map[PeerID]tombstone
+ reports map[string]*reportGrant // report token -> grant (report.go)
}
func New(cfg Config) *Hub {
@@ -161,6 +170,7 @@ func New(cfg Config) *Hub {
cfg: cfg, boot: randomID(),
rooms: map[string]*room{}, bySession: map[string]map[string]PeerID{},
byPerson: map[int64]int{}, tombstones: map[PeerID]tombstone{},
+ reports: map[string]*reportGrant{},
}
}
@@ -190,6 +200,9 @@ type OpenResult struct {
Peer PeerID
Sub *Sub
Existing PeerID
+ // ReportToken is the client's authority to send quality and problem
+ // reports (spec §15.26), carried in its hello.
+ ReportToken string
}
// Open is a fresh stream: a new unjoined participation, subject to the
@@ -229,13 +242,15 @@ func (h *Hub) Open(sqid, session string, personID int64, name string, kind Kind)
Peer: PeerID(randomID()), Room: sqid, Session: session, PersonID: personID, Name: name, Kind: kind,
OpenedAt: h.cfg.Now(), sub: newSub(), seqOut: map[PeerID]uint64{}, lastSeen: map[PeerID]uint64{},
}
+ h.enrolReportLocked(r, p) // before p joins r.peers: an empty room starts a new call
+
r.peers[p.Peer] = p
if h.bySession[sqid] == nil {
h.bySession[sqid] = map[string]PeerID{}
}
h.bySession[sqid][session] = p.Peer
h.byPerson[personID]++
- return OpenResult{Peer: p.Peer, Sub: p.sub}, nil
+ return OpenResult{Peer: p.Peer, Sub: p.sub, ReportToken: p.reportToken}, nil
}
// Find answers a peer only through its own room and session.
@@ -382,6 +397,7 @@ func (h *Hub) endLocked(r *room, p *Participation, reason Reason) (wasAdmitted,
p.sub = nil
}
delete(r.peers, p.Peer)
+ h.endReportLocked(p)
if h.bySession[r.sqid][p.Session] == p.Peer {
delete(h.bySession[r.sqid], p.Session)
}
@@ -432,6 +448,7 @@ func (h *Hub) Tick(ctx context.Context) {
delete(h.tombstones, id)
}
}
+ h.sweepReportsLocked(now)
var checks []standingCheck
for _, r := range h.rooms {
for _, p := range r.peers {
diff --git a/internal/calls/hub/relay.go b/internal/calls/hub/relay.go
index 7e5cdc7..9745e7b 100644
--- a/internal/calls/hub/relay.go
+++ b/internal/calls/hub/relay.go
@@ -131,6 +131,8 @@ type ResumeResult struct {
Roster *Roster
Replay []Message
Gap []PeerID
+ // ReportToken is the participation's, for the resumed hello (§15.26).
+ ReportToken string
}
// ResumeError is a refused resume in the shape spec §4.5's table asks
@@ -246,7 +248,7 @@ func (h *Hub) Resume(ctx context.Context, sqid, session string, peer PeerID, las
}
p.sub = newSub()
p.resumeUntil = time.Time{}
- res := ResumeResult{Sub: p.sub, Admitted: p.Admitted}
+ res := ResumeResult{Sub: p.sub, Admitted: p.Admitted, ReportToken: p.reportToken}
if !p.Admitted {
// A waiting guest learns nothing by resuming (ruling 9).
return res, nil
diff --git a/internal/calls/hub/report.go b/internal/calls/hub/report.go
new file mode 100644
index 0000000..6530ed3
--- /dev/null
+++ b/internal/calls/hub/report.go
@@ -0,0 +1,159 @@
+package hub
+
+import (
+ "crypto/rand"
+ "encoding/base32"
+ "strings"
+ "time"
+)
+
+// Quality and problem reports (spec §15.26, 2026-10-03).
+//
+// A report is written to the app log under two random tags and nothing
+// that names the call or the people in it: a call tag, minted when a room
+// that was empty gains a participation, so every line from one call shares
+// it; and a peer tag, minted per participation. Neither is derived from
+// the sqid, the peer id, the person or the session, and neither is ever
+// logged beside any of them, so a log line cannot be walked back to a
+// meeting or a person. They live here, in memory, and die with the
+// process like everything else in the hub.
+//
+// A client is handed a report token in its hello. It is the client's
+// authority to report: for the life of the participation, and for
+// ReportAfterLeave after it ends, so "Report a problem" still works on the
+// screen that says the call is over.
+
+const (
+ // ReportAfterLeave is how long a report token outlives its
+ // participation.
+ ReportAfterLeave = 10 * time.Minute
+ // maxReportGrants bounds the token table: every live participation
+ // (at most sixteen to a room) plus those ended in the last ten
+ // minutes. A real deployment holds tens; past this, the oldest ended
+ // grant is dropped first.
+ maxReportGrants = 4096
+ // maxCallPeers bounds how many participations one call instance
+ // remembers a tag for. A remote peer past it is reported as unknown.
+ maxCallPeers = 64
+)
+
+// callInstance is one call: a room from the moment it stopped being empty
+// to the moment it is empty again.
+type callInstance struct {
+ tag string
+ peers map[PeerID]string // peer id -> peer tag, for every participation this call has held
+}
+
+type reportGrant struct {
+ room string
+ session string
+ call *callInstance
+ peerTag string
+ endedAt time.Time // zero while the participation lives
+}
+
+// Reporter is what a report token authorizes: the tags to log under,
+// and the tags of everyone this call has held, by peer id, so a report
+// that names a remote peer by its id is logged under that peer's tag.
+type Reporter struct {
+ CallTag string
+ PeerTag string
+ Live bool
+ Peers map[PeerID]string
+}
+
+// newTag is a random tag: ten bytes in lowercase base32, sixteen
+// characters, from crypto/rand. Not a hash of anything.
+func newTag() string {
+ b := make([]byte, 10)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
+}
+
+// enrolReportLocked gives a fresh participation its peer tag and report
+// token, starting a new call instance when the room held nobody.
+func (h *Hub) enrolReportLocked(r *room, p *Participation) {
+ if r.call == nil || len(r.peers) == 0 {
+ r.call = &callInstance{tag: newTag(), peers: map[PeerID]string{}}
+ }
+ p.peerTag = newTag()
+ p.reportToken = randomID()
+ if len(r.call.peers) < maxCallPeers {
+ r.call.peers[p.Peer] = p.peerTag
+ }
+ if len(h.reports) >= maxReportGrants {
+ h.evictReportLocked()
+ }
+ h.reports[p.reportToken] = &reportGrant{room: r.sqid, session: p.Session, call: r.call, peerTag: p.peerTag}
+}
+
+// endReportLocked starts a participation's after-leave window.
+func (h *Hub) endReportLocked(p *Participation) {
+ if g, ok := h.reports[p.reportToken]; ok && g.endedAt.IsZero() {
+ g.endedAt = h.cfg.Now()
+ }
+}
+
+// sweepReportsLocked drops grants whose after-leave window has passed.
+func (h *Hub) sweepReportsLocked(now time.Time) {
+ for token, g := range h.reports {
+ if !g.endedAt.IsZero() && now.Sub(g.endedAt) > ReportAfterLeave {
+ delete(h.reports, token)
+ }
+ }
+}
+
+// evictReportLocked makes room: the grant that ended longest ago, or, if
+// none has ended, nothing — a live participation's token is never taken
+// from it, and the table is then bounded by the participations
+// themselves.
+func (h *Hub) evictReportLocked() {
+ var oldest string
+ var at time.Time
+ for token, g := range h.reports {
+ if g.endedAt.IsZero() {
+ continue
+ }
+ if oldest == "" || g.endedAt.Before(at) {
+ oldest, at = token, g.endedAt
+ }
+ }
+ if oldest != "" {
+ delete(h.reports, oldest)
+ }
+}
+
+// ReportTokenFor is the token of the participation this session holds in
+// the room under peer, for a resumed stream's hello.
+func (h *Hub) ReportTokenFor(sqid, session string, peer PeerID) string {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ p, ok := h.findLocked(sqid, session, peer)
+ if !ok {
+ return ""
+ }
+ return p.reportToken
+}
+
+// Reporter answers a report token, presented by this session for this
+// room: the participation's while it lives, and for ReportAfterLeave
+// after it ends. Anything else is not found.
+func (h *Hub) Reporter(sqid, session, token string) (Reporter, bool) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ g, ok := h.reports[token]
+ if !ok || g.room != sqid || g.session != session {
+ return Reporter{}, false
+ }
+ if !g.endedAt.IsZero() && h.cfg.Now().Sub(g.endedAt) > ReportAfterLeave {
+ delete(h.reports, token)
+ return Reporter{}, false
+ }
+ peers := make(map[PeerID]string, len(g.call.peers))
+ for id, tag := range g.call.peers {
+ peers[id] = tag
+ }
+ return Reporter{CallTag: g.call.tag, PeerTag: g.peerTag, Live: g.endedAt.IsZero(), Peers: peers}, true
+}
diff --git a/internal/calls/quality.go b/internal/calls/quality.go
new file mode 100644
index 0000000..a0d3776
--- /dev/null
+++ b/internal/calls/quality.go
@@ -0,0 +1,477 @@
+package calls
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "math"
+ "net/http"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/oficina/calls/internal/calls/hub"
+ "amadan.net/oficina/calls/protocol"
+)
+
+// Quality and problem reports, spec §15.26 (Paul's ruling, 2026-10-03).
+//
+// Two endpoints, both authorized by the report token a client was given
+// in its hello (hub/report.go), presented by the session it was given to,
+// for the room it was given for — during the participation and for ten
+// minutes after it ends:
+//
+// - POST /m/{sqid}/quality: the automatic report, every thirty seconds
+// and once at leave. A fixed schema (protocol/quality.go), validated
+// strictly, re-encoded from what was validated, and written as one
+// `calls: quality` line. CALLS_QUALITY_REPORTS=off answers 204 before
+// anything else is looked at, and a client that is answered 204 stops
+// sending for the rest of the call.
+// - POST /m/{sqid}/problem: "Report a problem". The person's note and
+// their client's call diagnostics, written as one `calls: problem
+// report` line and as many `calls: problem report diagnostics` lines as
+// the diagnostics need, under a reference the client shows.
+//
+// Neither touches the database: the log is the only store. Neither log
+// line carries the meeting's sqid, a peer id, a person, an email, a name
+// or an address; the call and the participation are named only by the
+// hub's random tags.
+
+const (
+ // reportTokenHeader carries the token: a header rather than a body
+ // member so the body stays exactly the schema.
+ reportTokenHeader = "X-Calls-Report"
+ // maxReportBuckets bounds both report limiters' maps, as
+ // maxSignalBuckets does the signal limiter's.
+ maxReportBuckets = 4096
+ // diagnosticsChunk is how much escaped diagnostics text goes in one
+ // log line. The platform keeps 8 KB of a line and truncates the rest
+ // (carlos/platform internal/activator/backend_exec.go execLogLineMax),
+ // so a chunk leaves room for the line's own fields.
+ diagnosticsChunk = 6000
+)
+
+var (
+ reportTokenPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
+ versionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$`)
+ numericVersion = regexp.MustCompile(`^[0-9]{1,4}([._][0-9]{1,6}){0,3}$`)
+
+ reportApps = []string{"web", "mac", "iphone", "ipad"}
+ reportBrowsers = []string{"chrome", "safari", "firefox", "edge", "other"}
+ reportOSes = []string{"macos", "ios", "ipados", "windows", "linux", "android", "chromeos", "other"}
+)
+
+// qualityReportsOn is the kill switch: CALLS_QUALITY_REPORTS=off turns the
+// automatic reports off. Anything else, including unset, is on.
+func qualityReportsOn(v string) bool {
+ return !strings.EqualFold(strings.TrimSpace(v), "off")
+}
+
+// reporter resolves the request's report token, or answers 404 itself.
+func (a *app) reporter(w http.ResponseWriter, r *http.Request) (hub.Reporter, string, bool) {
+ sqid := chi.URLParam(r, "sqid")
+ token := r.Header.Get(reportTokenHeader)
+ session, ok := a.sessionKey(r)
+ if !ok || !validSqid(sqid) || !reportTokenPattern.MatchString(token) {
+ streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"})
+ return hub.Reporter{}, "", false
+ }
+ rep, ok := a.hub.Reporter(sqid, session, token)
+ if !ok {
+ streamJSON(w, http.StatusNotFound, map[string]any{"error": "not-found"})
+ return hub.Reporter{}, "", false
+ }
+ return rep, sqid, true
+}
+
+// readReport reads a body up to limit, answering 413 or 400 itself.
+func readReport(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, bool) {
+ r.Body = http.MaxBytesReader(w, r.Body, limit)
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ streamJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "too-large"})
+ } else {
+ streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"})
+ }
+ return nil, false
+ }
+ return raw, true
+}
+
+func rateRefused(w http.ResponseWriter, retry time.Duration) {
+ w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
+ streamJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate"})
+}
+
+// quality is POST /m/{sqid}/quality.
+func (a *app) quality(w http.ResponseWriter, r *http.Request) {
+ if !a.cfg.qualityReports {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ rep, _, ok := a.reporter(w, r)
+ if !ok {
+ return
+ }
+ if allowed, retry := a.qualityLimiter.allow(rep.PeerTag, a.store.now()); !allowed {
+ rateRefused(w, retry)
+ return
+ }
+ raw, ok := readReport(w, r, protocol.MaxQualityBody)
+ if !ok {
+ return
+ }
+ q, ok := validateQuality(raw)
+ if !ok {
+ streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"})
+ return
+ }
+ a.logQuality(rep, q)
+ w.WriteHeader(http.StatusAccepted)
+}
+
+// problem is POST /m/{sqid}/problem.
+func (a *app) problem(w http.ResponseWriter, r *http.Request) {
+ rep, sqid, ok := a.reporter(w, r)
+ if !ok {
+ return
+ }
+ if allowed, retry := a.problemLimiter.allow(rep.PeerTag, a.store.now()); !allowed {
+ rateRefused(w, retry)
+ return
+ }
+ raw, ok := readReport(w, r, protocol.MaxProblemBody)
+ if !ok {
+ return
+ }
+ p, ok := validateProblem(raw)
+ if !ok {
+ streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad-request"})
+ return
+ }
+ ref := newReportRef()
+ a.logProblem(rep, sqid, ref, p)
+ streamJSON(w, http.StatusOK, map[string]any{"ref": ref})
+}
+
+// validateQuality is the strict half: the schema's types (a mistyped
+// member fails the decode), its enums, its bounds and its version. A
+// member the schema does not name is ignored, never logged: the log line
+// is re-encoded from this value, not from the request's bytes.
+func validateQuality(raw []byte) (protocol.QualityReport, bool) {
+ var q protocol.QualityReport
+ if !utf8.Valid(raw) || json.Unmarshal(raw, &q) != nil {
+ return q, false
+ }
+ if q.V != protocol.QualityVersion || q.Seconds == nil || *q.Seconds <= 0 {
+ return q, false
+ }
+ if !validClient(&q.Client) {
+ return q, false
+ }
+ if d := q.Devices; d != nil && (!enumOrEmpty(d.Input, protocol.DeviceKinds) || !enumOrEmpty(d.Output, protocol.DeviceKinds)) {
+ return q, false
+ }
+ if len(q.Peers) > protocol.MaxQualityPeers {
+ return q, false
+ }
+ seen := map[protocol.PeerID]bool{}
+ for i := range q.Peers {
+ p := &q.Peers[i]
+ if !validPeerID(p.Peer) || seen[p.Peer] {
+ return q, false
+ }
+ seen[p.Peer] = true
+ if p.Path != nil && (!enumOrEmpty(p.Path.Local, protocol.CandidateKinds) ||
+ !enumOrEmpty(p.Path.Remote, protocol.CandidateKinds) || !enumOrEmpty(p.Path.Protocol, protocol.PathProtocols)) {
+ return q, false
+ }
+ if p.Out != nil {
+ for _, s := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} {
+ if s != nil && !enumOrEmpty(s.Limit, protocol.QualityLimits) {
+ return q, false
+ }
+ }
+ }
+ }
+ // Every number, wherever it is: finite, non-negative, under its
+ // ceiling, and rounded to a tenth for the line.
+ if !boundNumbers(reflect.ValueOf(&q).Elem(), "") {
+ return q, false
+ }
+ return q, true
+}
+
+func validClient(c *protocol.ReportClient) bool {
+ if !slices.Contains(reportApps, c.App) {
+ return false
+ }
+ if c.Version != "" && !versionPattern.MatchString(c.Version) {
+ return false
+ }
+ if !enumOrEmpty(c.Browser, reportBrowsers) || !enumOrEmpty(c.OS, reportOSes) {
+ return false
+ }
+ if c.BrowserVersion != "" && !numericVersion.MatchString(c.BrowserVersion) {
+ return false
+ }
+ if c.OSVersion != "" && !numericVersion.MatchString(c.OSVersion) {
+ return false
+ }
+ return true
+}
+
+func enumOrEmpty(v string, allowed []string) bool { return v == "" || slices.Contains(allowed, v) }
+
+// numberCeiling is a number's bound, by its JSON name.
+func numberCeiling(name string) float64 {
+ switch name {
+ case "seconds", "reconnectingSeconds", "cantHearSeconds", "freezeSeconds":
+ return protocol.MaxQualitySeconds
+ case "rtt", "jitter", "jitterBufferMs":
+ return protocol.MaxQualityMillis
+ case "kbps":
+ return protocol.MaxQualityKbps
+ case "width", "height":
+ return protocol.MaxQualityPixels
+ case "fps":
+ return protocol.MaxQualityFPS
+ }
+ return protocol.MaxQualityCount
+}
+
+// boundNumbers walks the report and checks every *float64 against its
+// ceiling, rounding each to one decimal place.
+func boundNumbers(v reflect.Value, name string) bool {
+ switch v.Kind() {
+ case reflect.Pointer:
+ if v.IsNil() {
+ return true
+ }
+ if f, ok := v.Interface().(*float64); ok {
+ x := *f
+ if math.IsNaN(x) || math.IsInf(x, 0) || x < 0 || x > numberCeiling(name) {
+ return false
+ }
+ *f = math.Round(x*10) / 10
+ return true
+ }
+ return boundNumbers(v.Elem(), name)
+ case reflect.Struct:
+ t := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ tag, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
+ if !boundNumbers(v.Field(i), tag) {
+ return false
+ }
+ }
+ case reflect.Slice:
+ for i := 0; i < v.Len(); i++ {
+ if !boundNumbers(v.Index(i), name) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// validateProblem bounds the note (characters) and the whole body (bytes,
+// by the reader), and the client as the quality report does.
+func validateProblem(raw []byte) (protocol.ProblemReport, bool) {
+ var p protocol.ProblemReport
+ if !utf8.Valid(raw) || json.Unmarshal(raw, &p) != nil {
+ return p, false
+ }
+ if utf8.RuneCountInString(p.Note) > protocol.MaxProblemNote || !validClient(&p.Client) {
+ return p, false
+ }
+ return p, true
+}
+
+// logQuality writes the one `calls: quality` line. The top level carries
+// what the platform can filter on (carlos logs --field): the tags, the
+// client, and a few headline numbers; `report` is the rest, as validated,
+// with each remote peer's id replaced by its tag.
+func (a *app) logQuality(rep hub.Reporter, q protocol.QualityReport) {
+ sum := summarize(q)
+ for i := range q.Peers {
+ q.Peers[i].Peer = protocol.PeerID(tagOrUnknown(rep, q.Peers[i].Peer))
+ }
+ body := struct {
+ Seconds *float64 `json:"seconds"`
+ Final bool `json:"final,omitempty"`
+ Devices *protocol.Devices `json:"devices,omitempty"`
+ Events *protocol.CallEvents `json:"events,omitempty"`
+ Peers []protocol.PeerQuality `json:"peers,omitempty"`
+ }{q.Seconds, q.Final, q.Devices, q.Events, q.Peers}
+ attrs := append(clientAttrs(rep, q.Client),
+ slog.Bool("final", q.Final),
+ slog.Float64("seconds", *q.Seconds),
+ slog.Int("remotes", len(q.Peers)),
+ slog.Bool("relay", sum.relay),
+ slog.Float64("maxLossPct", sum.lossPct),
+ slog.Float64("maxRtt", sum.rtt),
+ slog.Float64("freezes", sum.freezes),
+ slog.Float64("reconnecting", sum.reconnecting),
+ slog.Any("report", body),
+ )
+ a.reportLog.LogAttrs(context.Background(), slog.LevelInfo, "calls: quality", attrs...)
+}
+
+func clientAttrs(rep hub.Reporter, c protocol.ReportClient) []slog.Attr {
+ attrs := []slog.Attr{slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag), slog.String("app", c.App)}
+ for _, kv := range [][2]string{{"version", c.Version}, {"browser", c.Browser}, {"browserVersion", c.BrowserVersion}, {"os", c.OS}, {"osVersion", c.OSVersion}} {
+ if kv[1] != "" {
+ attrs = append(attrs, slog.String(kv[0], kv[1]))
+ }
+ }
+ return attrs
+}
+
+func tagOrUnknown(rep hub.Reporter, id protocol.PeerID) string {
+ if tag, ok := rep.Peers[id]; ok {
+ return tag
+ }
+ return "unknown"
+}
+
+type qualitySummary struct {
+ relay bool
+ lossPct, rtt, freezes, reconnecting float64
+}
+
+// summarize is the line's headline: whether any connection went through
+// the relay, the worst inbound loss as a percentage of packets, the worst
+// round trip, the freezes and the "Reconnecting…" notices.
+func summarize(q protocol.QualityReport) qualitySummary {
+ var s qualitySummary
+ val := func(p *float64) float64 {
+ if p == nil {
+ return 0
+ }
+ return *p
+ }
+ loss := func(packets, lost *float64) {
+ n, l := val(packets), val(lost)
+ if n+l > 0 {
+ s.lossPct = max(s.lossPct, math.Round(1000*l/(n+l))/10)
+ }
+ }
+ if q.Events != nil {
+ s.reconnecting += val(q.Events.Reconnecting)
+ }
+ for _, p := range q.Peers {
+ if p.Path != nil && (p.Path.Local == "relay" || p.Path.Remote == "relay") {
+ s.relay = true
+ }
+ s.rtt = max(s.rtt, val(p.RTT))
+ if p.In != nil {
+ if p.In.Audio != nil {
+ loss(p.In.Audio.Packets, p.In.Audio.Lost)
+ }
+ for _, v := range []*protocol.InVideo{p.In.Video, p.In.Screen} {
+ if v != nil {
+ loss(v.Packets, v.Lost)
+ s.freezes += val(v.Freezes)
+ }
+ }
+ }
+ if p.Out != nil {
+ for _, o := range []*protocol.OutStream{p.Out.Audio, p.Out.Video, p.Out.Screen} {
+ if o != nil {
+ s.rtt = max(s.rtt, val(o.RTT))
+ }
+ }
+ }
+ if p.Events != nil {
+ s.reconnecting += val(p.Events.Reconnecting)
+ }
+ }
+ return s
+}
+
+// logProblem writes the report: one head line with the note, and the
+// diagnostics in as many lines as they take, each under the reference.
+//
+// The diagnostics are the client's own text, and are scrubbed here of
+// what the client could not know to leave out: every peer id this call
+// has held, in full or as the six-character prefix the diagnostics use,
+// becomes "peer:<tag>", and the meeting's sqid becomes "[meeting]". A
+// client already writes "peer abc123" for a person instead of their
+// name; device names stay, which the spec says.
+func (a *app) logProblem(rep hub.Reporter, sqid, ref string, p protocol.ProblemReport) {
+ scrub := func(s string) string {
+ s = strings.ReplaceAll(s, sqid, "[meeting]")
+ for id, tag := range rep.Peers {
+ s = strings.ReplaceAll(s, string(id), "peer:"+tag)
+ }
+ for id, tag := range rep.Peers {
+ s = strings.ReplaceAll(s, string(id)[:6], "peer:"+tag)
+ }
+ return s
+ }
+ note := scrub(p.Note)
+ chunks := chunkEscaped(scrub(p.Diagnostics), diagnosticsChunk)
+ attrs := append([]slog.Attr{slog.String("ref", ref)}, clientAttrs(rep, p.Client)...)
+ attrs = append(attrs, slog.Bool("live", rep.Live), slog.String("note", note), slog.Int("parts", len(chunks)))
+ a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report", attrs...)
+ for i, c := range chunks {
+ a.reportLog.LogAttrs(context.Background(), slog.LevelWarn, "calls: problem report diagnostics",
+ slog.String("ref", ref), slog.String("call", rep.CallTag), slog.String("peer", rep.PeerTag),
+ slog.Int("part", i+1), slog.Int("of", len(chunks)), slog.String("text", c))
+ }
+}
+
+// chunkEscaped splits s into pieces whose JSON-escaped length is at most
+// limit bytes, never inside a rune.
+func chunkEscaped(s string, limit int) []string {
+ if s == "" {
+ return nil
+ }
+ var out []string
+ var b strings.Builder
+ size := 0
+ for _, r := range s {
+ enc, _ := json.Marshal(string(r))
+ n := len(enc) - 2
+ if size+n > limit && b.Len() > 0 {
+ out = append(out, b.String())
+ b.Reset()
+ size = 0
+ }
+ b.WriteRune(r)
+ size += n
+ }
+ if b.Len() > 0 {
+ out = append(out, b.String())
+ }
+ return out
+}
+
+// refAlphabet is Crockford's: no I, L, O or U, so a reference read aloud
+// or copied by hand comes back the same.
+const refAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+// newReportRef is a problem report's reference: eight characters, forty
+// random bits.
+func newReportRef() string {
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ for i := range b {
+ b[i] = refAlphabet[int(b[i])%len(refAlphabet)]
+ }
+ return string(b)
+}
diff --git a/internal/calls/ratelimit.go b/internal/calls/ratelimit.go
index 01aa9b4..6e2b238 100644
--- a/internal/calls/ratelimit.go
+++ b/internal/calls/ratelimit.go
@@ -50,6 +50,12 @@ func newRateLimiter(rate, burst, maxKeys int) *rateLimiter {
return &rateLimiter{rate: float64(rate), burst: float64(burst), maxKeys: maxKeys, buckets: map[string]*tokenBucket{}}
}
+// newRateLimiterEvery is a limiter that adds one token per interval: for
+// the report endpoints, whose rates are slower than one a second.
+func newRateLimiterEvery(every time.Duration, burst, maxKeys int) *rateLimiter {
+ return &rateLimiter{rate: 1 / every.Seconds(), burst: float64(burst), maxKeys: maxKeys, buckets: map[string]*tokenBucket{}}
+}
+
// allow spends one token for key, refilling by the time elapsed since
// its last visit. ok is false when the bucket is empty, in which case
// retryAfter is how long until one token is available.
diff --git a/internal/calls/stream.go b/internal/calls/stream.go
index 645c5d0..18b76b3 100644
--- a/internal/calls/stream.go
+++ b/internal/calls/stream.go
@@ -223,7 +223,7 @@ func (a *app) stream(w http.ResponseWriter, r *http.Request) {
// browser discards what is at or below the seq it already has,
// so a fresh candidate written ahead of the replay would make
// the replay look stale.
- first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot()})
+ first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot(), Report: res.ReportToken})
if res.Admitted {
first = append(first, hub.Message{Type: hub.TypeRoster, Roster: res.Roster})
first = append(first, res.Replay...)
@@ -284,7 +284,7 @@ func (a *app) stream(w http.ResponseWriter, r *http.Request) {
return
}
sub, peer = res.Sub, res.Peer
- first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot()})
+ first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot(), Report: res.ReportToken})
}
// From here the participation exists and is this handler's to hold
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index e8ba0c4..de032fd 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -308,6 +308,41 @@ type browserRig struct {
cookieName string
cookieA string
cookieB string
+
+ // reports is every quality and problem report line the app has
+ // written (spec §15.26), as the deployed app writes them: JSON.
+ reports reportLog
+}
+
+// reportLog is a goroutine-safe sink for the app's report logger.
+type reportLog struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (l *reportLog) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.buf.Write(p)
+}
+
+// raw is everything written so far.
+func (l *reportLog) raw() string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.buf.String()
+}
+
+// lines are the report lines with msg, decoded.
+func (l *reportLog) lines(msg string) []map[string]any {
+ var out []map[string]any
+ for _, line := range strings.Split(l.raw(), "\n") {
+ var m map[string]any
+ if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == msg {
+ out = append(out, m)
+ }
+ }
+ return out
}
func newBrowserRig(t *testing.T) *browserRig {
@@ -352,6 +387,7 @@ func (rig *browserRig) serve(t *testing.T, ln net.Listener) {
AllowedAddresses: []string{memberA, memberB},
Mailer: rig.mail,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ ReportLogger: slog.New(slog.NewJSONHandler(&rig.reports, nil)),
})
if err != nil {
t.Fatalf("calls.NewForTest: %v", err)
diff --git a/protocol/messages.go b/protocol/messages.go
index ea7f11c..627eced 100644
--- a/protocol/messages.go
+++ b/protocol/messages.go
@@ -89,6 +89,10 @@ type Message struct {
Boot string `json:"boot,omitempty"`
Peer PeerID `json:"peer,omitempty"`
Waiting bool `json:"waiting,omitempty"`
+ // Report is hello's report token (spec §15.26): the client's authority
+ // to send quality and problem reports, for its participation and ten
+ // minutes after. Sent back in the X-Calls-Report header, never logged.
+ Report string `json:"report,omitempty"`
}
// Roster is the authoritative snapshot a browser reconciles against
diff --git a/protocol/quality.go b/protocol/quality.go
new file mode 100644
index 0000000..db21780
--- /dev/null
+++ b/protocol/quality.go
@@ -0,0 +1,191 @@
+package protocol
+
+// Quality and problem reports (spec §15.26, 2026-10-03): the wire shape a
+// client POSTs, client-neutral like the rest of this package.
+//
+// A quality report is a fixed schema of numbers and enums about how this
+// client's connections went over the last window: no names, no addresses,
+// no device names, no meeting, nothing typed by a person. Every number is
+// optional (absent means "this client could not measure it"), finite and
+// non-negative, and has a ceiling the server enforces; every string is an
+// enum or a version. Counts are what happened during the window (a delta),
+// not running totals. The server re-encodes what it validated and drops
+// anything it does not know, so a member a newer client adds is tolerated
+// and never logged.
+
+// QualityVersion is the schema's version, the report's "v".
+const QualityVersion = 1
+
+// Bounds the server enforces, here so a client can clamp to the same ones.
+const (
+ MaxQualityPeers = 8 // remote peers in one report (the mesh holds three)
+ MaxQualitySeconds = 600 // a window, in seconds
+ MaxQualityMillis = 600_000 // any rtt, jitter or buffer delay, in ms
+ MaxQualityCount = 1e9 // any count of packets, frames or samples
+ MaxQualityKbps = 1e6 // any bitrate, in kbit/s
+ MaxQualityPixels = 16384 // a frame's width or height
+ MaxQualityFPS = 240 // frames per second
+ MaxQualityBody = 16 << 10
+ MaxProblemNote = 1000 // characters of "What went wrong?"
+ MaxProblemBody = 64 << 10 // the whole problem report, diagnostics included
+)
+
+// QualityReport is one window's summary.
+type QualityReport struct {
+ V int `json:"v"`
+ Final bool `json:"final,omitempty"` // the report sent at leave or end
+ Seconds *float64 `json:"seconds"` // how long the window was
+ Client ReportClient `json:"client"`
+ Devices *Devices `json:"devices,omitempty"`
+ Events *CallEvents `json:"events,omitempty"`
+ Peers []PeerQuality `json:"peers,omitempty"`
+}
+
+// ReportClient says what is reporting. Enums, and versions as digits and
+// dots: never a user agent string, a device model or a machine name.
+type ReportClient struct {
+ App string `json:"app"` // web, mac, iphone, ipad
+ Version string `json:"version,omitempty"` // the app's version, or the server's build for web
+ Browser string `json:"browser,omitempty"` // chrome, safari, firefox, edge, other (web only)
+ BrowserVersion string `json:"browserVersion,omitempty"` // major[.minor]
+ OS string `json:"os,omitempty"` // macos, ios, ipados, windows, linux, android, chromeos, other
+ OSVersion string `json:"osVersion,omitempty"`
+}
+
+// Devices are the kinds of microphone and speaker in use, by transport:
+// never a device's name, which can be personal ("Paul's AirPods Pro").
+type Devices struct {
+ Input string `json:"input,omitempty"` // DeviceKinds
+ Output string `json:"output,omitempty"` // DeviceKinds
+}
+
+// DeviceKinds are the values Devices may carry.
+var DeviceKinds = []string{"built-in", "bluetooth", "usb", "wired", "airplay", "virtual", "continuity", "other", "none", "unknown"}
+
+// CallEvents are the notices this client showed for the whole call during
+// the window (§15.23), and whether its browser blocked playing sound
+// (§15.20).
+type CallEvents struct {
+ Reconnecting *float64 `json:"reconnecting,omitempty"` // times "Reconnecting…" was shown
+ ReconnectingSeconds *float64 `json:"reconnectingSeconds,omitempty"` // for how long
+ AudioBlocked bool `json:"audioBlocked,omitempty"`
+}
+
+// PeerQuality is one remote peer's connection, from this side.
+type PeerQuality struct {
+ // Peer is the remote peer's id as this client knows it. The server
+ // logs the peer's tag in its place and never the id.
+ Peer PeerID `json:"peer"`
+ Path *Path `json:"path,omitempty"`
+ RTT *float64 `json:"rtt,omitempty"` // the selected candidate pair's, ms
+ Out *Outbound `json:"out,omitempty"`
+ In *Inbound `json:"in,omitempty"`
+ Events *PeerEvents `json:"events,omitempty"`
+}
+
+// Path is the selected candidate pair: what kind of candidate each end
+// used, and the transport. Never an address or a port.
+type Path struct {
+ Local string `json:"local,omitempty"` // CandidateKinds
+ Remote string `json:"remote,omitempty"` // CandidateKinds
+ Protocol string `json:"protocol,omitempty"` // udp, tcp, tls, unknown
+}
+
+// CandidateKinds are the values Path.Local and Path.Remote may carry.
+var CandidateKinds = []string{"host", "srflx", "prflx", "relay", "unknown"}
+
+// PathProtocols are the values Path.Protocol may carry. "tls" is a relay
+// reached over TURN/TLS.
+var PathProtocols = []string{"udp", "tcp", "tls", "unknown"}
+
+// Outbound is what this client sends to the peer.
+type Outbound struct {
+ Audio *OutStream `json:"audio,omitempty"`
+ Video *OutStream `json:"video,omitempty"`
+ Screen *OutStream `json:"screen,omitempty"`
+}
+
+// OutStream is one outbound stream. Lost, RTT and Jitter are the
+// receiver's own account (remote-inbound-rtp).
+type OutStream struct {
+ Kbps *float64 `json:"kbps,omitempty"`
+ Packets *float64 `json:"packets,omitempty"`
+ Lost *float64 `json:"lost,omitempty"`
+ RTT *float64 `json:"rtt,omitempty"`
+ Jitter *float64 `json:"jitter,omitempty"`
+ Limit string `json:"limit,omitempty"` // QualityLimits
+ Width *float64 `json:"width,omitempty"`
+ Height *float64 `json:"height,omitempty"`
+ FPS *float64 `json:"fps,omitempty"`
+ // The screen-share recovery ladder's steps toward this peer during
+ // the window (§15.22): keyframes sent, rebuilds, shares again, and
+ // times it gave up.
+ Keyframes *float64 `json:"keyframes,omitempty"`
+ Rebuilds *float64 `json:"rebuilds,omitempty"`
+ Reshares *float64 `json:"reshares,omitempty"`
+ Failures *float64 `json:"failures,omitempty"`
+}
+
+// QualityLimits are qualityLimitationReason's values.
+var QualityLimits = []string{"none", "cpu", "bandwidth", "other"}
+
+// Inbound is what arrives from the peer.
+type Inbound struct {
+ Audio *InAudio `json:"audio,omitempty"`
+ Video *InVideo `json:"video,omitempty"`
+ Screen *InVideo `json:"screen,omitempty"`
+}
+
+// InAudio is the peer's audio as it arrives here.
+type InAudio struct {
+ Kbps *float64 `json:"kbps,omitempty"`
+ Packets *float64 `json:"packets,omitempty"`
+ Lost *float64 `json:"lost,omitempty"`
+ Jitter *float64 `json:"jitter,omitempty"` // ms
+ Samples *float64 `json:"samples,omitempty"` // totalSamplesReceived
+ Concealed *float64 `json:"concealed,omitempty"` // concealedSamples
+ Inserted *float64 `json:"inserted,omitempty"` // insertedSamplesForDeceleration
+ Removed *float64 `json:"removed,omitempty"` // removedSamplesForAcceleration
+ JitterBufferMs *float64 `json:"jitterBufferMs,omitempty"` // mean jitterBufferDelay per emitted sample over the window
+}
+
+// InVideo is the peer's camera or screen as it arrives here.
+type InVideo struct {
+ Kbps *float64 `json:"kbps,omitempty"`
+ Packets *float64 `json:"packets,omitempty"`
+ Lost *float64 `json:"lost,omitempty"`
+ Jitter *float64 `json:"jitter,omitempty"`
+ Freezes *float64 `json:"freezes,omitempty"` // freezeCount
+ FreezeSeconds *float64 `json:"freezeSeconds,omitempty"` // totalFreezesDuration
+ Decoded *float64 `json:"decoded,omitempty"`
+ Dropped *float64 `json:"dropped,omitempty"`
+ Width *float64 `json:"width,omitempty"`
+ Height *float64 `json:"height,omitempty"`
+ FPS *float64 `json:"fps,omitempty"`
+ Reattaches *float64 `json:"reattaches,omitempty"` // a stalled screen handed to its view again (§15.22)
+}
+
+// PeerEvents are the notices about this peer during the window.
+type PeerEvents struct {
+ Reconnecting *float64 `json:"reconnecting,omitempty"` // "Reconnecting to …" shown
+ ReconnectingSeconds *float64 `json:"reconnectingSeconds,omitempty"` // for how long
+ CantHearSeconds *float64 `json:"cantHearSeconds,omitempty"` // "… can't hear you yet" (§15.24)
+ NoSound bool `json:"noSound,omitempty"` // this client listed them in noSoundFrom (§15.20)
+ NoSoundFromUs bool `json:"noSoundFromUs,omitempty"` // they listed this client
+ AudioBlocked bool `json:"audioBlocked,omitempty"` // they said their browser blocked sound
+ ScreenStalled bool `json:"screenStalled,omitempty"` // this client reported their screen stalled (§15.21)
+}
+
+// ProblemReport is "Report a problem": what the person typed, and the
+// text "Copy call diagnostics" would copy, with the people in the call
+// named by short peer ids rather than their names.
+type ProblemReport struct {
+ Note string `json:"note"`
+ Diagnostics string `json:"diagnostics"`
+ Client ReportClient `json:"client"`
+}
+
+// ProblemReceipt is the server's answer: the reference to quote.
+type ProblemReceipt struct {
+ Ref string `json:"ref"`
+}