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_userand passes it tosubscribe. 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_ptrand 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_ptrand 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.