Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Notifications

A notification is mail. There is no notification bus, no notification feed,
and no second copy of "things that need you" — the durable record is always a
message in somebody's mailbox, and everything else (an unread count, a push to
Slack, an end-of-day digest) is a read of that mailbox or a fan-out from it.

## Approval is "needs you"

The platform already has the concept. A workflow run that parks on a signal is
represented by a `signal_correlation` row and an `approval` row, written
together by Interchange's own register co-write. That pair **is** the needs-you
state. `@corbits/notify` invents no sibling concept, widens no approval kind,
and registers no correlation of its own. It adds exactly one step the platform
was missing: an approval exists, therefore the people who can resolve it have
mail.

Two other things reach a person the same way and are simpler, because they
have nothing to resolve: a run that failed, and a mention in a thread.

## The shape

```
approval / run failure / mention
▼ parse (arktype) → render → one message per recipient
mailbox (the durable record, per human principal)
▼ post-commit, one row per (message, enabled sink)
notify_dispatch → dispatch worker → a sink
```

`deliverNotification` parses its input with `NotificationEvent` before anything
is written, so an unvalidated shape can never reach a mailbox. It writes one
message per recipient, keyed on a stable external id — an approval keys off the
approval itself, so a redelivered register frame mails once and only once.

Sink fan-out is queued strictly after the mail commits, never called inline.
A sink going down cannot cost anybody a notification: the message is already in
the mailbox, and `notify_dispatch` remembers what still owes a copy.

## `notify_dispatch`

The one table this package owns, and the only new table in the design. A row is
one attempt stream for one (message, sink) pair: `pending` → `delivered`, or
`failed` with an exponential backoff, or `dead` once the attempt ceiling is hit,
the failure is not retryable, or the sink is no longer registered at all. It is
bookkeeping, not an event log — the fact lives in the mail row it points at.

With no sink registered, `deliverNotification` queues zero rows and the worker
finds nothing due. That is the correct steady state of a fresh install.

## Adding a sink

A sink is a package, not a case in a switch. It exports a
`NotificationSinkPlugin` — a name, `isEnabledFor(scope)`, and `deliver(ctx)` —
and the hub's composition root registers it:

```ts
import { createSlackNotificationSink } from "@corbits/notify-sink-slack";

sinks.register(createSlackNotificationSink(deps));
```

That one line is also the approval gate. Installing a sink is a reviewed change
to the composition root, which is why there is no per-send approval prompt: a
notification about needing a human cannot itself wait on a human.

Nothing inside `@corbits/notify` changes when a sink is added. The registry
holds plugins by name, refuses two sinks with the same name, and the worker
resolves a queued row's sink by that name.

## Authorization

Reading is structural. A mailbox is scoped to a single principal by
construction, so there is no cross-principal read path to guard.

Emitting is checked. `resolveNotifyContext` authorizes the principal against
the platform's own grants — resource `notify:<sinkName>`, action `deliver`,
evaluated by `@intx/authz` exactly like `approval:<deploymentId>` is — and then
resolves the sink's credential. Each of the three ways this can fail has its
own named error: `NotifyGrantMissingError`,
`NotifySinkNotConfiguredError`, `NotifySinkCredentialInvalidError`.

## What a person sees

Subjects and bodies are written for a reader: `Approve "send_invoice"?`,
`"Nightly digest" failed`, `Sawyer mentioned you in "Launch plan"`. Identifiers
never appear in what is displayed — they travel in the message's `refs`, where
the interface uses them to navigate and nothing else.
28 changes: 28 additions & 0 deletions packages/notify/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@corbits/notify",
"private": true,
"description": "Turns things that need a human — a parked approval, a failed run, a mention — into mail in the recipient's mailbox, and fans that mail out to registered sinks",
"version": "0.0.1",
"license": "SEE LICENSE IN LICENSE.md",
"type": "module",
"exports": {
".": "./src/index.ts",
"./migrations": "./src/migrations.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@intx/authz": "workspace:*",
"@intx/hub-common": "workspace:*",
"@intx/types": "workspace:*",
"arktype": "catalog:",
"drizzle-orm": "catalog:",
"postgres": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
63 changes: 63 additions & 0 deletions packages/notify/src/approval-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// The step the platform is missing: an approval exists, therefore somebody
// has mail. The approval and its signal correlation are already written by
// the platform's own register co-write; this reads that pair back and
// delivers it, registering nothing and widening nothing.
//
// Delivery keys off `approval.id`, which is also the mailbox dedupe key, so a
// redelivered register frame — sidecar reconnect, log replay, supervisor
// restart — mails once and only once.
import { deliverApprovalMail, type NotifyDeliveryDeps } from "./deliver";
import type { NotifyRecipient } from "./events";

export interface ParkedApproval {
readonly approvalId: string;
readonly tenantId: string;
readonly runId: string;
readonly deploymentId: string;
/** The tool the run is asking to call, named the way a person would read it. */
readonly toolName: string;
readonly toolArguments: object;
readonly createdAt: Date;
}

export interface ApprovalNotificationBridgeDeps {
readonly delivery: NotifyDeliveryDeps;
readonly findParkedApproval: (
correlationId: string,
) => Promise<ParkedApproval | null>;
/** Who may resolve this approval, and therefore who should hear about it. */
readonly listApprovers: (
approval: ParkedApproval,
) => Promise<readonly NotifyRecipient[]>;
}

export type ApprovalNotificationBridge = (
correlationId: string,
) => Promise<void>;

/**
* Build the "an approval was created, so mail it" step. A correlation with no
* approval row, or an approval nobody can resolve, delivers nothing rather
* than mailing into the void.
*/
export function createApprovalNotificationBridge(
deps: ApprovalNotificationBridgeDeps,
): ApprovalNotificationBridge {
return async (correlationId) => {
const approval = await deps.findParkedApproval(correlationId);
if (approval === null) return;
const recipients = await deps.listApprovers(approval);
if (recipients.length === 0) return;
await deliverApprovalMail(deps.delivery, {
kind: "approval",
approvalId: approval.approvalId,
tenantId: approval.tenantId,
runId: approval.runId,
deploymentId: approval.deploymentId,
toolName: approval.toolName,
toolArguments: approval.toolArguments,
recipients: [...recipients],
createdAt: approval.createdAt.toISOString(),
});
};
}
112 changes: 112 additions & 0 deletions packages/notify/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Emit-time authorization and credential resolution for one sink, in one
// place. Read-time authorization needs nothing here: a mailbox is scoped to a
// single principal by construction, so there is no cross-principal read to
// guard. What has to be checked is the other direction — whether this install
// may push a principal's notification out to an external place at all.
import { authorize } from "@intx/authz";
import type { ConditionRegistry, GrantStore } from "@intx/types/authz";

export type NotifyCredential = {
readonly id: string;
readonly name: string;
readonly kind: string;
};

export type NotifyContext = {
readonly tenantId: string;
readonly principalId: string;
readonly sinkName: string;
readonly credential: NotifyCredential;
};

export class NotifyGrantMissingError extends Error {
constructor(sinkName: string, tenantId: string) {
super(
`No grant allows delivering notifications through the ${JSON.stringify(sinkName)} ` +
`sink in this workspace. Grant "notify:${sinkName}" in ${tenantId} first.`,
);
this.name = "NotifyGrantMissingError";
}
}

export class NotifySinkNotConfiguredError extends Error {
constructor(sinkName: string) {
super(
`The ${JSON.stringify(sinkName)} notification sink has no credential configured ` +
"in this workspace, so there is nothing to deliver through.",
);
this.name = "NotifySinkNotConfiguredError";
}
}

export class NotifySinkCredentialInvalidError extends Error {
constructor(sinkName: string, expectedKind: string, actualKind: string) {
super(
`The credential configured for the ${JSON.stringify(sinkName)} notification sink is a ` +
`${JSON.stringify(actualKind)} credential, but the sink needs a ${JSON.stringify(expectedKind)} one.`,
);
this.name = "NotifySinkCredentialInvalidError";
}
}

export const NOTIFY_DELIVER_ACTION = "deliver";

export interface ResolveNotifyContextDeps {
readonly grantStore: GrantStore;
readonly conditionRegistry?: ConditionRegistry;
/** The credential an operator configured for this sink in this workspace, if any. */
readonly findSinkCredential: (args: {
tenantId: string;
sinkName: string;
}) => Promise<NotifyCredential | null>;
}

export interface ResolveNotifyContextArgs {
readonly tenantId: string;
readonly principalId: string;
readonly sinkName: string;
readonly credentialKind: string;
}

/**
* Resolve the grant and credential one sink delivery needs, or throw a named
* error saying exactly which of the three is missing. Grants are the platform's
* own — the resource string is `notify:<sinkName>`, matched by `@intx/authz`
* the same way `approval:<deploymentId>` is.
*/
export async function resolveNotifyContext(
deps: ResolveNotifyContextDeps,
args: ResolveNotifyContextArgs,
): Promise<NotifyContext> {
const result = await authorize(
deps.grantStore,
args.principalId,
args.tenantId,
`notify:${args.sinkName}`,
NOTIFY_DELIVER_ACTION,
deps.conditionRegistry,
);
if (result.effect !== "allow") {
throw new NotifyGrantMissingError(args.sinkName, args.tenantId);
}
const credential = await deps.findSinkCredential({
tenantId: args.tenantId,
sinkName: args.sinkName,
});
if (credential === null) {
throw new NotifySinkNotConfiguredError(args.sinkName);
}
if (credential.kind !== args.credentialKind) {
throw new NotifySinkCredentialInvalidError(
args.sinkName,
args.credentialKind,
credential.kind,
);
}
return {
tenantId: args.tenantId,
principalId: args.principalId,
sinkName: args.sinkName,
credential,
};
}
Loading
Loading