A notification outlives its permission

Storing a notification freezes an access decision at write time. When the audience is a permission rather than a settled list, derive the feed on read.

Two pedestal-mounted glass bowls sit beneath a lens; the open bowl glows orange

Fan-out on write against fan-out on read is usually argued about cost. There is a second axis, and it decides correctness rather than latency: a stored notification freezes an access decision at the instant it was written, while a derived one re-evaluates that decision on every read. Permissions move afterwards.

A stored notification is an access decision with a timestamp

Inserting a row into a notifications table answers a question about authorization once, at write time, and then stops. The question is whether this person may know that this happened, and the answer is correct on the day. Then the ticket gets archived, or an admin unmarks it from the portal, or somebody’s access is revoked, and the row does not move, because rows do not.

Two in-app notification systems once ran side by side in this product for the same feature, and the one that lost the merge had a reader that never filtered by membership. Its rows were written correctly, its query returned what it was asked for, and a project whose access had been revoked kept feeding notifications to somebody who no longer belonged to it. The mechanism is the lesson, and it is closed: the surviving inbox filters on every read.

The shape of the mistake is familiar from elsewhere in a product. A value that arrived from a trusted place and got stored is evidence about the past, and evidence is not a permission, which is the distinction reporter identity draws about the email on a ticket. A notification row is evidence that somebody was allowed to know something, once.

The audience decides which of the two is right

So the question to settle before creating the table is what kind of thing the audience is.

Fan-out on writeDerive on read
The audience isA recipient list settled when the event happenedA permission evaluated when the page is opened
Access is decidedOnce, by the writerOn every read, by the reader
A revocation takes effectWhen something re-checks the stored rowsAt once, because there is nothing stored to re-check
What can driftThe row, against archives, unmarking and membershipNothing, because there is no second copy
The costOne write per recipient, then trivial readsOne bounded query per open
Where it belongsAssignments, mentions, digests, anything addressed to a personA client portal, a shared board, anything gated by a permission somebody else can change

Deriving is the more expensive column and there is no argument about that. What matters is the range where the arithmetic favours it. A client portal is opened by a handful of people a handful of times a day, so the derived feed costs one bounded query per open, against a stored design that pays a write per recipient per event and then re-checks membership on the read anyway. Invert the read rate and the columns swap: a social timeline read thousands of times a second is what fan-out on write was invented for, and none of this advice survives the move.

Deriving works because the reader brings its own predicate

The client portal’s bell has no table behind it. Everything it announces is already an activity row, written by the same transaction that did the work, and the list is assembled at the moment somebody opens the page. Archive a ticket, unmark it from the portal, or revoke the access that reaches it, and the next open answers differently, because the feed changes by reading a changed thing rather than by remembering to retract something.

The part worth copying is smaller than that. The module that assembles the feed knows nothing about which tickets the reader may see. Each door passes in the very clause its own ticket listing uses, the named client access one and the reporter link the other, so a notification cannot name a ticket that the same reader’s list would refuse to show. Writing a second opinion about visibility inside the feed is the one edit that would put the frozen permission back, which is why the parameter is a query fragment and not a set of options: no shape of arguments lets a caller describe visibility twice.

One expression then carries two decisions at once. Under a named client access, archived tickets are out and a portal configured to show only marked tickets narrows to those, so unmarking a ticket takes it off the list and out of the bell in the same instant, because the same clause answered both. A reporter portal link answers the archive question the other way, and on purpose: somebody who filed a report is entitled to learn what became of it after the team took it off the board.

That disagreement is the whole case for handing the predicate in. One door is scoped to a project, the other to the reporter’s own subject, and they do not agree on what a finished ticket is, so a feed that decided for itself what counts as visible would have to pick one of the two answers and be wrong at the other door. The fragment is typed as required and non-nullable rather than as the optional the query builder hands back, so a later edit cannot pass nothing and quietly produce a query with no tenant in it. The read watermarks are joined to that clause too, which stops the ledger accumulating a permanent entry for a ticket the reader can no longer reach.

A stored inbox still has to re-check what it stored

The team’s own inbox is a table, and that is the right call for it. Its audience is a recipient list decided when the event happened: the people the dispatcher resolved, minus the one who caused it, since the dispatcher never notifies the actor behind the event. That exclusion is safe to freeze because it is a fact about the past rather than a permission, and facts about the past do not get revoked.

The read still re-checks. The inbox is contextual, global while no project is open and scoped to one once a project is, so the project identifier arrives from the URL and cannot be trusted. What the client asks for is intersected with what the actor may see: an instance admin gets every project, everybody else the deduplicated set of their own memberships, and a notification with no project attached passes for either. A project outside those memberships is reported as missing rather than forbidden, because a forbidden answer would confirm to somebody that it exists.

That intersection is the difference between the surviving inbox and the one it replaced. Both store rows. Only one treats what it stored as a claim to re-check rather than an answer to repeat.

What the feed refuses to say, and how far back it looks

Two announcements the feed declines to make are worth more than the ones it makes. A status change whose recorded destination is missing is dropped, because saying that something changed without saying what it changed to is worse than staying quiet. A comment with no team author was written through the portal, by this reader or by the ticket’s reporter, and telling somebody the team replied while they read their own words back is the most confusing thing the feature could do. Internal notes are excluded at the database, and that judgement is repeated in the code mapping a row to a notification rather than trusting whatever WHERE clause the caller wrote.

The feed also inherits a property from deriving. A write that records no activity announces nothing, with no second system to remember to stay quiet, which is the rule two-way sync leans on when a status write that changes nothing touches the row and produces no activity entry, no notification and no webhook.

Then the bounds, which are product decisions rather than paging accidents.

BoundValueWhy it is set there
Items in one feed50A bell is read from the top, and the fifty-first item has never been read by anyone
How far back it looks90 daysA project quiet for a season should open on an empty bell rather than on last quarter’s news presented as if it just happened
PagingnoneA reader who wants a ticket’s full history opens the ticket, where the whole trail already lives

That is the limit to design around: the bell is a recency surface rather than an archive. Anything past the cap or outside the window is unreachable from it, because there is no cursor to follow, and a client who needs the complete record of a ticket has to open that ticket. A compliance trail or a client-facing audit log built on a portal bell is built on a structure that deliberately forgets.

Common questions

Should a notification feed be a stored table or a query?

Both answers are right for different audiences. A table suits a recipient list settled when the event happened. A query suits an audience defined by a permission, because permissions change after the event and only a read can re-evaluate them.

What happens to a stored notification when somebody loses access to the project?

Nothing happens to the row on its own. A stored notification records who was allowed to know at the moment it was written, so the reader has to intersect it with current membership on every fetch, or a revoked project keeps returning rows.

Is deriving a notification feed on every read too expensive?

The read rate decides it. A client portal is opened by a handful of people a handful of times a day, so one bounded query per open is cheap. A feed read thousands of times a second is the case fan-out on write exists for.

Why does a client portal bell stop showing older activity?

A bell is a list somebody reads from the top, so it carries a capped number of recent items from a fixed window and offers no paging. The full history of a ticket lives on the ticket, where the whole activity trail already sits.

When should a notification feed announce nothing at all?

When it cannot describe the event honestly. A status change with no recorded destination would say something moved without saying where, and a comment with no team author is the reader's own message being handed back as news.

Why report an unreachable project as missing rather than forbidden?

Because a forbidden answer confirms the project exists to somebody who was never meant to learn that. Reporting it as missing keeps the existence of another tenant out of the response, and a client cannot tell the two situations apart.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">