Task: Keep each stream to its own tenant, and let a dead one go

Table of Contents

This page documents a task in the Stream the changes as they happen story. It captures the goal, current status, acceptance, and any notes or results.

1. Goal

A stream shows a caller its own tenant's changes and the system tenant's, and nothing else. A client that disconnects leaves nothing behind. The hub has no timer it could cancel after the fact.

2. Status

Field Value
State DONE
Parent story Stream the changes as they happen
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-11-01

3. Acceptance

  • A frame for another tenant is never written to a stream, and a frame for the system tenant is written to every stream.
  • A stream whose source is destroyed leaves the hub, so its queue and its copy of every frame do not outlive the client.
  • The waker holds the timer it cancels by shared ownership, so a cancel after the read ended is a no-op and not a dangling call.
  • A case proves each of the three without a broker, and the stream's own cases still pass.

4. Plan

  • A subscription states the tenant it was opened for. The hub holds the system tenant, and a publish gives the frame to a stream when the frame's tenant is the stream's own or the system tenant's. Filtering happens before anything is queued, so another tenant's frame never enters a queue at all.
  • The route reads the caller's tenant from authenticated_user and passes it to subscribe. A caller with no tenant in its claims hears only the system tenant, which is the safe default rather than a wildcard.
  • The stream source closes its subscription in its destructor. The hub keeps its own reference, so without this a stream that ended stayed in the hub for the life of the process and kept receiving frames.
  • The keep-alive timer is held by shared_ptr and the waker captures that, so a cancel that lands after the read ended touches a live object and does nothing.
  • The feed is a shared_ptr and the registrar's handler captures it by value, because the handler outlives the frame that built it.

5. Measurements

Every number this task claims about a tree, with the command that produced it and the commit it was measured at. A number without both is not a measurement, because a clean-up record describes a tree and trunk moves under a branch: a consumer count, a gate's component count and a suite's size all change with no code changing, and the reader of a bare number cannot tell which tree it came from. Where the raw output matters, put it in an evidence/ file beside the task and name that file in the Command column.

What Value Command Commit
Core suite cases 48 ores.http.core.tests bfcc96fd46
Core suite assertions 194 ores.http.core.tests bfcc96fd46
Core suite cases before this task 45 ores.http.core.tests on 4455142167 4455142167

6. Notes

7. Test Scenarios

Manual QA scenarios (scaffolded via compass add test_scenario) that verify this task. Link new ones here as they're created; the scenario doc itself links back via its "Verifies task" field.

Scenario State Notes
     

8. PRs

PR Title
#4344 [http] Keep a stream to its own tenant, and let a dead one go

9. Review

Comment summary File Decision Notes
       

10. Result

A stream is now kept to its own tenant. The hub holds the system tenant, a subscription states the tenant it was opened for, and a frame is queued only for the streams that hear it: the one whose tenant owns the frame, and every one when the frame belongs to the system tenant. The route takes the caller's tenant from its claims, so a session cannot read another tenant's changes, and a caller whose claims state no tenant hears only the system tenant.

Three cases prove it: a frame for another tenant is not queued, a system-tenant frame reaches two streams of different tenants, and a stream whose source is destroyed leaves the hub and takes no further frame.

The other two defects are fixed beside it. The source closes its subscription when it is destroyed, so a client that leaves no longer leaves a queue behind that fills with every frame for the life of the process. The keep-alive timer is shared and the waker holds that share, so a cancel that lands after the read ended touches a live object.

One thing the work found: open_streams() counted closed subscriptions until the next publish, so it answered a question nobody asks. It now counts the streams that are still open.

Emacs 29.3 (Org mode 9.6.15)