| name | rask-notifications |
| description | How a feature gets a person told — the targeted inbox behind the estate's bell. The six targeting sources (AUTHOR/ORIGINATOR/WATCH/GRANT_ADDED/GRANT_REVOKED/TASK_ASSIGNED+TASK_UNASSIGNED), the producer contract for the lineage and control lanes, and the four silent-drop traps that make an emitted event reach nobody. Use when adding a feature whose outcome a person should hear about; when a notification "should have fired" and did not; when adding a `ControlAction` or a `NotificationReason`; when emitting an OpenLineage run event from any service; or when wiring a new service into `lineage.events.v1` / `catalog.control.v1`. |
rask notifications — how a feature gets a person told
A notification names a PERSON and a REASON. A feed names an estate. services/notifications is an
inbox behind the bell — one Dapr InboxActor per subject holding claim-check pointers with durable
read state — precisely so the badge counts your work rather than the estate's. That makes coverage a
targeting problem, not a transport one: the ingresses already carry everything the estate does, and
the plane's whole job is to discard all of it except rows that name a subject under one of four reasons.
The corollary, and the thing to internalise: a state change that names nobody is not
under-delivered, it is UNDELIVERABLE. No amount of subscribing fixes it. Coverage is decided at the
producer. For the topology (ports, ingresses, chart wiring, the four values entries the service
needs in-cluster) see rask-services-fleet; this skill is only "what must I emit, and who hears it".
The decision procedure
Run top to bottom. First match is your source. No match means the feature has no audience and needs
a producer change before it can have a notification — say that in the design rather than emitting
something adjacent.
| Question | Source | What you emit |
|---|
| Q1 | Does a named person cause it, ending in a terminal run over a governed object? | AUTHOR | terminal lineage event + verified author.sub + ≥1 output |
| Q2 | Does a service run it, on a named person's behalf? | ORIGINATOR | the same event carrying lance.originator = that person's sub |
| Q3 | Should people other than the actor know, because it is the project's work? | WATCH | the same event plus lance.project |
| Q4 | Does it give a specific person access? | GRANT_ADDED | CatalogControlEvent, extra.subject = the principal |
| Q5 | Does it take access away from a specific person? | GRANT_REVOKED | the same envelope, action="grant_revoked" |
| Q6 | Does it hand a person work, or take work away from them? | TASK_ASSIGNED / TASK_UNASSIGNED / TASK_CHANGES_REQUESTED / TASK_DROPPED / TASK_LEASE_EXPIRED | the same envelope; extra.subject is the WORKER, never the manager who clicked |
Q2 is the one people miss, and it exists because Q1 is unreachable for most long work. The estate's
expensive runs — a Ray training job, a medallion stage — execute detached, hours after the request, and
post their own lineage as a service. enforce_author then OVERWRITES the author facet with that
service's verified sub (trap 2), so the human CANNOT be the author and must not try to be. originator
is the field for a run authored by a service but run FOR a person; it is a TARGETING hint that
authorizes nothing, because the plane re-derives every recipient's visibility at delivery. The worked
reference is the /train chain (tests/unit/test_train_originator.py): five links — door → trigger →
consumer → Ray submission → the job's own events — and the person is told only if every one carries it.
Long-running jobs need the identity in TWO places, and they are not interchangeable. Ray's own
metadata (rask.originator, returned by GET /api/jobs/<id>) is how an OUTSIDE observer recovers who
a job was for after it died — including a job that died before emitting anything. runtime_env.env_vars
is the job's own copy, for the events it emits itself. Put it in only one and you lose either the
self-emitted lane or the post-mortem one.
AUTHOR needs no registry and no opt-in — you may always be told about your own run
(fanout.py:87). WATCH is an explicit project#member-gated opt-in: membership gates watching and
never implies it, so no audience widens by default.
Q6 needs ONE ACTION PER EDGE, not a generic one. The panel renders the reason as each row's
user-visible label, so a distinct verb is not decoration — telling somebody their reviewed work was
"unassigned" is a worse answer than the silence it replaces. The annotator's audience always comes off
the task's pre-turn snapshot, never the post-transition document: the actor nulls assignee inside
the very turn these edges fire, so by the time fire() returns there is nobody left to name.
submitted_by is the safer field — written once and cleared by no edge — which is why it carries the
review side.
lease_expired is covered, and how it got there is the reusable part. It was recorded as
structurally impossible — "no principal, no request, no emitter in scope" — and two thirds of that was
a misreading. No principal is true and IRRELEVANT: the lane targets on extra.subject and never reads
actor (its envelope carries system:annotator). What was actually missing was a handle: a Dapr actor
has no Request, so it cannot resolve ControlEmitterDep. service_kit.control_emit's
set_process_control_emitter / process_control_emitter pair closes that — set once in the lifespan
beside app.state.control_emitter, read by any non-request producer, defaulting to the NO-OP so a
service that never sets it is silent rather than broken. Before filing an emit site as impossible,
separate "no audience" from "no handle" — only the first is structural.
Two details that path forces, and both are general: read the audience BEFORE the transition (the actor
nulls assignee in the same turn, so the post-turn document names nobody), and read the object id off
the RECORD rather than self.id.id — identical in production, but the actor-runtime attribute does not
exist under a test double, so an emit that used it silently swallowed an AttributeError and asserted
nothing.
Q4 is the sharpest and is why the control lane exists at all: losing access silently is how someone
discovers it by hitting a 403 in the middle of work. That lane deliberately runs no visibility
check — after a revoke the subject can no longer see the object, so a delivery-time check would drop
the one event they most need. Being named IS the targeting.
Lane 1 — lineage (lineage.events.v1)
Four things must ALL hold. notifiable() (notifications/api/lineage_events.py:154-203) returns
None — "ack it, tell nobody" — on any miss, and that ack is a SUCCESS, so nothing anywhere
reports the loss. This is the estate's most expensive silent failure mode.
- Terminal state —
COMPLETE / FAIL / ABORT (TERMINAL_STATES, lineage_events.py:32).
START/RUNNING notify nobody by product decision. RECONCILED is excluded on purpose: it is
lineage's REPAIR marker for a run whose real terminal event was lost, not an outcome anyone chose.
- A VERIFIED
author.sub — see the trap below. This is the one that keeps biting.
lance.project — optional, but omitting it costs you every watcher.
- ≥1 output, named exactly as the FGA object is named. Delivery runs
can_be_notified and render
re-runs can_get_metadata against table:<output name>, so an unqualified name against
tenant-qualified grants counts every recipient HIDDEN.
event = {
"eventType": "FAIL",
"eventTime": datetime.now(UTC).isoformat(),
"run": {
"runId": str(run_uuid),
"facets": {
"author": {"name": token.sub, "sub": token.sub},
"lance": {
"operation": "promote",
"run_id": producer_run_id,
"project": project_id,
},
"errorMessage": {"message": reason},
},
},
"outputs": [{"namespace": "gold", "name": f"{project_id}-gold$catalog"}],
}
await publish_event(client, pubsub_name="pubsub", topic_name="lineage.events.v1", data=json.dumps(event), data_content_type="application/json")
Lane 2 — control (catalog.control.v1)
await emit_control(
emitter,
action="grant_revoked",
object_type="project",
object_id=f"project:{project_id}",
actor=f"user:{token.sub}",
extra={"relation": relation, "subject": user},
)
Call it after the backend mutation and its audit succeed, so a real change is never announced.
extra.subject is the entire targeting: named_subject returns None for a missing subject, a bare
user:, and the * wildcard, and the event is then filed IGNORED with a SUCCESS ack.
A new named action is a THREE-file change, and all three are load-bearing:
service_kit/control_events.py — add the member to ControlAction, or the envelope will not validate.
notifications/api/control_events.py — add it to NAMED_ACTIONS, or the lane files it IGNORED.
notifications/models.py — add the matching NotificationReason, because as_delivery constructs
NotificationReason(event.action) and would otherwise raise on every delivery.
The deeper reason for (3) is that the reason is stored, not inferred: a delivery re-check keys on
it, and a governance row is checked against no object rule at all, so a reader that could not tell it
apart from a run row would have to guess. Note also that ControlAction is a wire contract reaching
the frontend through docs/catalog-openapi.json — see rask-lance-catalog for the regen step.
A NEW REASON IS A COMPATIBILITY SURFACE, NOT JUST AN ENUM. The reason is stored, so the moment
a row carrying it lands in the actor's durable state, every build that reads that state must be able
to name it. This is not theoretical — it took an inbox down on 2026-08-16: three members were added,
rows landed, the deployment rolled back, and the older enum turned them into
ValidationError: 4 validation errors for InboxRows → InboxUnreadable → 503 for the entire
inbox. Not one missing row: the badge went blank and every other notification that subject had
became unreachable, because list validation is all-or-nothing. The bell then fell back to the run
feed, which is indistinguishable from "no service configured".
InboxPointer now degrades an unnameable reason to NotificationReason.UNKNOWN on read, and
LineageCursor takes extra="ignore" (it is service-internal and single-writer, so nothing is being
contained). extra="forbid" on InboxPointer deliberately stays — an unknown field may be
another subject's data, which is a containment guard (test_inbox_leak_containment), while an
unknown reason value arrives on a declared field and carries nothing foreign. Those are two hazards
wearing one symptom; do not "fix" the first by relaxing the second.
Still strict and still this class, unfixed because each needs its own containment argument:
InboxMeta, InboxRows, ChannelPrefs. Adding a field to any of them bricks older readers of that
record. A rollback and a mixed-version rollout are both routine.
Adding a NotificationReason needs no frontend change — but not because the bell ignores it. The
panel RENDERS the reason as each row's label (verified in a browser: rows read originator · 1m ago
beside older author ones). It passes the string straight through rather than switching on a known
set, so a new member displays correctly with no TS edit. Two consequences: the value is user-visible,
so it must read as a reason a person would accept; and notification-center.stories.svelte is stale
(project_watch vs the backend's watch) without anything failing.
The four traps — an event emitted is not a person told
- A role literal in
author.sub reaches nobody. author_subject() reads author.sub and
nothing else — never author.name, never the standard ownership facet — because those are
producer-supplied and honouring them would let any producer put a row in a named person's inbox.
The medallion movers author with a chart role literal (data_eng/analyst, chart/values.yaml
medallion.movers[].author; the producer's is ray), so a failed cascade addresses an inbox
actor named ray. That is not a bug to fix at the mover — enforce_author would overwrite a
human there anyway (trap 2). The literal is correct as the author; what makes the cascade
reachable is the ORIGINATOR riding beside it, which is why every trigger payload in the chain
re-carries it.
The hop that lost it was the TIER BOUNDARY, and the lesson generalises past this cascade.
/produce → /bronze-arrival → the mover's four FAIL emits all carried the human; the mover then
publishes its output to the CATALOG (the tag move is what wakes the next tier), and that call
carried cascade_id and dropped the person. The publication head filled the gap by deriving an
originator from the control event's actor — but a mover authenticates to the catalog AS ITSELF,
so the silver→gold trigger named service-bronze-to-silver and every gold failure wrote into an
inbox actor named after a mover. A service subject in the originator is the same defect as a role
literal in the author, and it is worse than silence because it looks delivered. Closed by the
cascade_id shape: the mover puts the human on the publish body
(catalog_register.publish_stage_output), the catalog RESOLVES it once
(publication.publication_originator — a service caller's carried claim, else a human caller's own
verified sub, else nothing) and echoes it onto table_published, and the head reads extra
instead of guessing. The catalog owns that decision because IDToken.service — set by its own
service door — is the only place the estate records "this caller was a service".
Pinned end-to-end on delivered rows by tests/unit/test_cascade_originator.py.
- A service token SUBSTITUTES the author. If your emit runs behind a service bearer,
() the facet with that service's
sub — "never trust the request body" is doing its job, and your human is gone. Carrying the human's
sub through your own call graph is the only fix.
The FGA prerequisite
The feed is governed. The reconciler reads it as its own service principal, so a deployment that
forgets the grants gets a reconciler that runs cleanly, logs success, and reconciles nothing. There is
no retry that helps — the estate's invokeRetry never retries a 4xx, because a 403's answer will not
change. Symmetrically, delivery needs can_be_notified and render needs can_get_metadata on
table:<output name> per recipient; an FGA outage is fail-closed (RETRY), never a delivery.
So a new producer ships two grants: notifications' reader on the feed, and the recipients' grants
on the objects your outputs name. See openfga — can_* relations are never directly assignable.
Testing with no cluster
Both audience seams are callables, not imports, precisely so this needs no sidecar:
InboxOpener = Callable[[str], TypedActorProxy] and
WatcherLookup = Callable[[str], Awaitable[Sequence[str]]] (fanout.py:37-69).
Visibility(client=None, enabled=False) is the FGA-off value. Drive the whole ingress through
ingest_run_event (api/ingest.py) — the identical function both lanes run, which is what makes "the
same event arriving twice lands one pointer" a property of the projection rather than a coincidence.
Assert on the delivered rows, never on the status — an event that names nobody returns SUCCESS
too, which is exactly how a coverage gap hides. services/notifications/tests/test_ingress_audience.py
is the worked reference.
What is deliberately NOT notified
Rules with reasons, so they are not "fixed" back into defects:
- Non-terminal states — "your run started" tells the clicker nothing and is noise to everyone else.
- Output-less runs — a pointer names the object it is about, and there is no honest value for that
field. Refused under FGA on AND off; one rule everywhere.
- Unverified authors — see trap 1. Attribution and targeting are different questions.
- Wildcards and usersets — a statement about everyone addresses no one.
- Actions naming no party — delivering every catalog mutation recreates the estate-wide feed this
plane exists to replace.
- Synchronous HTTP failures — the caller already has the 4xx; an inbox row is a second copy.
What is still uncovered — the CLASSES, not a row list
A workflow audit (2026-08-16, 8 services, three adversarial verify lenses) found the plane sound and
its producers thin. Its register is gone because the work it tracked is done or ruled on; what
survives is the shape of what remains, because these classes recur every time a new feature asks to
notify somebody.
- No identity at the door. A producer that never captured a verified sub can never name a person,
and nothing downstream can repair it. Closed for
/produce, /train, ingest and — since
2026-08-22 — /ingest-media, which was token-only and so could resolve no principal at all; it now
shares the pinned dual-auth door (authorize_ingest_media, no ?project=, because the media head's
target is configured and authorization scope must equal write scope). Still open for
services/compute (RayJob has no author field) and the controlplane (the Project CR carries
spec.team, a literal, not a requester). See docs/DECISIONS.md — an emitter without an identity
produces events the plane is designed to discard, which reads as coverage and is not.
- No principal at all. Some transitions are caused by a TIMER, not a person: the annotator's
lease_expired fires from an actor reminder with no request and no emitter in scope. The audience
exists (the task's holder) but the emit site does not.
- A fact that only exists while somebody is looking. Several failures are composed at read time
inside an HTTP handler and never persisted or published — flows' unparsable result, ingest's
provenance-verification defect. Nothing can notify what was never recorded.
- An audience that is real but unmodelled.
services/flows has no project and no lakehouse
output, so even a perfect emit dies on notifiable()'s output rule; the controlplane keys watches
by CR name while fan-out matches the FGA tenant id, and nothing joins the two namespaces.
- Steady states wearing an event's clothes. A permanently un-granted mover, a repeating denial, a
degraded lane — these are METRICS, and
docs/DECISIONS.md records why lineage must not carry them.
The line, worth re-reading before adding anything: lineage answers what happened to this dataset
and who produced it; the control lane answers what changed for this person; metrics answer how
often is this happening. A fact that names a principal and touches no data is never lineage.