Skip to content

M6-05 Statistics in the server

No Date Ruling Reason
D-M6-05-1 2026-10-24 Every statistics query first authorizes stats.read (getCostStats: cost.read.aggregate) on the instance without space_id and on the space with it; the decision of authorize answers 403 or 404. Then Scope(message.read) gives the visible channels, chat keeps those with a space (and, with space_id, those of that space), and that set is the only input of every source; a source never widens it, and the merge drops any key outside it again. Values without a channel (webhook deliveries, push registrations, scans) are read only for the instance target with Scope.InstanceWide. DM channels have no space and are not counted. The numbers follow exactly the permissions of the data they count (25 4, T-OW-01); a DM names two people and has no space to group by.
D-M6-05-2 2026-10-24 The metric names of the five operations are those of their descriptions in openapi.yaml; sources: messaging (messages by origin, active people, refused triggers), chat (threads created and closed), runs (ended runs by state and reason, duration percentiles, tool calls), billing (valid cost sums), files (storage, scans), apps (webhook deliveries), notify (push registrations). A run counts in the bucket of its end, a tool call in that of its start, a cost in that of occurred_at. Percentiles are computed with percentile_cont in the owning module, so no sample leaves the database. getCostStats answers in one currency: the parameter currency, else the one currency of the range, USD (the default of cost_entries.currency) without any entry, else 400 validation_failed with errors[].code currency_required; entries of mode personal count only when billing.show_personal_estimates is on (10 12.2). One place lists every name, and amounts of different currencies are never mixed or dropped; the display rule of 10 12.2 applies to statistics as to the thread costs.
D-M6-05-3 2026-10-24 active_people exists only for the granularities week and month and is counted with count(DISTINCT author_principal_id) inside messaging for exactly the requested bucket. A day of one space almost names a person, and a monthly distinct count cannot be built from weekly ones; no person ID leaves the module.
D-M6-05-4 2026-10-24 A value of active_people below privacy.stats_min_group_size is removed from metrics and its name is added to the new optional member below_min_group of StatsBucket; the threshold is not sent. privacy.per_person_stats_enabled changes nothing in these operations. The client shows "fewer than" the minimum group (admin-statistics.md 3 item 2, DP-T11) without a second, guessable number.
D-M6-05-5 2026-10-24 The result cache is in memory per api process, keyed by operation, the aligned parameters, the caller's rights version (Scope.PermissionVersion), whether the scope is instance-wide and a SHA-256 of the channel set; an entry expires after stats.cache_ttl by the clock, and at most stats.cache_max_entries (new key, default 1000) entries are kept, evicting the least recently used one. Authorization runs before every cache read. A permission change gives a new rights version and a miss, so a revoked manager never sees cached numbers of the old scope; arbitrary ranges would otherwise give unbounded keys.
D-M6-05-6 2026-10-24 stats.depends_on gains files, apps and notify for their StatsSource ports; wire/module_stats.go builds the statistics in a step of its own after apps, only in the roles api and all, with each source over its own module's PostgreSQL adapter. The statistics read storage, scans, webhook deliveries and push registrations only through the ports of the modules that own these tables (02 4.4).
D-M6-05-7 2026-10-24 Differences to the brief (D-W-100, the merged code wins): threads have no done_at, and threads belongs to chat, not messaging, so threads_created and threads_done (threads closed in the bucket and still closed) come from a chat.StatsSource, which also maps the visible channels to spaces and threads; runs has no channel_id (D-M2-05-9), so the runs source takes the threads of the visible channels; run_steps has status, not state. The SQL visibility gate had no category system_aggregate; it is added for the statistics queries that filter by the thread set or scope_channel_id, or have no channel. Unknown query members are ignored by the request validator (M1-18 closes bodies, not query strings), so no operation has a parameter that groups by person rather than a 400 for one. Following the merged code and the core contract; no contract change is needed.
D-M6-05-8 2026-10-24 The G7 contract run keeps generated calls from disabling or demoting the principals of its passes: a positive case of an operation with x-authz-action user.disable or owner.remove whose user_id is the owner or the user of a pass gets an unknown person instead (the documented 404). Place: tools/contract/operations.py (principal_ending_operations, shield_principals), _shield in tools/contract/live_contract.py; the harness names the owner's principal status and owners rows when the broker cannot issue an owner session. With the five statistics operations enabled, a generated call on CI disabled the pass owner (the harness reported principal status disabled with the owners row still active, in three of three attempts); every later broker session then failed with not an active owner and all SEC-T06 cases failed. Any change of the enabled set can move that path, so the rule protects the passes for every operation with such an action instead of one sequence.
D-M6-05-9 2026-10-24 The input rule of the statistics sources lives once in internal/kernel/statskit (CheckQuery: bucket size, time zone, equal list lengths; ReadError: a database error of a read as internal_error), a stateless library in the kernel core like kernel/searchkit; the parameter conversions of the statistics reads use platform/pg (UUIDs, Timestamptz, Timestamptzs, UUIDText, NullableUUID). The source of each module keeps only its own checks. Place: kernel/statskit, platform/pg/convert.go, 02 (kernel table and component list), architecture/modules.yaml, .go-arch-lint.yml. The same check and helpers were copied into six modules (no module may import another's app or adapter); the copies made the duplication gate of the pull request fail and would drift apart.