commons

package module
v0.0.0-...-9f2d632 Latest Latest
Warning

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

Go to latest
Published: Jun 27, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package commons — Branding factory per spec V3 §6.3 + §11.0.

Package commons — clock abstraction per spec V3 §3.5.

Herald never calls time.Now() directly outside this file. The Clock abstraction lets tests fast-forward time, which is essential for quiet-hours, batching windows, retry backoff, idempotency TTLs, and escalation chains.

Participant identity model per docs/design/PARTICIPANT_ATTRIBUTION.md §1.

A Participant is one logical person/agent (a Subscriber/User) who may carry a DIFFERENT @username on every messenger channel. Workable items store the canonical, messenger-neutral handle in their created_by / assigned_to fields; the IdentityResolver bridges between that canonical world and the per-channel runtime (inbound sender resolution + outbound @username lookup for tagging).

This is the contract surface other streams (inbound, workflow, channel adapters, storage) code against — the Go signatures here are load-bearing and match the §1/§3 contract exactly.

Notification-tagging matrix per docs/design/PARTICIPANT_ATTRIBUTION.md §3.

Package commons is Herald's L0 foundation. See spec V3 §10 + §11.0.

This file defines the Channel interface every adapter implements and its complete set of value types. Adapters under commons_messaging/ channels/<name>/ consume these types directly; no adapter is allowed to invent its own equivalent (spec §11.0).

Package commons — UUIDv7 generator per spec V3 §4.3 / §9.2 (uuidv7 primary keys).

UUIDv7 is naturally time-ordered (the first 48 bits are a Unix-millisecond timestamp), giving us B-tree-friendly indexes without the locality problems of UUIDv4. The implementation here is a thin wrapper around google/uuid v1.6+ which ships UUIDv7 native.

Index

Constants

View Source
const SystemAgentHandle = "Claude"

SystemAgentHandle is the reserved created_by/assigned_to sentinel for the system agent (Claude). It is NEVER @-tagged in any notification (it is the system, not a human participant). See §1 + §3.

Variables

This section is empty.

Functions

func MentionsFor

func MentionsFor(createdBy, assignedTo, operatorHandle, channel string, r IdentityResolver) []string

MentionsFor returns the canonical handles to @-mention for a workable-item event dispatched to `channel`, implementing the §3 matrix exactly:

mentions = {}
if assigned_to is a human handle AND assigned_to != Operator:   mentions += assigned_to
if created_by  is a human handle AND created_by  != Operator AND created_by != "Claude":
                                                                mentions += created_by
# "Claude" is NEVER tagged (it is the system).
# Operator is NEVER tagged (no self-ping).
# de-dup; resolve UsernameFor(handle, channel) — skip if not on that channel.

The returned slice contains the canonical handles (NOT the resolved @usernames) that have a valid alias on `channel`, in a stable order (assigned_to before created_by), de-duplicated. A handle with no alias on `channel` is skipped — you cannot tag someone who is not on that messenger.

operatorHandle is passed explicitly (rather than read from r) so callers that already know the operator for a non-primary channel can override; pass r.OperatorHandle() for the default.

func MustUUIDv7

func MustUUIDv7() uuid.UUID

MustUUIDv7 panics on the (extremely rare) generator failure path. Use only at process boot or in test fixtures; production code should call NewUUIDv7 and propagate the error.

func NewUUIDv7

func NewUUIDv7() (uuid.UUID, error)

NewUUIDv7 returns a fresh time-ordered UUID.

Use this everywhere Herald generates primary keys (idempotency keys, dead-letter rows, inbound message ids, …) so that index inserts append to the rightmost leaf and stay cache-hot.

func OperatorHandleFromEnv

func OperatorHandleFromEnv(channel string) string

OperatorHandleFromEnv reads HERALD_<CHANNEL>_OPERATOR_USERNAME for the given channel (e.g. channel="tgram" -> HERALD_TGRAM_OPERATOR_USERNAME) and returns the operator's canonical handle. The channel is upper-cased; the value is returned verbatim (trimmed) — an empty string means "no operator configured for this channel". Per §1 the Telegram operator username is the operator's canonical handle, so callers building the roster typically pass channel "tgram".

func ProjectName

func ProjectName() string

ProjectName resolves the Claude Code session name per the Wave 6 operator-locked decision (2026-05-22):

  1. HERALD_PROJECT_NAME env var wins when non-empty after TrimSpace.
  2. Otherwise filepath.Base(os.Getwd()) — the current working directory's basename.
  3. Otherwise (cwd unreadable) the final fallback is "Herald".

Every pherald subcommand that touches the Claude Code dispatcher MUST call commons.ProjectName() rather than hardcoding "Herald" — pins the session name to the operator's project context.

Types

type Action

type Action struct {
	Type   ActionType
	Label  string
	URL    string // for ActionView / ActionURL / ActionHTTP
	Method string // "GET" | "POST" (ActionHTTP)
	Body   []byte // ActionHTTP
	Data   string // ActionCallback payload (provider-defined)
}

Action is an interactive UI hint (URL button, callback button, Adaptive Card action, ntfy X-Action, etc.).

type ActionType

type ActionType int

ActionType selects how the channel adapter renders an Action.

const (
	ActionView     ActionType = iota // open a URL
	ActionURL                        // synonym for ActionView; provider-specific styling
	ActionCallback                   // round-trip back into Herald via inbound handler
	ActionHTTP                       // fire-and-forget HTTP from the recipient device (ntfy)
	ActionCopy                       // copy text to clipboard (ntfy)
)

type Attachment

type Attachment struct {
	Filename  string
	MIMEType  string
	SizeBytes int64
	Reader    func() (io.ReadCloser, error)
	CID       string // optional inline-image Content-ID (Email)
}

Attachment is a single attached file. Reader is lazy so the adapter streams without buffering the entire payload (spec §11.0).

type Body

type Body struct {
	Plain    string
	Markdown string
	HTML     string
	Native   map[string]any // adapter-specific (Slack blocks, Adaptive Card JSON, ...)
}

Body carries one or more rendered representations; the adapter picks the best match for its Capabilities (Slack Block-Kit JSON, HTML email, Markdown for Telegram, etc.).

type Branding

type Branding struct {
	AppName        string // "Project Herald", "System Herald", ...
	BinaryName     string // "pherald", "sherald", ...
	IconURL        string // for rich embeds
	AccentColorHex string // "#2C7BE5"
	DefaultFooter  string // "Sent by pherald 1.0 · github.com/vasic-digital/Herald"

	// Wave 2 per-flavor identity fields (design §3.5). Populated by
	// DefaultBranding and consumed by the shared commons/cli/ scaffold
	// to render --version / --help and to bind the default HTTP port.
	Flavor      string // single-letter (or short) flavor key: "p", "s", "b", "sc", ...
	Prefix      string // 3-letter prefix per §8.2 (e.g. "PHR", "SHR")
	DisplayName string // human-readable display name (typically == AppName)
	DefaultPort int    // default HTTP listen port (per-flavor, 70XXX range)
	Mission     string // one-line mission statement for --help / about
}

Branding is the per-flavor visual identity (spec §6.3 + §11.0).

func DefaultBranding

func DefaultBranding(flavor string, version string) Branding

DefaultBranding returns a sensible Branding for the named flavor. Each <prefix>herald binary calls this at startup and threads the returned Branding through every OutboundMessage; channel adapters use it to render rich-message accents.

Wave 2 r1 (2026-05-21): also populates the per-flavor identity fields (Flavor / Prefix / DisplayName / DefaultPort / Mission) consumed by the shared commons/cli/ scaffold. Per design §3.5 the DefaultPort for serving flavors lives in the 247xx range; CLI-only flavors set DefaultPort=0 to signal "no HTTP serve mode".

type Capabilities

type Capabilities struct {
	Text             bool
	Markdown         bool // adapter's native markdown flavor
	HTML             bool
	Attachments      bool
	AttachmentMaxMiB int
	Threads          bool             // first-class reply threading
	InteractiveURL   bool             // URL buttons / link actions
	InteractiveCall  bool             // callback handlers (advanced tier)
	DeliveryCeiling  DeliveryEvidence // see §17 / R-05
}

Capabilities advertises what an adapter supports at runtime (spec §11.0). Routers consult Capabilities before dispatching; mismatches are logged and the message is dropped or downgraded per the adapter's choice.

type CategoryPref

type CategoryPref struct {
	Channels []ChannelID
	Muted    bool
}

CategoryPref is the opt-in / channels for one category (spec §7.2).

type Channel

type Channel interface {
	Name() string               // "tgram", "slack", ...
	Capabilities() Capabilities // declarative feature flags
	Send(ctx context.Context, msg OutboundMessage) (Receipt, error)
	Subscribe(ctx context.Context, h InboundHandler) error // long-running, called by `serve`
	HealthCheck(ctx context.Context) error
}

Channel is the interface every channel adapter implements (spec §11.0).

type ChannelID

type ChannelID string

ChannelID is the canonical channel identifier (spec §11.0). It MUST match the scheme used in `channel_addresses.address_url` and in the URL scheme registered by the adapter.

const (
	ChannelTelegram ChannelID = "tgram"
	ChannelMax      ChannelID = "max"
	ChannelSlack    ChannelID = "slack"
	ChannelDiscord  ChannelID = "discord"
	ChannelTeams    ChannelID = "teams"
	ChannelLark     ChannelID = "lark"
	ChannelWhatsApp ChannelID = "whatsapp"
	ChannelViber    ChannelID = "viber"
	ChannelEmail    ChannelID = "mailto"
	ChannelNtfy     ChannelID = "ntfy"
	ChannelGotify   ChannelID = "gotify"
	ChannelWebhook  ChannelID = "webhook"
	ChannelDiary    ChannelID = "diary"
	ChannelNull     ChannelID = "null" // §11.14 sandbox/no-op adapter for tests
)

type Clock

type Clock interface {
	Now() time.Time
	Since(t time.Time) time.Duration
	Sleep(d time.Duration)
	After(d time.Duration) <-chan time.Time
	NewTimer(d time.Duration) Timer
}

Clock is the time-source abstraction (spec §3.5).

var Default Clock = RealClock{}

Default is the process-global Clock. Production main() leaves it as RealClock{}; tests swap it in TestMain. Anyone calling time.Now() outside this file is a bug — the herald-no-direct-time-now lint rule (planned per spec §3.5) flags violations.

type CloudEventEnvelope

type CloudEventEnvelope struct {
	SpecVersion     string
	ID              string // UUIDv7
	Source          string // URI
	Type            string // reverse-DNS
	Time            time.Time
	Subject         string            // tag:/channel:/empty
	DataContentType string            // e.g. "application/json"
	Data            []byte            // opaque payload
	Extensions      map[string]string // heraldtenant, heraldidempotencykey, heraldpriority, ...
}

CloudEventEnvelope is Herald's typed projection of a CloudEvents v1.0 payload (spec §4.1 + §11.0). The boundary type is cloudevents.Event from github.com/cloudevents/sdk-go/v2; this struct is the in-process canonical form after parsing / validation.

type ConversationRef

type ConversationRef struct {
	Channel         ChannelID
	ThreadID        string // Slack thread_ts; Telegram message_thread_id (forum); Email References[0]
	ParentMessageID string // Slack ts; Telegram reply_to_message_id; Email In-Reply-To
	RootMessageID   string // first message in the thread
}

ConversationRef is the per-channel thread identifier (§12 + §11.0).

type DeliveryEvidence

type DeliveryEvidence int

DeliveryEvidence enumerates the strongest reachable signal per channel (spec §11.0 + §17.4.1).

const (
	DeliveryUnknown   DeliveryEvidence = iota
	DeliveryAccepted                   // hop-by-hop ack (SMTP 250)
	DeliveryRouted                     // platform stored & broadcast (Telegram/Slack API ok)
	DeliveryDelivered                  // recipient transport confirmed (Email DSN, WA delivered)
	DeliveryRead                       // recipient read (Email MDN, Telegram Business read marker)
)

func (DeliveryEvidence) String

func (d DeliveryEvidence) String() string

String returns the lowercase enum name (matches the spec wording).

type FakeClock

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

FakeClock is the test implementation. Advance(d) moves the clock forward by d and fires any pending After/Timer channels whose deadlines the advance crosses (spec §3.5).

func NewFakeClock

func NewFakeClock() *FakeClock

NewFakeClock returns a FakeClock anchored at a deterministic instant.

func (*FakeClock) Advance

func (f *FakeClock) Advance(d time.Duration)

Advance moves the fake clock forward by d, firing any timers whose deadlines the advance crosses.

func (*FakeClock) After

func (f *FakeClock) After(d time.Duration) <-chan time.Time

After returns a channel that fires when the fake clock advances by d.

func (*FakeClock) NewTimer

func (f *FakeClock) NewTimer(d time.Duration) Timer

NewTimer returns a fake Timer that fires when Advance crosses its deadline.

func (*FakeClock) Now

func (f *FakeClock) Now() time.Time

Now returns the fake clock's current instant.

func (*FakeClock) Since

func (f *FakeClock) Since(t time.Time) time.Duration

Since returns the elapsed time since t against the fake clock.

func (*FakeClock) Sleep

func (f *FakeClock) Sleep(d time.Duration)

Sleep advances the fake clock by d (does NOT actually sleep).

type IdentityResolver

type IdentityResolver interface {
	// ResolveSender maps a received message's sender to a canonical handle.
	// Unknown senders resolve to their raw "@username" (so first-contact users
	// are still attributable).
	ResolveSender(channel, channelUserID, username string) (handle string)
	// UsernameFor returns the @username for a canonical handle on a target
	// channel. ok=false if the participant has no alias on that channel —
	// you cannot tag someone who is not on that messenger.
	UsernameFor(handle, channel string) (username string, ok bool)
	// OperatorHandle returns the canonical operator handle (sourced from
	// HERALD_<CHANNEL>_OPERATOR_USERNAME).
	OperatorHandle() string
}

IdentityResolver bridges the per-channel runtime world to canonical handles. See §1 contract.

type InboundEvent

type InboundEvent struct {
	EventID     string             // UUIDv7
	CloudEvent  CloudEventEnvelope // §4.1
	Sender      Recipient          // who sent it
	Subscriber  *Subscriber        // resolved via subscriber_aliases, nil if unknown
	Body        Body
	Attachments []Attachment
	Thread      *ConversationRef
	// ThreadContext carries the PRIOR messages of the thread this event belongs
	// to (oldest→newest), populated by the channel adapter when the inbound
	// message is itself inside a thread (Slack thread_ts present; Telegram a
	// reply_to chain). Empty for a fresh/top-level message. The dispatcher feeds
	// this to Claude so a reply is bound by the thread's MEANING and only made
	// when the thread's context warrants one — replies are contributions to a
	// thread, not isolated answers (operator mandate 2026-06-02). It excludes the
	// current inbound message itself (that is in Body).
	ThreadContext []ThreadMessage
	Raw           map[string]any // adapter-specific raw payload (for diary)
}

InboundEvent is a CloudEvent constructed from a subscriber message.

type InboundHandler

type InboundHandler interface {
	Handle(ctx context.Context, ev InboundEvent) error
}

InboundHandler receives messages emitted by the adapter's Subscribe loop. Implementations enqueue events back into the router (§5).

type MemoryResolver

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

MemoryResolver is a concrete in-memory IdentityResolver built from a participant list plus an operator handle. It is the resolver used by tests and by runtime paths that have loaded the participant roster into memory.

func NewMemoryResolver

func NewMemoryResolver(operatorHandle string, participants []Participant) *MemoryResolver

NewMemoryResolver builds a MemoryResolver from the given participant roster and operator handle. The operator handle is messenger-neutral (the operator is a normal Participant whose handle equals the operator env value); pass it from OperatorHandleFromEnv for the primary channel.

func (*MemoryResolver) AddSenderIndex

func (r *MemoryResolver) AddSenderIndex(channel, channelUserID, handle string)

AddSenderIndex registers a (channel, channelUserID) -> canonical handle mapping so a sender identified by chat/user id (not just @username) resolves. channelUserID is the chat/user id (subscriber_aliases.channel_user_id), which is distinct from the @username. Safe to call repeatedly.

func (*MemoryResolver) OperatorHandle

func (r *MemoryResolver) OperatorHandle() string

OperatorHandle implements IdentityResolver.

func (*MemoryResolver) ResolveSender

func (r *MemoryResolver) ResolveSender(channel, channelUserID, username string) string

ResolveSender implements IdentityResolver. It matches by (channel, channelUserID) first, then by (channel, @username). An unknown sender returns the raw "@username" (normalized to a single leading "@") so attribution still works for first-contact users.

func (*MemoryResolver) UsernameFor

func (r *MemoryResolver) UsernameFor(handle, channel string) (string, bool)

UsernameFor implements IdentityResolver. It returns the participant's @username on the target channel; ok=false if the handle is unknown or the participant has no alias on that channel.

type OutboundMessage

type OutboundMessage struct {
	EventID        string      // CloudEvents id (§4)
	IdempotencyKey string      // explicit; falls back to EventID
	TenantID       string      // UUID; matches `subscribers.tenant_id`
	To             []Recipient // resolved from preferences + tag fan-out
	Subject        string      // optional (Email, RSS-like channels)
	Body           Body        // rendered per-channel template output
	Attachments    []Attachment
	Thread         *ConversationRef // optional; per §12
	Priority       Priority         // ntfy-compatible 1..5
	Actions        []Action         // optional interactive buttons
	Branding       Branding         // per-flavor (§6.3)
	Trace          TraceContext     // OTel propagation
}

OutboundMessage is the value passed to Channel.Send (spec §11.0).

type Participant

type Participant struct {
	// Handle is the canonical, messenger-neutral handle stored in items'
	// created_by / assigned_to (e.g. "@milos85vasic" or "Claude").
	Handle string
	// DisplayName is the human-readable name.
	DisplayName string
	// Kind is "human" | "agent" | "service" (spec §7.5).
	Kind string
	// Usernames maps a channel name ("tgram", "slack", …) to the participant's
	// "@username" on that channel. A participant with no entry for a channel
	// cannot be @-tagged there.
	Usernames map[string]string
}

Participant is a logical Subscriber/User: one person/agent with potentially a different username on every messenger. See §1.

type PreferenceSet

type PreferenceSet struct {
	Categories  map[string]CategoryPref // category_id → pref
	Workflows   map[string]WorkflowPref // CloudEvents type → pref
	QuietHours  *QuietHours             // nil ⇒ no quiet hours configured
	ChannelData map[ChannelID]any       // provider-specific routing data
}

PreferenceSet is a typed view of the per-subscriber preferences JSON stored in `subscribers.metadata.preferences` (spec §7.2 + §11.0).

type Priority

type Priority int

Priority maps to ntfy 1..5 and to per-channel native priorities.

const (
	PriorityLow    Priority = 1
	PriorityNormal Priority = 3
	PriorityHigh   Priority = 4
	PriorityUrgent Priority = 5
)

type QuietHours

type QuietHours struct {
	TZ               string   // IANA TZ
	Start            string   // "HH:MM" 24h
	End              string   // "HH:MM" 24h
	ExemptCategories []string // categories that override quiet hours
}

QuietHours encodes the TZ-aware silence window (spec §7.3).

type RealClock

type RealClock struct{}

RealClock wraps the stdlib time package. Used in production.

func (RealClock) After

func (RealClock) After(d time.Duration) <-chan time.Time

After returns a channel that fires once d has elapsed.

func (RealClock) NewTimer

func (RealClock) NewTimer(d time.Duration) Timer

NewTimer wraps time.NewTimer in the Timer interface.

func (RealClock) Now

func (RealClock) Now() time.Time

Now returns the current time (real wall clock).

func (RealClock) Since

func (RealClock) Since(t time.Time) time.Duration

Since returns the elapsed time since t.

func (RealClock) Sleep

func (RealClock) Sleep(d time.Duration)

Sleep blocks for the given duration.

type Receipt

type Receipt struct {
	Evidence      DeliveryEvidence
	ChannelMsgID  string // Slack ts; Telegram message_id; SMTP queue-id; ...
	SentAt        time.Time
	LatencyMillis int64
	Native        map[string]any // adapter-specific raw response (for diary)
}

Receipt is what Channel.Send returns on success — the adapter's evidence of acceptance/routing/delivery so the router can decide whether to retry (spec §11.0).

type Recipient

type Recipient struct {
	Channel       string // "tgram", "slack", "mailto", ...
	ChannelUserID string // chat_id, U0xxx, email address, ...
	DisplayName   string // best-effort, for templating
}

Recipient is a resolved (channel, channel_user_id) pair (spec §11.0).

type Subscriber

type Subscriber struct {
	ID          uuid.UUID
	TenantID    uuid.UUID
	Handle      string // empty if not operator-mapped
	DisplayName string
	Locale      string // BCP-47, e.g. "en-US", "sr-Latn-RS"
	Timezone    string // IANA, e.g. "Europe/Belgrade"
	Roles       []string
	Metadata    map[string]any
	Aliases     []SubscriberAlias
	Preferences *PreferenceSet
}

Subscriber is the in-memory projection of `subscribers` + its linked `subscriber_aliases` (spec §7.1 + §11.0).

type SubscriberAlias

type SubscriberAlias struct {
	Channel       string
	ChannelUserID string
	VerifiedAt    *time.Time
	LastSeenAt    *time.Time
}

SubscriberAlias is one row from `subscriber_aliases`.

type ThreadMessage

type ThreadMessage struct {
	SenderHandle string    // resolved/raw sender handle; "Claude" for this bot's own prior messages
	SenderIsBot  bool      // true when authored by a bot (incl. this Herald bot)
	Text         string    // the message text
	Timestamp    time.Time // when it was sent (zero if the adapter cannot determine it)
}

ThreadMessage is one prior message in a thread, supplying the conversational context that binds a reply to the thread's meaning (operator mandate 2026-06-02). Adapters populate it from the channel's thread-history API (Slack conversations.replies; Telegram the reply_to chain).

type Timer

type Timer interface {
	C() <-chan time.Time
	Stop() bool
}

Timer abstracts over time.Timer for FakeClock support.

type TraceContext

type TraceContext struct {
	TraceID    string // 32-hex chars (W3C Trace Context)
	SpanID     string // 16-hex chars (the parent span at handoff)
	TraceFlags byte   // sampling flags (W3C)
	TraceState string // vendor-specific (W3C tracestate header)
	Baggage    string // W3C baggage header value
}

TraceContext carries OpenTelemetry trace propagation across Herald boundaries (spec §11.0 + §17.3).

type WorkflowPref

type WorkflowPref struct {
	Channels []ChannelID // may be empty (= use Category default)
	Muted    bool
}

WorkflowPref is the opt-in / channels for one CloudEvents type.

Directories

Path Synopsis
Wave 4b Task 3 — Accept-header content negotiation primitive.
Wave 4b Task 3 — Accept-header content negotiation primitive.
Package gitops provides the REAL git/repo primitives the Herald §43 flavor command bodies build on.
Package gitops provides the REAL git/repo primitives the Herald §43 flavor command bodies build on.
Package stresschaos is Herald's shared, dependency-light scaffold for the §11.4.85 stress + chaos test suite (GAP-3, plan 2026-05-27-stress-chaos-suite).
Package stresschaos is Herald's shared, dependency-light scaffold for the §11.4.85 stress + chaos test suite (GAP-3, plan 2026-05-27-stress-chaos-suite).

Jump to

Keyboard shortcuts

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