Saphan StudioDocs
Notifications

What goes out

Every notice type, when each is sent and how severe it is, what a notice carries, the rule that the body of the work never travels, and how routes choose what goes where.

The fleet announces particular events, and one rule governs all of them: a notice tells you that something happened and names the thing it happened to. It never carries the work itself.

"Names", and at most a pointer. What a notice gives you is a stream name you take to the console or the command line yourself. Beneath the message, a binding adds a reference line: if your installation declares the console's public address (server.public_base_url), a notice that names a stream and a gate carries an open in the console link to that gate; every other notice carries a plain ref: line naming the record. ⚠ Not every type names a stream — delivery.failed says so in the message itself, and seat.hard_logout names a seat and a machine instead.

The notice types

These names are the vocabulary. They are what you write into notice.route.<n>.type. The table is the set the product declares in this release, and once any route exists every one of them must be routed or the configuration refuses to load (route-type-unrouted).

TypeWhat it announcesSeverity
owner.act_owedAn act is owed by a human. Something in the fleet is waiting and will keep waiting until a person does it.urgent
gate.signedA gate was signed — a human decision has been recorded.notable
merge.landedA merge landed.notable
delivery.failedA notice could not be delivered. ⛔ Route it to a different binding from the one it would be reporting on — a failure report sent through the path that just failed is a report you will not read. The copy block sets a binding of its own for it.red
seat.hard_logoutA seat is out of the fleet — the vendor ruled its credential dead, and the seat stays out until someone logs it back in.red
silence.refusal_unrepairedA leg stopped at a refusal, nothing repaired it, and the stream has been silent since.red
silence.terminal_no_successorA leg ended, nothing started after it, and a gate it owes was never asked for.notable
silence.verdict_missingA review ended without an accept and without a reject, so the work it reviewed cannot move.red

The severity scale, lowest first, is info · notable · red · urgent. These types — owner.act_owed, seat.hard_logout and delivery.failed — are declared as never to be silenced.

Which event sends which message

Sent whenMessage
A human records a gate decision. The message is produced by the same act that writes the decision down — not by a later scan that noticed it.gate.signed
A merge is observed to have actually happened in git. Not when it was authorised — authorising a merge and a merge happening are two different facts, and this message is tied to the second.merge.landed
A stream starts owing its owner an act: a returned status is taken into the record (the owner now owes the second gate), or that gate is accepted (the owner now owes the merge).owner.act_owed
The sender runs out of attempts for a notice it could not deliver.delivery.failed
A seat's credential is condemned: after a second opinion, the record holds the verdict that the vendor ruled it dead. Only that outcome is announced, never a transient failure.seat.hard_logout
A stream's last run was refused and nothing has repaired, gated or torn it down for 30 minutes.silence.refusal_unrepaired
A stream's last run ended and no act of any kind followed it for 60 minutes.silence.terminal_no_successor
A review leg ended cleanly and its verdict did not reach the stream it reviewed within 60 minutes.silence.verdict_missing

The silence.* types are found by the server daemon on its periodic sweep, inside a window: they fire after the delays above and not at all once a stream has been quiet for more than 12 hours, because past that the reading means "abandoned", not "somebody should pick this up". Each silence is announced once — a new run on the stream that goes quiet again is a new silence. ⚠ Two holes follow from how they are found: a stream that was never dispatched has no run to be silent about, and nothing is swept while the server daemon is not running.

Why you would want each of them

  • gate.signed — the record that a human decided. For a team that wants to see decisions in the conversation rather than ask about them.
  • merge.landed — this is the audit one. It ties a change that shipped to the signed act that permitted it, in one message. If you keep an audit trail, this is the message that belongs in it.
  • owner.act_owed — the one that asks. Route it to the person who can act, not to a channel where it becomes everyone's and therefore nobody's. ⛔ You do that with a binding of its own — a route on this type naming that person's conversation — and not with notice.route.<n>.actor, because this type carries no actor for that key to match.
  • seat.hard_logout and the silence.* types — the stalls that will not clear on their own. Each ends on what is now expected of the reader.
  • delivery.failed — the one that tells you the others stopped arriving. ⛔ It is worth nothing if it travels the path that just failed; see the warning in the table above.

What is not here

There is no notice for work starting, none for a run finishing successfully, none for an error inside the fleet that the table above does not name, and none on a schedule or a digest. A notice is delivered when the sender's pass reaches it, and a failed delivery is retried on a bounded schedule (Slack) — not saved up to be summarised later.

The body never travels

⛔ The content of the work is not in the message. Not the text of an order, not a review's words, not a diff, not a log, not a message body, not the reasoning behind a decision.

Two consequences, and both of them are the point:

  1. Your chat workspace never becomes a second copy of the record. Whatever your retention, export or search settings are over there, they cannot reach what the fleet holds, because what the fleet holds was never sent.
  2. The boundary is between a name and its content — not between an event's existence and its detail. A mis-addressed binding therefore discloses considerably more than "an event happened". Size that disclosure by the table below.

What a mis-addressed binding would disclose

TypeWhat it puts in the conversation
owner.act_owedthe stream's title and its slug, which act is owed, the track name where the stream has one, and three time expressions. ⛔ No actor — nobody has acted yet, and that absence is the message
gate.signedthe stream's title and slug, the gate and the decision recorded on it, the name of the person who decided, the track name where there is one, and three time expressions
merge.landedthe stream's title and slug, the branch and the base it went into, the merge commit id, the id of the authorising gate, the name of the person who decided, the track name where there is one, and three time expressions
delivery.failedthe name of the binding you configured, the type of the notice that did not arrive, how it ended and how many attempts were made. ⛔ No stream, no track and no time — deliberately, because the channel that failed is the one that would have carried them
seat.hard_logoutthe most disclosing row in this table. A seat identity, a machine's name, which AI backend that seat runs, a quoted free-text diagnostic of the authentication failure, three time expressions, and a ready-to-run command carrying the machine name and the identity
silence.refusal_unrepairedthe stream's title and slug, the code of the refusal it stopped on and, where the product has one, a sentence saying what that refusal means, how long it has been silent, the track name where there is one, and three time expressions
silence.terminal_no_successorthe stream's title and slug, the status the run ended with, the gate it still owes, how long it has been silent, the track name where there is one, and three time expressions
silence.verdict_missingtwo streams — the reviewed one and the reviewer's own, each as a title and a slug — how long the review ran, the track name where there is one, and three time expressions

Beneath the message a binding appends up to three further lines, and they disclose too: your own note, the reference line (a console address, when you declared one), and the no-reply line if you turned it on.

Read the seat.hard_logout row twice before you route it. It is the one row where a wrong destination publishes your infrastructure rather than your work — a machine name, a credential's name, your choice of AI vendor and a command that names two of them.

The trade is deliberate: a notice is a name, not a copy. It tells you which stream to open, and you open it where the record lives — under the fleet's access control rather than your chat vendor's.

What a notice carries

Two things travel on every notice, because without them it could not be routed at all:

  • its type — one of the names in the table above, the value notice.route.<n>.type matches;
  • its severity — the value notice.route.<n>.severity_min compares against, on the scale given under the type table.

Everything else depends on the type, as the disclosure table lists. Three things to get right, because the opposite of each is the easy assumption:

  • An actor is not universal. owner.act_owed carries none. A route on it narrowed with notice.route.<n>.actor is accepted when you set it and never matches — so narrow that route with its binding, not with actor.
  • A time is not universal either. Every type carries the moment the event happened except delivery.failed. Where a time is carried it arrives as three expressions — how long ago, in words, and then the UTC and local pair that Languages and time describes.
  • Your own line is a property of the binding, not of the notice. It appears on what that binding delivers because you set it there.

Choosing what goes where

A route is a numbered rule, and each one answers up to four questions.

KeyThe question it answers
notice.route.<n>.typewhich type from the table at the top of this page this route carries — required
notice.route.<n>.bindingwhich binding — that is, which conversation, language and timezone — it delivers through; it must name a declared binding (route-binding-undeclared)
notice.route.<n>.severity_minhow severe a notice has to be before this route fires: info, notable, red or urgent; any other word is refused (route-severity-min-unknown)
notice.route.<n>.actorwhether it is narrowed to a single actor — on a type that carries one, which owner.act_owed does not

The keys machine, stream, tier and workspace are refused by name (route-matcher-deferred), not ignored: a filter that silently did nothing would look exactly like one that works.

Two routes may name the same type with different bindings, which is how one notice reaches both a channel and a person. The arrangement the copy block sets is three bindings and eight routes: the types that ask a person for something to that person's conversation, the ones that only report to a team conversation, and delivery.failed to a conversation nothing else uses. ⛔ Everything to one place is the arrangement to avoid, for the delivery.failed reason alone.

Read the result back with saphan config show, which prints each route under your own numbering. In the configuration file a route is a section of its own — notice.route.1.type is type under [notice.route.1] — see the key reference.

⚠ There is no digest and no quiet window. A route that matches a busy type delivers as the events happen, so narrow it with severity_min — and, on the types that carry an actor, with actor — or give the audience a binding of its own.

Examples

The blocks below are the English message text the product renders for some of the types, with invented values. ⛔ What they are not: they are the message only — a binding appends your note, the reference line and, if you turned it on, the no-reply line beneath it — and they are not evidence of what your own installation delivered.

owner.act_owed — the one that asks:

🔴 *DECISION NEEDED* · STOP-2
add retry budget to the importer `importer-retry-budget`

asked 11 min ago · 2026-10-07T03:29:04.412103Z · 10:29 Asia/Bangkok
Nothing on this stream moves until this gate is signed.

merge.landed — the audit one, and it repays a close look:

🟢 *LANDED* · add retry budget to the importer `importer-retry-budget`
`feature/importer-retry-budget` → `main` · `b9aab238`
authorised by fleet_gate#4711 · Jane Doe · 11 min ago · 2026-10-07T03:29:04.412103Z · 10:29 Asia/Bangkok
This stream's history is closed; nothing on it is owed to you.

Reading the merge line by line:

  • the first line — a marker, a headline word, and the stream: its title, then its slug in code type, so the slug can be copied without ambiguity;
  • the second line — feature/… → main and the merge commit: what merged into what, and the commit it produced;
  • authorised by fleet_gate#4711 — the signed human decision this merge was permitted by. The change and the permission are named in one message, which is the whole reason this is the type an audit trail wants;
  • Jane Doe — who decided;
  • three time expressions — 11 min ago for the person reading it now, the UTC instant for the record, and the local time for the reader's own clock. The last two are the pair Languages and time describes and the binding's timezone governs;
  • the track — a stream that belongs to a track carries an extra line naming it above the stream; most streams belong to none, and then the line is simply absent;
  • the last line — a plain-English sentence saying what is now expected of you. Every type ends with one.

A binding set to another language renders these same messages in that language; see Languages and time.

On this page