notifications

package
v1.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 19, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package notifications ships Harbor's `notification.*` event family — a NEW event topic on the typed event bus carrying operator-facing notifications (alerts surfaced by the Console's notification centre, the Overview page alert ribbon, and the Settings notification-routing matrix).

The family uses **per-class topic naming**:

  • notification.task_failed (severity Error)
  • notification.tool_approval_requested (severity Warning)
  • notification.governance_budget_exceeded (severity Error)
  • notification.auth_required (severity Warning)
  • notification.pause_requested (severity Info)

Per-class topics compose naturally with the rest of Harbor's event taxonomy (`tool.failed`, `task.completed`, `governance.budget_exceeded` all use per-class topics) and with the `events.subscribe` topic-filter shape. The alternative — a single `notification.emit` with a payload class field — was rejected by the notifications wire-shape decision in favour of per-class topics.

Architecture:

  • Map(ctx, triggering ev) → []events.Event is a pure function. No I/O. No global state. No time.Now() dependency (OccurredAt is filled by the bus's Publish path, matching the existing bus convention). Concurrent calls against a single shared mapper are safe by construction (no state to share); the concurrent-reuse contract is trivially satisfied; the test still runs N=100 concurrent invocations under -race to validate end-to-end.

  • Subscriber is a long-lived bus consumer that wires Map onto Publish. NewSubscriber(bus, log) constructs the wiring; (*Subscriber).Run(ctx) opens an Admin-scope subscription on the V1 trigger event types, runs Map on every delivered event, and republishes each synthesised notification.* event onto the same bus. The originating event's identity.Quadruple is preserved on the synthesised event so identity-scope filtering on the downstream subscriber works without elevation.

  • The NotificationPayload embeds events.Sealed (NOT SafeSealed). The Summary field is caller-controlled (a human-readable one-liner derived from the originating event's typed payload), so the bus's redactor walks the payload on Publish (per audit-redactor- as-bus-boundary). The synthesised event still rides the same bus.Publish path every other event ships through.

What is OUT of scope here:

  • Notification routing fan-out (email / Slack / web-push). Per-user routing lives in 73m Settings + the Console DB's notifications_routing table.
  • Severity escalation policy ("notify only when severity >= warning"). V1 emits one notification per matching trigger; downstream filtering uses the existing events.subscribe filter shape.
  • Snooze / dismiss / mute-this-trigger user actions (Console DB only, not runtime entities).
  • Anomaly detection (post-V1).
  • Persistence of notification history (rides the same events bus replay surface — ring + durable log).
  • New Protocol methods (notification.* is an event topic, consumed via the existing events.subscribe surface).

Harbor implements the §13 primitive-with-consumer rule via a test consumer (`subscriber_test.go::TestSubscriber_TaskFailedSynthesisesNotificationTaskFailed`) that fires a deliberate `task.failed` and asserts the synthesised `notification.task_failed` arrives at a separately-scoped subscriber via the bus. The UI consumers (73a Overview alert ribbon, 73m Settings notification-routing matrix) land in and cannot substitute for the test consumer per the notifications decomposition decision. See also docs/decisions.md.

Index

Constants

View Source
const (
	// EventTypeNotificationTaskFailed — synthesised from task.failed.
	// Severity Error. The deep-link points at the failed
	// task in the Console.
	EventTypeNotificationTaskFailed events.EventType = "notification.task_failed"

	// EventTypeNotificationToolApprovalRequested — synthesised from
	// tool.approval_requested. Severity Warning. The deep-link
	// points at the pending approval in the Console's Tools page.
	EventTypeNotificationToolApprovalRequested events.EventType = "notification.tool_approval_requested"

	// EventTypeNotificationGovernanceBudgetExceeded — synthesised from
	// governance.budget_exceeded. Severity Error. The
	// deep-link points at the affected tenant/session's governance
	// posture in the Console.
	EventTypeNotificationGovernanceBudgetExceeded events.EventType = "notification.governance_budget_exceeded"

	// EventTypeNotificationAuthRequired — synthesised from
	// tool.auth_required. Severity Warning. The deep-link
	// points at the OAuth-binding flow in the Console's MCP Connections
	// page.
	EventTypeNotificationAuthRequired events.EventType = "notification.auth_required"

	// EventTypeNotificationPauseRequested — synthesised from
	// pause.requested. Severity Info. The deep-link points
	// at the paused task in the Console's Interventions queue.
	EventTypeNotificationPauseRequested events.EventType = "notification.pause_requested"

	// EventTypeNotificationTaskGroupResolved — synthesised from
	// task.group_resolved. Severity Info when every member
	// succeeded, Warning when any member failed or was cancelled. The
	// deep-link points at the resolved group in the Console. This is the
	// conversational mirror of the typed group-completion wake — the
	// planner still consumes the typed GroupCompletion path unchanged.
	EventTypeNotificationTaskGroupResolved events.EventType = "notification.task_group_resolved"

	// EventTypeNotificationTaskCompleted — synthesised from
	// task.completed ONLY when the completing task carried the
	// NotifyOnComplete opt-in. Severity Info. Closes the silent-
	// background-success gap: a solo background task that finishes
	// produces an operator-visible completion line, not just the typed
	// result the planner already had.
	EventTypeNotificationTaskCompleted events.EventType = "notification.task_completed"

	// EventTypeNotificationIdentityRejected — emitted by the Subscriber
	// when a trigger event arrives with the `<missing>` sentinel
	// substituted into one or more identity components. Mirrors the
	// `memory.identity_rejected` / `skill.identity_rejected` shape so
	// audit observers consume one identity-rejection vocabulary across
	// the runtime. Fail-loudly per CLAUDE.md §13 — the Subscriber
	// emits this rejection event instead of silently dropping the input
	// or silently publishing a notification with a malformed identity.
	EventTypeNotificationIdentityRejected events.EventType = "notification.identity_rejected"
)

V1 notification event-type constants. Each is registered with the canonical events.EventType registry from this package's init() so a Publish never trips events.ErrUnknownEventType.

Per-class topic naming is locked by the notifications wire-shape decision.

View Source
const MaxMemberSummaries = 20

MaxMemberSummaries caps the number of per-member entries carried on a task_group_resolved notification payload. It balances "the operator can see what happened" against payload size and audit-redactor walk cost on large batches, and stays comfortably above the common parallel-spawn fan-out while keeping the mirror a single bounded line. When a group exceeds the cap the payload carries the first MaxMemberSummaries members and sets MembersTruncated — never a silent drop (CLAUDE.md §13).

Variables

View Source
var ErrUnmappable = errors.New("notifications: triggering event structurally invalid for mapping")

ErrUnmappable is returned by Map when a triggering event is structurally invalid for mapping — its payload type does not match the expected typed payload for its declared event type. Callers compare via errors.Is.

Fail-loudly per CLAUDE.md §13: a structurally invalid trigger event MUST NOT silently degrade to "no notifications." The Subscriber catches ErrUnmappable, logs at Error, and emits a runtime.error observability event; it does NOT republish a malformed notification.

Functions

func Map

func Map(_ context.Context, ev events.Event) ([]events.Event, error)

Map translates a triggering bus event into zero or more synthesised notification.* events.

Map is a PURE function. No I/O. No global state. No time.Now() dependency (the bus's Publish path fills OccurredAt on the synthesised event). Concurrent calls against a single shared mapper instance are trivially safe — there is nothing to share. The concurrent-reuse concurrent-reuse is satisfied by construction.

Return contract:

  • (nil, nil) — the event's Type is not in V1TriggerEventTypes. The vast majority of bus traffic hits this branch; the Subscriber's bus filter already narrows the input set so the happy-unmapped case is rare in practice, but the contract is defined here so the function is safe to call against any event.

  • ([]events.Event with one element, nil) — the event was a known trigger type with a well-formed typed payload; the slice carries the synthesised notification.* event.

  • (nil, wrapped ErrUnmappable) — the event was a known trigger type BUT the typed payload assertion failed (wrong payload type for the declared event type, or a RedactedMap arrived where the bus normally delivers the typed shape). Fail-loudly per CLAUDE.md §13 — never silently degrade to "no notifications."

The synthesised event carries the trigger's identity.Quadruple, the V1 class's per-class severity (a settled heuristic — see the per-case comments below for rationale), and a per-class deep-link shape. The bus's Publish path fills Sequence and OccurredAt.

Note: trigger event payloads in V1 are all SafePayload by construction (TaskFailedPayload, ToolApprovalRequestedPayload, BudgetExceededPayload, ToolAuthRequiredPayload, PauseRequestedPayload all embed events.SafeSealed). The bus therefore delivers them with their typed shape preserved. A RedactedMap on a known trigger type is a contract violation upstream — Map fails loudly via ErrUnmappable so the violation does not silently downgrade.

func V1NotificationClasses

func V1NotificationClasses() []events.EventType

V1NotificationClasses returns a deterministic snapshot of every notification event-type the V1 mapper synthesises. Useful for boot- log output, for tests asserting exhaustiveness, and for the Subscriber to scope its bus subscription. The identity-rejection class is NOT included — it is emitted only by the Subscriber's own error path and is not a class consumers filter on positively.

func V1TriggerEventTypes

func V1TriggerEventTypes() []events.EventType

V1TriggerEventTypes returns the set of bus event types the V1 mapper listens for. Anything outside this set is unmapped (Map returns (nil, nil)); anything inside maps to exactly one notification.* event.

`agent.credentials_expired` and `runtime.health_degraded` are named in the design's "starter list" but are not shipped V1 event types; the mapper accepts them via the input-type registry as future inputs but emits nothing for V1 unmapped inputs. Adding the mappings is a one-line change in a future phase.

Types

type IdentityRejectedPayload

type IdentityRejectedPayload struct {
	events.SafeSealed

	// Operation is the rejected operation name (always
	// "Subscriber.Run" for V1; future operations may extend the
	// vocabulary).
	Operation string

	// Reason names the missing identity components ("tenant_id empty",
	// "user_id and session_id empty", etc.). Deterministic ordering.
	Reason string

	// OriginEventType is the bus event-type the Subscriber was trying
	// to map when the identity check failed.
	OriginEventType events.EventType
}

IdentityRejectedPayload reports a Subscriber-side identity rejection: the trigger event arrived with the `<missing>` sentinel substituted into one or more identity components, so the Subscriber emits this rejection event instead of silently dropping the input or synthesising a malformed notification (CLAUDE.md §13 fail-loudly + mirrors the `memory.identity_rejected` shape).

SafePayload by construction — `Operation` is a bounded constant ("Subscriber.Run"), `Reason` is a short static string naming the missing component(s), `OriginEventType` is an EventType enum.

type MemberOutcomeSummary added in v1.16.0

type MemberOutcomeSummary struct {
	// TaskID is the member task's identifier.
	TaskID string
	// Status is the member's terminal status ("complete" / "failed" /
	// "cancelled").
	Status string
	// Description echoes the member Task.Description (bounded, already
	// caller-side redacted upstream; the bus redactor walks it again on
	// Publish because NotificationPayload is non-Safe).
	Description string
}

MemberOutcomeSummary is the ref-shaped per-member record carried on a task_group_resolved NotificationPayload. It echoes only the member's identifier, terminal status, and human-readable description — never the member's Result or Error bytes, which stay on the typed GroupCompletion wake path the planner consumes.

type NotificationPayload

type NotificationPayload struct {
	events.Sealed

	// Class is the notification.* class this payload belongs to. One
	// of EventTypeNotificationTaskFailed / ToolApprovalRequested /
	// GovernanceBudgetExceeded / AuthRequired / PauseRequested /
	// TaskGroupResolved / TaskCompleted.
	Class events.EventType

	// Severity is the operator-visible urgency.
	Severity Severity

	// Summary is a human-readable one-liner the Console renders.
	// Caller-controlled (the mapper derives it from the originating
	// event's typed payload); the audit redactor walks it on Publish.
	Summary string

	// DeepLink is a Protocol-relative path (e.g. "/console/tasks/<id>").
	// The Console renders this via its router.
	DeepLink string

	// OriginEventType is the originating event's Type
	// (e.g. "task.failed").
	OriginEventType events.EventType

	// OriginEventSequence is the originating event's bus Sequence
	// (correlation key).
	OriginEventSequence uint64

	// TaskID is the subject task for single-task classes
	// (task_failed, task_completed). Empty for classes that do not
	// name a single task.
	TaskID string

	// GroupID is the resolved group for the task_group_resolved class.
	// Empty otherwise.
	GroupID string

	// Members is the ref-shaped per-member outcome summary for the
	// task_group_resolved class. Bounded at MaxMemberSummaries — when
	// the group has more members than the cap, the first
	// MaxMemberSummaries are carried and MembersTruncated is set (never
	// a silent drop). Member Result/Error bytes never cross onto this
	// payload; only TaskID/Status/Description.
	Members []MemberOutcomeSummary

	// MembersTruncated reports that Members was capped at
	// MaxMemberSummaries and does not enumerate every group member. The
	// counts below still reflect the full membership.
	MembersTruncated bool

	// MemberSucceeded / MemberFailed / MemberCancelled are the full-
	// membership terminal-status tallies for the task_group_resolved
	// class (independent of the Members cap). Zero for other classes.
	MemberSucceeded int
	MemberFailed    int
	MemberCancelled int
}

NotificationPayload is the typed payload for every notification.* event. Embeds events.Sealed (NOT events.SafeSealed): the Summary field is human-readable and derived from caller-controlled bytes on the originating event's typed payload, so the bus's redactor walks the payload on Publish.

Field semantics:

  • Class is the notification.* class — one of the V1 constants. Echoed here so subscribers parsing only the payload (e.g. when reading from a durable event log without the bus envelope) still have the class.
  • Severity is the operator-visible urgency.
  • Summary is a one-liner the Console renders in its notification centre. Derived from the originating event's typed payload (e.g. `task.failed` → "Task <id> failed with code <code>"). The mapper builds the summary; the audit redactor walks it.
  • DeepLink is a Protocol-relative path the Console's router deep-links into. V1 hard-codes the shape per class; if the Console's route shape changes post-V1 the mapper updates without a Protocol break (the runtime stays the source of truth).
  • OriginEventType is the bus event-type the mapper consumed. Lets subscribers join the notification with its source event without additional bus traffic.
  • OriginEventSequence is the bus Sequence of the originating event. Stable correlation key across the runtime's lifetime.

type Severity

type Severity string

Severity classifies a notification's operator-visible urgency. Per By design the Console renders notifications in alert ribbons / notification centres with a severity-derived colour and ordering.

V1 is a fixed three-value enum. A richer model (payload-derived severity, dynamic thresholds) is post-V1.

const (
	// SeverityInfo — operator-visible but non-actionable. Example:
	// notification.pause_requested (a planner asked for human input;
	// the runtime is healthy).
	SeverityInfo Severity = "info"

	// SeverityWarning — operator-actionable but not blocking. Example:
	// notification.tool_approval_requested (a tool call is parked on
	// an approval gate); notification.auth_required (a tool needs
	// OAuth binding).
	SeverityWarning Severity = "warning"

	// SeverityError — operator-actionable and blocking. Example:
	// notification.task_failed (a task entered a terminal failure
	// state); notification.governance_budget_exceeded (a budget cap
	// triggered).
	SeverityError Severity = "error"
)

type Subscriber

type Subscriber struct {
	// contains filtered or unexported fields
}

Subscriber wires the rules-engine-lite mapper onto a long-lived bus subscription. It consumes V1 trigger events already emitted and republishes a `notification.*` event for each match through the same bus.

Lifecycle: call Start to establish the bus subscription synchronously (Start returns an error on failure; never silently degrades), then launch the returned run closure in a goroutine. Run is a convenience wrapper that does both steps sequentially — prefer Start when the caller needs delivery guarantees immediately after construction.

Concurrent-reuse contract: the Subscriber is a long-lived component constructed once and shared across the bus's lifetime. It owns no per-run state — every call into the mapper is pure. Multiple Subscribers may be wired onto the same bus (rare in production — the default boot wires one — but the type does not block it).

Goroutine-leak contract: Run blocks until ctx is cancelled OR the bus closes its delivery channel. On return, the subscription's Cancel() has been called and the bus's reaper has joined the underlying goroutine. The mandatory leak test (subscriber_test.go::TestSubscriber_Run_GoroutineLeak) asserts runtime.NumGoroutine() returns to baseline after Run returns.

func NewSubscriber

func NewSubscriber(bus events.EventBus, log *slog.Logger) *Subscriber

NewSubscriber constructs a Subscriber. The bus is mandatory (nil panics — the constructor fails loudly per CLAUDE.md §13 because a nil bus would silently degrade the entire subscriber to a no-op). The logger is mandatory; pass slog.Default() if no contextual logger is available.

func (*Subscriber) Run

func (s *Subscriber) Run(ctx context.Context) error

Run opens an Admin-scope subscription on the V1 trigger event types and republishes each synthesised notification.* event onto the same bus. Blocks until ctx is cancelled OR the bus closes the subscription's delivery channel.

Run is a convenience wrapper around Start: it establishes the subscription synchronously and then runs the blocking consume loop in the same call. Callers that need the subscription to be live before further work proceeds should call Start directly so the synchronous subscribe step is separated from the blocking loop.

func (*Subscriber) Start added in v1.4.0

func (s *Subscriber) Start(ctx context.Context) (func() error, error)

Start establishes the Admin-scope bus subscription synchronously and returns a run closure that, when called, blocks until ctx is cancelled or the bus closes the subscription's delivery channel.

The subscription is established before Start returns, so callers that need the notification.* topic to have a live consumer immediately after construction (e.g. an assembler that publishes trigger events synchronously after boot) can call Start, confirm success, then launch the returned closure in a goroutine. A Subscribe failure is returned directly and must be treated as fatal — the topic cannot be made live without a valid subscription.

The returned func must be called exactly once; it owns the subscription's lifecycle and calls sub.Cancel() on return.

The subscription is Admin-scope (Filter.Admin=true) because the Subscriber is a runtime-internal infrastructure consumer that must fan in across the full identity space — every tenant, user, and session generates trigger events the notification topic should cover. The Admin scope use is audit-emitted by the bus on subscription open (events.EventTypeAdminScopeUsed); the Subscriber is therefore observable as a privileged consumer the same way every other Admin-scope subscriber is.

Identity-rejection fail-loudly path: if a delivered trigger event arrives with the `<missing>` identity sentinel in any component, the run closure emits a `notification.identity_rejected` event (SafePayload — no caller bytes) AND logs at Error, then continues. The malformed trigger does NOT silently produce a malformed notification.

Mapper-error fail-loudly path: if Map returns a non-nil error (always wrapped ErrUnmappable), the closure logs at Error and emits a `runtime.error` event via the bus; no notification.* event is emitted for that trigger.

Publish errors are logged at Error; the closure continues so a transient bus issue does not collapse the subscriber.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL