Superseded on 2026-10-20 by apps/mvp/docs/rulings.md (ARCH-1; decision 0009). Kept as prior art.
Repository notification policy
Implemented for #4026 (M-47, .specs/product/mvp.md §6.17). A repository flow decides who is told about a defined set of repository notifications. Delivery is unchanged. Source-green only; this document is not production evidence.
Declaring it
A repository claims notification policy with one row of the Dispatcher table it already writes in .smithers/FACTORY.ts, on the event the app asks on:
on: {
"notification.route": { flow: "notification-policy", description: "Choose who is told" }
}
notification.route is a key of the existing event vocabulary, and the row projects into .smithers/factory.json through //:factoryProjection like every other rule. No new field, schema, store or configuration screen exists for this: the declaration is the repository's typed source, and the app reads it from the signed-out contents route it already reads the Dispatcher card from. Two rows for the event, or one row naming two flows, is ambiguous and the built-in audience stands.
The flow itself is an ordinary repository flow at flows/<name>/flow.ts — repository scope, priority 2 of the catalog order in #3871. This repository ships .smithers/flows/notification-policy/flow.ts as the one a maintainer copies.
The defined set of events
| Kind | Built-in audience (§6.4) |
needs_you, approval, conflict | The TODO's owner and the people on its branch |
in_review, failed, merged | The TODO's owner |
These are the notices §6.4 hard-coded. A kind outside the set keeps its own audience and starts no policy run.
The contract
@smthrs/rpc/NotificationPolicy is the one contract both sides hold. The request carries identity alone:
{ repo, event: { kind, entryId, todo? }, owner, candidates, defaults }
No title, prompt, body, comment, diff or error text travels into a policy run, so a decision cannot carry repository content or a customer's words into a notification, a run trace or a diagnostic. candidates is everyone who already can read the event; defaults is who the built-in audience would tell, always a subset of the candidates.
The answer is bounded typed recipient ids plus the one allowed handling flag, parsed strictly:
{ recipients: ReadonlyArray<login>, handling?: { quiet?: boolean } }
Up to 50 recipients, each a GitHub login. An answer carrying any other key does not decode, so arbitrary diagnostics, a URL or a secret value cannot ride one out of the run.
What the app guarantees, not the policy
- Access is checked after the policy selects. A selected recipient who is not a candidate is withheld and counted. The policy narrows who is told among people who can already read the work; it can never grant access to anything.
- A missing policy keeps the existing default. With no declaration nothing runs, nothing is recorded, and every notice behaves exactly as before — including when the declaration read was slow, because a read that answers "nothing is declared" is knowledge rather than a refusal and is not stamped as one. A read that does not answer inside the bound is a different thing and records
policy-unavailable.
- A failure is never silence. An ambiguous, unreadable, failed, malformed or superseded policy delivers the built-in audience and records one closed reason on the notice:
policy-ambiguous, policy-unavailable, policy-failed, policy-malformed, policy-superseded. No error text, path, URL or credential is part of that record.
- Identity stays bound. The person, identity revision and repository are captured before the run and re-checked before the answer is believed. A TODO card carries no repository of its own, so the repository is the active target (
RepoContext.ts resolveTargetRepo), read again on every fence rather than captured once. A late answer that arrives after an account or repository switch delivers and persists nothing — a retired account's decision is never another account's notification, and a decision reached under one repository is never delivered under the next. The fence is re-asked after the declaration read as well as after the run's answer, so an absent, unreadable or ambiguous declaration — the common case, where no run exists to fence — also tells nobody once the person has moved on, rather than delivering the first repository's default under the second.
- A failure never widens an audience. Every fallback delivers the default audience, which for
in_review, failed and merged is the owner alone. A branch reader the built-in audience would not have told is not told by a policy failure either, and an unexpected throw at the seam's own boundary is a refusal like any other (policy-unavailable) rather than a rejection its caller has to own.
- The wait is finite end to end. The run-summary read count bounds nothing by itself —
controller/gateway.ts sets no fetch deadline and re-reads a provisioning snapshot for up to 180 s per call — so the decision is bounded by the clock as well, and end to end names the stages it covers: the clock starts before the declaration read and bounds that read, the launch and the run-summary poll alike, whichever ends first. The declaration read is inside the bound because it is the one stage every notice crosses, declared policy or not: a mirror that parks on .smithers/factory.json would otherwise suppress an ordinary no-policy notification rather than delay it. Nothing continues into an effect once the bound has passed: the launch's own stillCurrent counts the deadline as part of being current, and the relay asks it before Plan as well as between Plan, Approval.Submit and Run, so a declaration or a plan that answers late grants no approval and starts no run. Past it nobody is still polling, nothing continues into a new effect, and the default is delivered exactly once — so a hung policy can delay a notification but never suppress it and never deliver it twice.
- A resolved event is not re-raised. Work moves while a policy decides. A wait answered before the answer lands, or a state a newer state has superseded, is no longer the card's own event, so the decision about it raises nothing — the settled Needs You stays settled.
- Delivery is unchanged. The toast, the timeline entry, the browser notification and the repository update card are the same surfaces, and read receipts are untouched. Only the recipient decision moved — and
handling.quiet, which keeps the record and skips the raise.
handling.quiet
A policy that answers { recipients: [...], handling: { quiet: true } } is asking for the notification record without the interruption. The record is kept exactly as any other notice: one toast.shown row with its audience, its policy stamp and its quiet flag, so the timeline line, the repository update and the person's own Hide (#4034) all still see it. The two surfaces that raise a notice skip it — the HUD stack (ShellRail.tsx railNotices) and the browser notification (controller/failures.ts), which also means a quiet notice never triggers the browser permission ask.
audience.quiet is a new optional field on the local toast row and nothing else: it is absent on every already-stored notice, the projector that writes the audience did not move, and the raised surfaces read its absence as "raise it", so no session migration or projector version is owed (AppEventStream.ts notes 36 and 37). There is no new engine, scheduler or settings screen for it: quiet is one flag of the one closed handling shape the contract already decodes.
How it runs
One ordinary workflow launch per notice through ctx.gateway: Plan, the launch's approval, Run, then the run summary's own answer — the same path a button, a slash command or an agent call uses, with the same workspace binding and the person's own session. The idempotency key is the notice's identity, so a repeated read of the same TODO joins the decision already in flight instead of starting a second run. The declaration is read once per repository per session, the cadence the flow catalog already uses. It is the repository's own public projection, served to a signed-out visitor, so nothing account-private is cached; the decision it leads to is fenced on the person, identity revision and repository instead.
The example flow reads nothing, writes nothing, spawns nothing and calls no model: a routing rule is a declaration the repository owns, and keeping it deterministic is what makes it safe to run on every notice. It is modelInvocable: false — a notification's audience is not a model's guess.
Source
| Part | File |
| Contract and the rules the app enforces | packages/rpc/src/NotificationPolicy.ts |
| Declaration read, run and fallback | apps/app/src/mainview/state/controller/notificationPolicy.ts |
| Delivery, with access checked first and last | apps/app/src/mainview/state/seams/TodoSeam.ts |
| The example policy flow | .smithers/flows/notification-policy/flow.ts |
Not done here: the repository update card's own activity rows still project per account and are not routed by a policy, and this repository declares no notification.route rule of its own, so nothing is activated by landing this. A policy run that outlives its deadline is abandoned, not cancelled — the gateway exposes no abort for an HTTP read in flight, so an orphaned declaration or snapshot read ends on its own, and its rejection is absorbed rather than left unhandled. What abandonment does not mean is that the work carries on: the launch's own stillCurrent is the guard the relay consults between Plan, Approval.Submit and Run, and it counts the deadline as part of being current, so a Plan the workspace answers after the fallback grants no approval and starts no run. A held read is left to finish; a held launch is not resumed.