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).
| Type | What it announces | Severity |
|---|---|---|
owner.act_owed | An act is owed by a human. Something in the fleet is waiting and will keep waiting until a person does it. | urgent |
gate.signed | A gate was signed — a human decision has been recorded. | notable |
merge.landed | A merge landed. | notable |
delivery.failed | A 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_logout | A 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_unrepaired | A leg stopped at a refusal, nothing repaired it, and the stream has been silent since. | red |
silence.terminal_no_successor | A leg ended, nothing started after it, and a gate it owes was never asked for. | notable |
silence.verdict_missing | A 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 when | Message |
|---|---|
| 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 withnotice.route.<n>.actor, because this type carries no actor for that key to match.seat.hard_logoutand thesilence.*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:
- 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.
- 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
| Type | What it puts in the conversation |
|---|---|
owner.act_owed | the 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.signed | the 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.landed | the 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.failed | the 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_logout | the 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_unrepaired | the 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_successor | the 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_missing | two 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>.typematches; - its severity — the value
notice.route.<n>.severity_mincompares 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_owedcarries none. A route on it narrowed withnotice.route.<n>.actoris accepted when you set it and never matches — so narrow that route with its binding, not withactor. - 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.
| Key | The question it answers |
|---|---|
notice.route.<n>.type | which type from the table at the top of this page this route carries — required |
notice.route.<n>.binding | which binding — that is, which conversation, language and timezone — it delivers through; it must name a declared binding (route-binding-undeclared) |
notice.route.<n>.severity_min | how 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>.actor | whether 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:
merge.landed — the audit one, and it repays a close look:
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/… → mainand 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 agofor 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.
Slack
The app and its scopes, why the destination must be a conversation id, where the token goes, the three bindings and eight routes to copy, which machine sends, and what the delivery path refuses.
Languages and time
The two languages a binding can select, the two that are present but refused, how times are shown, and the one line you write yourself.