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

KindBuilt-in audience (§6.4)
needs_you, approval, conflictThe TODO's owner and the people on its branch
in_review, failed, mergedThe 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

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

PartFile
Contract and the rules the app enforcespackages/rpc/src/NotificationPolicy.ts
Declaration read, run and fallbackapps/app/src/mainview/state/controller/notificationPolicy.ts
Delivery, with access checked first and lastapps/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.