integration

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 36 Imported by: 0

Documentation

Overview

Package integration holds the use cases that let another system reach into this one, and this one reach out: today the webhook subscriptions, later the trigger polling and the jumble's inbound routes.

The calendar feeds are deliberately not here. They live beside the saved views, because minting one is a read of a view and the rule that decides who may see which view is there - a second copy of a visibility rule is how two answers to one security question get into a codebase.

Index

Constants

View Source
const (
	ReadOwnAiPositionName     = "ReadOwnAiPosition"
	AcknowledgeAiPositionName = "AcknowledgeAiPosition"

	OwnAiPositionReadAction audit.Action = "ai.own_position_read"
	AiNoticeAckAction       audit.Action = "ai.notice_acknowledged"
)
View Source
const (
	SetAiPersonOptOutName = "SetAiPersonOptOut"

	// AiPersonOptOutSetAction is the workspace's position on people keeping their own content out
	// of AI, with the legal basis it named - never whom to ask, which is free text (rule 10).
	AiPersonOptOutSetAction audit.Action = "ai.person_opt_out_set"
)
View Source
const (
	ConfigureAiProviderName = "ConfigureAiProvider"
	ReadAiProviderName      = "ReadAiProvider"
	RemoveAiProviderName    = "RemoveAiProvider"
)
View Source
const (
	AiProviderConfiguredAction audit.Action = "ai.provider_configured"
	AiProviderRemovedAction    audit.Action = "ai.provider_removed"
	AiProviderReadAction       audit.Action = "ai.provider_read"
)

The audit codes of the AI surface. ADR-0018 decision 7 asks for the use to be audited with the provider, the region, the model and the purpose; the first three are the entry's changes and the fourth is the action itself, which is what an action name is for.

View Source
const (
	RefusalUnavailable = "ai.unavailable"
	RefusalNotAllowed  = "ai.source_not_allowed"
)

Refusals the resolver answers with.

View Source
const (
	ReadAiSourceName   = "ReadAiSource"
	ChooseAiSourceName = "ChooseAiSource"
)
View Source
const (
	AiSourceChosenAction audit.Action = "ai.source_chosen"
	AiSourceReadAction   audit.Action = "ai.source_read"
)
View Source
const (
	ListWebhookDeliveriesName = "ListWebhookDeliveries"
	ReplayWebhookDeliveryName = "ReplayWebhookDelivery"
	RotateWebhookSecretName   = "RotateWebhookSecret" //nolint:gosec // G101: a use case name, not a credential.

	// DefaultDeliveryPage and MaxDeliveryPage are api-guidelines.md §4's numbers. A delivery log
	// is the one webhook read that is paged: a busy integration produces a row per event, where a
	// workspace has a handful of subscriptions.
	DefaultDeliveryPage = 50
	MaxDeliveryPage     = 200

	// WebhookReplayedAction is the audit code for a delivery sent again by hand. An act rather
	// than machinery: somebody looked at a dead letter and decided the target is ready for it.
	WebhookReplayedAction audit.Action = "webhooks.delivery_replayed"
)
View Source
const (
	SendWebhookName = "SendWebhook"

	// WebhookSentAction is a delivery somebody asked for by name - through the API, or through a
	// rule's SEND_WEBHOOK action running as its account. An act rather than machinery, which is
	// why it is audited where the fan-out's ordinary deliveries are not: the fan-out delivers what
	// a subscription asked to receive, and this delivers what a caller decided to send.
	WebhookSentAction audit.Action = "webhooks.delivery_sent"
)
View Source
const (
	CreateWebhookSubscriptionName = "CreateWebhookSubscription"
	GetWebhookSubscriptionName    = "GetWebhookSubscription"
	ListWebhookSubscriptionsName  = "ListWebhookSubscriptions"
	UpdateWebhookSubscriptionName = "UpdateWebhookSubscription"
	DeleteWebhookSubscriptionName = "DeleteWebhookSubscription"

	// The audit codes. A subscription is a standing instruction to send this workspace's events
	// to an address outside it, so every change to one is an act a review looks for (audit.md §4).
	WebhookCreatedAction  audit.Action = "webhooks.subscription_created"
	WebhookUpdatedAction  audit.Action = "webhooks.subscription_updated"
	WebhookDeletedAction  audit.Action = "webhooks.subscription_deleted"
	WebhookRotatedAction  audit.Action = "webhooks.secret_rotated"
	WebhookDisabledAction audit.Action = "webhooks.subscription_disabled"
	// WebhookReadAction is what a listing performs.
	WebhookReadAction audit.Action = "webhooks.subscription_read"

	// SecretBytes is the entropy of a signing secret. The same 32 bytes a personal access token
	// draws: this is an HMAC key, and anything shorter would be the weak half of SHA-256.
	SecretBytes = 32
)
View Source
const BusFanOutName = "bus"

BusFanOutName is what the deduplication and the logs call this consumer. Stable across versions: renaming it would make every event it has already seen look new (core/port/eventbus).

View Source
const DefaultCollapseGrace = 30 * time.Second

DefaultCollapseGrace is a few dispatch rounds: long enough for a push of five hundred mutations to land in the outbox and be dispatched, short enough that a subscriber still sees an offline batch arrive as one thing rather than as a delay.

View Source
const FanOutName = "webhooks"

FanOutName is what the deduplication and the logs call this consumer. Stable across versions: renaming it would make every event it has already seen look new (core/port/eventbus).

View Source
const PollTriggerEventsName = "PollTriggerEvents"

PollTriggerEventsName is the use case behind GET /integrations/triggers/{eventType}.

View Source
const TriggerPolledAction audit.Action = "triggers.polled"

TriggerPolledAction is what a poll performs. Info severity and not required, for the reason a webhook listing is not: an entry per poll per trigger per minute would bury the entries a review is looking for under a feed nobody reads (audit.md §4).

Variables

This section is empty.

Functions

func AiKeyPurpose

func AiKeyPurpose(tenantID shared.ID) crypto.Purpose

AiKeyPurpose binds the sealed key to the workspace it belongs to, so a ciphertext lifted into another workspace's row does not open (the identity provider's client secret).

func AiSourceOutput

func AiSourceOutput(state AiState) usecase.Output

AiSourceOutput is the read shape. An offered model is answered by its name, its processor and its jurisdiction only - never its address, its models or its key (UC-AI-05 check 3) - and only where the levels above the workspace allow offered models at all.

func OfferedAiKeyPurpose

func OfferedAiKeyPurpose(providerID shared.ID) crypto.Purpose

OfferedAiKeyPurpose binds an offered model's sealed key to that model, so a ciphertext lifted into another row - a workspace's own, or another offered one - does not open.

func OwnAiPositionOutput

func OwnAiPositionOutput(own OwnAiPosition) usecase.Output

OwnAiPositionOutput is the read shape.

func SecretPurpose

func SecretPurpose(id shared.ID) crypto.Purpose

SecretPurpose binds a sealed secret to the subscription it belongs to, so that a ciphertext lifted out of one row and dropped into another no longer opens (core/port/crypto).

Types

type AcknowledgeAiPosition

type AcknowledgeAiPosition struct{ Reader AiObjectionReader }

AcknowledgeAiPosition records that the person read the notice: that they were told is part of what the workspace owes them (data-protection.md §4.1), so the trail keeps it. Acknowledging twice is acknowledging once.

func (AcknowledgeAiPosition) Descriptor

func (h AcknowledgeAiPosition) Descriptor() usecase.Descriptor

func (AcknowledgeAiPosition) Execute

Execute records it and answers the view afterwards.

type AiBudget

type AiBudget interface {
	// AiTokens reports whether the source's daily budget still has room.
	AiTokens(ctx context.Context, tenant string, own bool, now time.Time) (bool, error)
	// MeterAi records what a call to the source cost, after the provider answered.
	MeterAi(ctx context.Context, own bool, at time.Time, tokens int64) error
	// AiRenewal is when the day that `now` falls on ends for the workspace: the next midnight in
	// its time zone, when a spent budget has room again.
	AiRenewal(ctx context.Context, now time.Time) (time.Time, error)
}

AiBudget is the slice of the quota guard this needs (multi-tenancy.md §4): one budget per source, the offered model's and the workspace's own (ADR-0072 §4).

type AiObjectionReader

type AiObjectionReader struct {
	Rules      *AiRules
	People     identityrepo.People
	Consents   privacyrepo.Consents
	Notices    privacyrepo.AiNotices
	Audit      audit.Sink
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
}

AiObjectionReader is what the person's two use cases share.

type AiProviderResealer

type AiProviderResealer struct {
	Providers repository.AiProviders
	Encryptor crypto.Encryptor
}

AiProviderResealer moves a workspace's API key under the current master key (ADR-0045).

It arrives with the store rather than after it, which is the lesson of the re-sealing task: a store that seals and cannot be re-sealed is invisible until a rotation stalls on a key nobody can account for, and by then the census is the only thing that knows.

func (AiProviderResealer) Reseal

func (r AiProviderResealer) Reseal(
	ctx context.Context, tenantID shared.ID,
) (sealing.Outcome, error)

func (AiProviderResealer) Store

func (AiProviderResealer) Store() string

type AiProviderResolver

type AiProviderResolver interface {
	For(ctx context.Context, actor appshared.ActorContext) (aiprovider.Provider, error)
}

AiProviderResolver is what answers which provider a workspace uses. The adapter satisfies it; this file wraps it.

type AiProviderWriter

type AiProviderWriter struct {
	Providers  repository.AiProviders
	Authorizer Authorizer
	// Encryptor seals the API key. The application never stores a plaintext and the repository
	// never holds a key: the sealing happens here, between the two.
	Encryptor  crypto.Encryptor
	Audit      audit.Sink
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
	// ThirdCountryConfirmed is the installation operator's confirmation that a provider outside
	// the EEA may be used (HUBTASK_AI_ALLOW_THIRD_COUNTRY_TRANSFER, data-protection.md §6,
	// ADR-0018 decision 7).
	//
	// The installation's and not the workspace's, deliberately. It is the operator who signs the
	// processing agreement and who owes the transfer impact assessment, and a workspace
	// administrator cannot take either of those on for them - so the friction sits where the
	// responsibility does. False by default, which is what makes it friction rather than a
	// formality.
	ThirdCountryConfirmed bool
	// Rules is the installation's rule about AI sources (ADR-0072 §2). Nil is an installation
	// without one, where the own model is always allowed.
	Rules *AiRules
	// Reindex seeds re-embedding when the own embedding model changes (ADR-0072 §6). Nil seeds
	// nothing.
	Reindex EmbeddingReseeder
}

AiProviderWriter is what the three configuration use cases share.

type AiRules

type AiRules struct {
	Instance   identityrepo.InstanceSettings
	Workspaces identityrepo.Workspaces
	Offered    repository.OfferedAiProviders
	Choices    repository.AiChoices
	// Own is the workspace's own configuration, read by Select where the choice is the own model.
	Own repository.AiProviders
	// Consents counts who keeps their content out, for the number the screen shows before the
	// workspace changes its position (P-04). Nil counts nobody.
	Consents privacyrepo.Consents
	// Plans reads the workspace's plan's AI layer, between the installation's and the workspace's.
	// Its own read beside the workspace's: the AI layer cannot ride on the identity workspace, which
	// this package's domain depends on. Nil reads no plan.
	Plans repository.PlanAi
}

AiRules reads what decides a workspace's AI: the installation's level, the workspace's own narrowing, the models offered, and the workspace's choice. Read inside the caller's transaction, in the workspace's scope - the instance level has no row level security and the offered rows are admitted by the read policy - so one read answers the whole state at one moment.

func (AiRules) Resolve

func (r AiRules) Resolve(ctx context.Context, tenantID shared.ID) (AiState, error)

Resolve reads the state inside the caller's transaction.

func (AiRules) Select

func (r AiRules) Select(ctx context.Context, tenantID shared.ID) (AiSelection, error)

Select decides, inside the caller's read-only transaction in the workspace's scope.

The own configuration is read only where the choice is the own model, and the offered key only where the choice is an offered one: a workspace's key is not opened on the way to deciding not to use it, and neither is the installation's. Nothing here writes - narrowing stops calls and deletes nothing (ADR-0072 §5).

type AiSelection

type AiSelection = portai.Selection

AiSelection is the answer to "which configuration may this workspace's AI call, if any" - the decision the resolver acts on, made here because which source the rule allows is the application's question and not an adapter's (ADR-0072 §2, §5). Refusal is `ai.unavailable` for nothing configured or consented, `ai.source_not_allowed` where the rule in force no longer allows what the workspace chose.

type AiSourceWriter

type AiSourceWriter struct {
	Rules      AiRules
	Authorizer Authorizer
	Audit      audit.Sink
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
	// Reindex seeds the re-embedding a change of the model behind search needs. Nil where the
	// installation has no semantic search to rebuild.
	Reindex EmbeddingReseeder
	// Spaces answers whether search by meaning is being rebuilt. Nil answers never.
	Spaces workrepo.EmbeddingSpaces
	// Embeddings forgets the vectors of the people whose objection takes effect again when the
	// workspace goes back to letting each person keep their content out. Nil leaves it to the pass.
	Embeddings EmbeddingForgetter
}

AiSourceWriter is what the two use cases share.

type AiState

type AiState struct {
	Rule    domain.AiRule
	Choice  domain.AiChoice
	Offered []domain.AiProvider
	// Narrowing is the workspace's own narrowing, empty where it narrowed nothing.
	Narrowing domain.AiSources
	// SearchRebuilding is true while vectors of another model than the one now in use are still
	// stored: search by meaning is being rebuilt, and search by words works meanwhile.
	SearchRebuilding bool
	// OptOutContact and OptOutSince belong to the workspace's NOT_OFFERED: whom to ask, and
	// since when - empty and zero under OFFERED.
	OptOutContact string
	OptOutSince   time.Time
	// KeepingOut is how many people's newest ai_processing record is a withdrawal: a number, never
	// who (UC-PRV-05 check 12).
	KeepingOut int
}

AiState is a workspace's AI at one moment.

func (AiState) ChosenOffered

func (s AiState) ChosenOffered() (domain.AiProvider, bool)

ChosenOffered answers the offered model the choice names, when it still exists.

type Authorizer

type Authorizer interface {
	Authorize(ctx context.Context, actor appshared.ActorContext, request access.Request) error
	// Permits is the same question without an audit entry, for the listing.
	Permits(ctx context.Context, actor appshared.ActorContext, request access.Request) (bool, error)
}

Authorizer is the application's own decision point. Declared here rather than imported so that what this package needs of it is visible in one place (ADR-0005).

type Budgeted

type Budgeted struct {
	Providers  SourcedResolver
	Budget     AiBudget
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
}

Budgeted is the provider resolver with `ai-first.md` §2's per-tenant budget counter around it.

**In the application layer rather than in the adapter**, which ADR-0049 decision 1 asked for and this is the first thing that needed it: the budget is a quota, a quota is read from the workspace's settings inside a transaction, and an outbound adapter that opened one would be an adapter driving the application layer (project-structure.md §2).

What it produces when the budget is spent is a provider that **refuses like an absent one**: zero capabilities, and `ErrUnavailable` from both calls. That is the whole of the degradation, and it needed no new handling anywhere, because every caller already deals with a provider that cannot do something - the embedding pass finds nothing to do, the search runs lexically, and a suggestion is not made. A budget with a refusal shape of its own would have been a second way for each of them to degrade (ADR-0049 decision: one refusal, `ai.unavailable`). It carries the reason and the moment the budget renews as parameters, because the person reading it can do something an absent provider does not let them: wait until then.

func (Budgeted) For

For answers the workspace's provider, or one that refuses because the budget is spent.

type BusFanOut

type BusFanOut struct {
	Jobs  Queue
	Clock clock.Clock
}

BusFanOut is the outbox's consumer for the optional message bus: one event in, one publish job out (ADR-0007's "and optionally NATS JetStream").

It knows nothing about NATS, and that is the point of it being here rather than in the adapter. What it does is the one thing the port allows a subscriber to do with work that has a network on the other side: ask the queue for it. Which bus, how it authenticates and what happens when it is gone are the adapter's, behind the job.

One job per event and not per subject. There is one bus, so splitting further isolates no failure; what the job buys is the retry ladder and the dead letter, which is the same thing the webhook delivery buys and for the same reason.

Why a replay reaches no bus

It implements Subscriber and deliberately not TakesReplays. An external bus receiving a restore's events would fire whatever its consumers do - rules, mails, tickets in somebody else's system - and that is the first prohibition of backup-restore.md §8.4. The dispatcher enforces it rather than trusting this consumer to remember.

Why it is registered only when the bus is configured

An installation with no bus registers no BusFanOut, so no job is written and no row is paid for. Registering it always and letting the handler decide would put a job per event in the queue of every installation that does not use this at all, which is the opposite of what "optional" has to mean (ADR-0042).

func (BusFanOut) Deliver

func (b BusFanOut) Deliver(ctx context.Context, envelope event.Envelope) error

Deliver queues the publish.

In the dispatcher's transaction, so the job and the mark that this event was consumed commit together: a job that committed without the mark would publish an event that is delivered again on the next round, and a mark without the job would be an event nobody ever put on the bus.

func (BusFanOut) Name

func (BusFanOut) Name() string

Name identifies the consumer in the deduplication.

func (BusFanOut) Wants

func (BusFanOut) Wants(event.Type) bool

Wants reports whether the bus is interested, and it always is.

A bus is a transport rather than a subscriber: what is interesting is the consumer's business, expressed as its own subscription on the stream. Filtering here would be this system deciding what somebody else's consumers may see, with a redeploy needed to change its mind.

type ChooseAiSource

type ChooseAiSource struct{ Writer AiSourceWriter }

ChooseAiSource sets which source answers the workspace, and its own narrowing.

func (ChooseAiSource) Descriptor

func (h ChooseAiSource) Descriptor() usecase.Descriptor

func (ChooseAiSource) Execute

Execute narrows first, then checks the choice against the rule that results: a workspace that narrows itself to its own model and chooses the offered one in the same request is refused, not half-written.

type ChooseAiSourceCommand

type ChooseAiSourceCommand struct {
	Source            domain.AiSourceKind
	OfferedProviderID shared.ID
	Sources           string
}

ChooseAiSourceCommand is the input, typed. Sources is the narrowing: empty keeps it, INHERIT clears it, a value narrows to it.

type ConfigureAiProvider

type ConfigureAiProvider struct{ Writer AiProviderWriter }

ConfigureAiProvider points a workspace at its AI provider, or switches it off.

func (ConfigureAiProvider) Descriptor

func (h ConfigureAiProvider) Descriptor() usecase.Descriptor

func (ConfigureAiProvider) Execute

Execute validates, checks the transfer, seals the key and stores the lot.

The order matters and is the identity provider's: everything that can be refused is refused before anything is sealed or written, so a rejected configuration leaves no envelope behind and no half-written row.

type ConfigureAiProviderCommand

type ConfigureAiProviderCommand struct {
	Kind            domain.AiProviderKind
	BaseURL         string
	CompletionModel string
	EmbeddingModel  string
	// APIKey is present only when the caller sent one. Absent keeps whatever is stored; present
	// and empty clears it, which is what a local provider that needs none wants.
	APIKey            secret.Secret
	APIKeyPresent     bool
	Jurisdiction      domain.AiJurisdiction
	ProcessingAllowed bool
}

ConfigureAiProviderCommand is the input, typed.

type CreateWebhookSubscription

type CreateWebhookSubscription struct {
	Writer Writer
	Quota  TargetQuota
}

CreateWebhookSubscription subscribes an external system to the event stream.

func (CreateWebhookSubscription) Descriptor

Descriptor is the catalogue entry.

func (CreateWebhookSubscription) Execute

Execute creates the subscription and answers its signing secret, once.

Whether the target may be dialled at all is not asked here, and that is deliberate: the guard that would refuse a private range is an adapter's, because only the adapter knows what the operator released (T-07). What this asks is whether the caller may subscribe anything.

type CreateWebhookSubscriptionCommand

type CreateWebhookSubscriptionCommand struct {
	TargetURL  string
	EventTypes []string
	Filter     string
	// Scope is where it listens; nil is the whole workspace.
	Scope *domain.WebhookScope
}

CreateWebhookSubscriptionCommand is the input, typed.

type Cursors

type Cursors interface {
	Encode(position outbox.Position) string
	Decode(cursor string) (outbox.Position, error)
}

Cursors turns a position in the outbox into an opaque string and back.

An interface here and a keyed HMAC in infrastructure/security, for the change stream's reason: the installation secret has no business above the adapters (security.md §8), and the application never looks inside the string - which is what "opaque" means from this side, and what lets the encoding change without the contract changing with it.

type DeleteWebhookSubscription

type DeleteWebhookSubscription struct{ Writer Writer }

DeleteWebhookSubscription unsubscribes.

func (DeleteWebhookSubscription) Descriptor

Descriptor is the catalogue entry.

func (DeleteWebhookSubscription) Execute

Execute removes the subscription and, by cascade, its deliveries: a delivery log for a subscription nobody can reach any more is a record of attempts against an address the workspace no longer knows.

type DeliveryListing

type DeliveryListing struct {
	SubscriptionID shared.ID
	Status         string
	Cursor         shared.ID
	PageSize       int
}

DeliveryListing is what a listing asks for.

type DisableNotifier

type DisableNotifier interface {
	WebhookDisabled(ctx context.Context, tenantID, recipient, subscriptionID shared.ID) error
}

DisableNotifier is how the owner of a subscription is told it stopped.

A notification category rather than a new channel: "auto-disable after sustained unreachability, plus a notification to the owner" is one sentence in automation.md §3.1, and the second half of it is a thing this system already knows how to do.

type EmbeddingForgetter

type EmbeddingForgetter interface {
	Forget(ctx context.Context, creators []shared.ID) (int, error)
}

EmbeddingForgetter removes the vectors of what some people created.

type EmbeddingJobs

type EmbeddingJobs interface {
	Enqueue(ctx context.Context, request queue.Request) (shared.ID, error)
}

EmbeddingJobs is the queue's one method this needs.

type EmbeddingReseeder

type EmbeddingReseeder interface {
	Reseed(ctx context.Context, tenantID shared.ID) error
}

EmbeddingReseeder queues a re-embedding pass in the transaction's workspace.

type EmbeddingSeeder

type EmbeddingSeeder struct {
	Jobs  EmbeddingJobs
	Delay time.Duration
	Clock clock.Clock
}

EmbeddingSeeder seeds the embedding pass in the transaction's workspace: the write that changed the model behind search seeds it, and nothing visits workspaces (ADR-0072 §6). The same job and the same deduplication key the entries' own writes use, so a burst of changes is one pass.

func (EmbeddingSeeder) Reseed

func (s EmbeddingSeeder) Reseed(ctx context.Context, tenantID shared.ID) error

Reseed queues the pass.

type EventRendering

type EventRendering interface {
	Render(envelope event.Envelope) map[string]any
}

EventRendering turns an envelope into the document a subscriber receives.

The CloudEvents mapping is an adapter's (ADR-0007), and this layer may not reach into one. It is held behind an interface so that the pull half renders through the very same function the push half delivers - one schema and two transports, rather than two renderings that agree until one of them is changed.

type EventSource

type EventSource interface {
	FindEvent(ctx context.Context, id shared.ID) (event.Envelope, error)
}

EventSource reads the event a send names, as it was written.

Narrow rather than the outbox port, for the reason every slice here is: a use case that held the outbox could append to it.

type Exclusion

type Exclusion struct {
	Sources []KeptOutSource
}

Exclusion reads every source into one KeptOut - what every path that hands content to a model asks, in the transaction it reads the material in.

func (Exclusion) Read

func (e Exclusion) Read(ctx context.Context, tenantID shared.ID) (domain.KeptOut, error)

Read asks every source. A failing source fails the read: the content of somebody who objected must not reach a model because a query could not say that they did.

type FanOut

type FanOut struct {
	Subscriptions repository.WebhookSubscriptions
	Deliveries    repository.WebhookDeliveries
	Jobs          Queue
	Clock         clock.Clock
	IDs           clock.IDGenerator
	// Reach keeps an event of a private hub from every subscription whose creator does not reach
	// the hub (D5, ADR-0073 §1): an administrator's subscription would otherwise read a family
	// member's diary titles. Nil only in a build that has no private hubs to keep.
	Reach PrivateReach
	// Containers places an event in its hub for a subscription scoped to one (condition.Locate).
	// Without it a hub's scope matches only the hub's own events, never its collections'.
	Containers condition.Containers
	// Conditions evaluates a subscription's condition. Without it an event for a subscription with
	// one is held back, as one that cannot be evaluated is: delivering it would send what the
	// subscriber narrowed away.
	Conditions expression.Compiler
	// CollapseGrace is how long a pushed event's delivery waits before its first attempt, so
	// that the events of one push, dispatched across a few rounds, fold onto one delivery
	// rather than racing the worker to the target (offline-sync.md §8). Zero means the default.
	CollapseGrace time.Duration
	// Entitlements is what the workspace's plan leaves out. Nil includes every feature.
	Entitlements adminrepo.Entitlements
}

FanOut is the outbox's webhook consumer: one event in, a delivery job per interested subscription out.

It does not deliver. It records that a delivery is owed and asks the queue for it, which is what keeps one unreachable target from holding up every other subscriber's events - the retry ladder belongs to one job, and the dispatcher's own round is over as soon as the rows are written.

Why a replay reaches no subscriber

It implements Subscriber and deliberately not TakesReplays. A restore would otherwise report last month's states to every external system that has ever subscribed, which is the first prohibition of backup-restore.md §8.4 - and the dispatcher enforces it for us rather than trusting this consumer to remember.

func (FanOut) Deliver

func (f FanOut) Deliver(ctx context.Context, envelope event.Envelope) error

Deliver records what is owed and queues it.

Everything happens in the dispatcher's transaction: the delivery rows, the jobs and the mark that this event was consumed commit together or not at all. A job that committed without its row would be a delivery nothing could record the outcome of.

One delivery per push

The events of one push that name the same subscription, subject and type owe one delivery for the push rather than one per event, with the last event's payload (offline-sync.md §8): a device that synchronises four hundred offline changes to forty entries does not send a subscriber four hundred webhooks. The pending delivery of the push is repointed at the newer event; one already attempted stands for what it sent, and a new one is recorded. The outbox stays complete and a consumer on the bus sees every event - the collapse is the webhook's, and an event with no push in its cause collapses with nothing.

func (FanOut) Name

func (FanOut) Name() string

Name identifies the consumer in the deduplication.

func (FanOut) Wants

func (FanOut) Wants(event.Type) bool

Wants reports whether any subscription could be interested.

It answers true for every type this build emits rather than reading the table, and that is deliberate: Wants is asked before the transaction the delivery would be written in, so a lookup here would be a query per event per consumer that the fan-out then repeats. The real filter is the query in Deliver, which the database answers with an index.

type GetWebhookSubscription

type GetWebhookSubscription struct{ Writer Writer }

GetWebhookSubscription answers one subscription.

func (GetWebhookSubscription) Descriptor

func (h GetWebhookSubscription) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry.

func (GetWebhookSubscription) Execute

type KeptOutSource

type KeptOutSource interface {
	KeptOut(ctx context.Context, tenantID shared.ID) ([]domain.KeptPerson, error)
}

KeptOutSource answers the people one reason keeps out of AI, inside the caller's transaction. The objection is one source; a restriction (Art. 18) is another, added beside it rather than as a second mechanism.

type ListWebhookDeliveries

type ListWebhookDeliveries struct{ Writer Writer }

ListWebhookDeliveries answers what was delivered and what became of it.

func (ListWebhookDeliveries) Descriptor

func (h ListWebhookDeliveries) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry.

func (ListWebhookDeliveries) Execute

Execute reads the log.

The subscription is read first and its absence answered as absence, so that a delivery listing for a subscription of another tenant says the same thing as one for a subscription that never existed (T-04).

type ListWebhookSubscriptions

type ListWebhookSubscriptions struct{ Writer Writer }

ListWebhookSubscriptions answers the workspace's subscriptions.

func (ListWebhookSubscriptions) Descriptor

Descriptor is the catalogue entry.

func (ListWebhookSubscriptions) Execute

type MintedSubscription

type MintedSubscription struct {
	Subscription domain.WebhookSubscription
	Secret       secret.Secret
}

MintedSubscription is what a creation or a rotation answers: the subscription as it will be listed, and the secret that will never be readable again.

The plaintext travels in secret.Secret rather than as a string, so that a struct printed whole - the shape that actually leaks - masks it (T-18, rule 10).

type ObjectionSource

type ObjectionSource struct {
	Rules    *AiRules
	People   identityrepo.People
	Consents privacyrepo.Consents
}

ObjectionSource keeps out the people who objected, where their objection takes effect: the workspace offers keeping one's content out, and more than one person works here - alone, the workspace's decision is the person's own, and there is no switch to undo it (UC-PRV-05 check 8).

func (ObjectionSource) KeptOut

func (s ObjectionSource) KeptOut(ctx context.Context, tenantID shared.ID) ([]domain.KeptPerson, error)

KeptOut answers the objecting people.

type OfferedAiProviderResealer

type OfferedAiProviderResealer struct {
	Providers repository.OfferedAiSealings
	Encryptor crypto.Encryptor
}

OfferedAiProviderResealer moves the keys of the models the installation offers under the current master key (UC-AI-06 check 1, UC-INS-16 check 2). It runs in the installation's own round, under the scope with no tenant - the only one whose writes the installation_write policy admits - and binds each key to its own row, the purpose it was sealed under.

func (OfferedAiProviderResealer) Reseal

func (OfferedAiProviderResealer) Store

type Outcomes

type Outcomes struct {
	Subscriptions repository.WebhookSubscriptions
	Audit         audit.Sink
	Notifier      DisableNotifier
	Clock         clock.Clock
}

Outcomes is the half of the webhook use cases the delivery path needs: it reports what happened and lets the aggregate decide what that means.

A type of its own rather than methods on Writer, because this is called by a job handler rather than by a request, and the two have different rights: a handler has no actor and asks no permission - it is the system carrying out an instruction somebody already gave.

func (Outcomes) Delivered

func (o Outcomes) Delivered(ctx context.Context, subscriptionID shared.ID) error

Delivered ends the failure run.

func (Outcomes) Failed

func (o Outcomes) Failed(
	ctx context.Context, subscriptionID shared.ID, code string, terminal bool,
) error

Failed records a failed delivery and, when the run is long enough, disables the subscription and tells its owner. The parameters are plain rather than a struct, so that the delivery adapter can declare the two methods it needs as an interface of its own - an outbound adapter may not import a use case (project-structure.md §2), and this is the shape that lets the composition root put the two together.

code is a message code of ours, never the target's response body (rule 10). terminal says the attempts have stopped - the delivery went to the dead letter - which is what the failure run counts: an attempt that will be retried is not yet a failed delivery.

type OwnAiPosition

type OwnAiPosition struct {
	// Shown is whether the person has anything here at all: AI answers this workspace and more
	// than one person works in it.
	Shown        bool
	PersonOptOut domain.AiPersonOptOut
	Basis        domain.AiOptOutBasis
	Contact      string
	Since        time.Time
	// KeepingOut is the person's own choice - their newest ai_processing record is a withdrawal -
	// and KeepingOutSince when they made it. Whether it takes effect is InEffect.
	KeepingOut      bool
	KeepingOutSince time.Time
	InEffect        bool
	// NoticePending is the one-time notice: their objection stopped taking effect because the
	// workspace made AI part of everybody's work, and they have not acknowledged that yet.
	NoticePending bool
}

OwnAiPosition is one person's view.

type Page

type Page struct {
	// Events are the rendered documents, oldest first.
	Events []map[string]any
	// Cursor is the position after this page. It advances even when the page is empty, so that a
	// poller crawling a quiet stretch does not re-read it.
	Cursor outbox.Position
	// More says the page came back full, so the caller comes straight back rather than waiting for
	// its next scheduled poll.
	More bool
}

Page is one poll's answer: the events, and where the walk now stands.

type PollTriggerEvents

type PollTriggerEvents struct {
	Events     outbox.Pollable
	Policies   lifecyclerepo.Policies
	Cursors    Cursors
	Rendering  EventRendering
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
	// Lag is how far behind the present a page may reach. See environment.QueueConfig.
	Lag time.Duration
	// Reach leaves out the events of a private hub the caller does not reach (D5, ADR-0073 §1).
	// The cursor still moves past them, so a page may come back short (api-guidelines.md §4).
	Reach PrivateReach
}

PollTriggerEvents answers the tenant's events of one type, oldest first, from a cursor.

The pull half of the stream the subscriptions push (automation.md §3.2), for a platform with no address a webhook could reach. The same events, the same document, the same identifier to deduplicate on.

Three boundaries hold it together, and each is drawn once, here:

The window is the outbox's retention period, which is the tenant's own (ADR-0020). A cursor older than it is refused rather than answered from the beginning of what is left: a poller that missed more than the window has to be told that it missed, or it goes on reporting a consistency it does not have.

The horizon is how the cursor stays gapless, and Poll's own documentation says why.

The authorisation is two scopes and both are checked. `automation:manage` is the endpoint's, the same one a webhook subscription needs - reading this workspace's whole event stream and pointing it at an address outside the workspace are the same power, and the pull half must not be the cheaper way to it. The event type's own read scope is the second: a token scoped to read items polls item events and is refused what it is not scoped for, which falls out of the scopes the catalogue already declares rather than out of a polling permission invented beside them.

func (PollTriggerEvents) Descriptor

func (h PollTriggerEvents) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry.

func (PollTriggerEvents) Execute

Execute answers one page.

type PollTriggerEventsCommand

type PollTriggerEventsCommand struct {
	// EventType is the full type, `de.hubtask.work.item.created.v1`.
	EventType string
	// Cursor is where the last poll stopped. Empty starts at the oldest event still inside the
	// window.
	Cursor string
	// Limit is the page size the caller asked for. Zero takes the default.
	Limit int
}

PollTriggerEventsCommand is one poll.

type PrivateReach

type PrivateReach interface {
	Hidden(ctx context.Context, actor appshared.ActorContext, path []identity.Scope) (bool, error)
}

PrivateReach is the authoriser's question about a private hub, asked for the person a subscription or a poll delivers to (access.Service.Hidden).

type Queue

type Queue interface {
	Enqueue(ctx context.Context, request queue.Request) (shared.ID, error)
}

Queue is the slice of the job queue this package needs. Declared here rather than imported whole so that what the webhooks can enqueue is visible in one place: one kind, and no other.

type ReadAiProvider

type ReadAiProvider struct{ Writer AiProviderWriter }

ReadAiProvider answers the configuration, never the key.

func (ReadAiProvider) Descriptor

func (h ReadAiProvider) Descriptor() usecase.Descriptor

func (ReadAiProvider) Execute

Execute reads it.

type ReadAiSource

type ReadAiSource struct{ Writer AiSourceWriter }

ReadAiSource answers the workspace's AI: what it may use, what it chose, and what is offered.

func (ReadAiSource) Descriptor

func (h ReadAiSource) Descriptor() usecase.Descriptor

func (ReadAiSource) Execute

func (h ReadAiSource) Execute(ctx context.Context, actor appshared.ActorContext) (AiState, error)

Execute reads it.

type ReadOwnAiPosition

type ReadOwnAiPosition struct{ Reader AiObjectionReader }

ReadOwnAiPosition answers the person's view.

func (ReadOwnAiPosition) Descriptor

func (h ReadOwnAiPosition) Descriptor() usecase.Descriptor

func (ReadOwnAiPosition) Execute

Execute reads it. No permission beyond being somebody: it is the caller's own choice, and the workspace's position is what every person here is entitled to see (P-14).

type RemoveAiProvider

type RemoveAiProvider struct{ Writer AiProviderWriter }

RemoveAiProvider takes the configuration away.

func (RemoveAiProvider) Descriptor

func (h RemoveAiProvider) Descriptor() usecase.Descriptor

func (RemoveAiProvider) Execute

Execute removes it.

Nothing else is undone. A suggestion somebody already accepted was that person's own write and stays exactly as it is, which is the whole reason accepting is a use case rather than a side effect.

type ReplayWebhookDelivery

type ReplayWebhookDelivery struct {
	Writer Writer
	// Jobs is where the new attempt is queued. The replay does not deliver: it records the attempt
	// and asks for it, exactly as the first attempt was asked for, so that one code path sends
	// every webhook this system has ever sent.
	Jobs Queue
}

ReplayWebhookDelivery sends a dead-lettered delivery again.

func (ReplayWebhookDelivery) Descriptor

func (h ReplayWebhookDelivery) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry.

func (ReplayWebhookDelivery) Execute

func (h ReplayWebhookDelivery) Execute(
	ctx context.Context, actor appshared.ActorContext, subscriptionID, deliveryID shared.ID,
) (domain.WebhookDelivery, error)

Execute queues the replay.

type RestrictionSource

type RestrictionSource struct {
	Restrictions privacyrepo.Restrictions
}

RestrictionSource keeps out the people whose processing is restricted (Art. 18, data-protection.md §4 and §4.1): no gate - unlike an objection it holds whatever the workspace's position, alone or not, because it is the controller's duty rather than a choice the workspace can take away. Placed first in the list, so that somebody both restricted and objecting reads as restricted and is kept out as far as a restriction reaches.

func (RestrictionSource) KeptOut

KeptOut answers the restricted people.

type RotateWebhookSecret

type RotateWebhookSecret struct{ Writer Writer }

RotateWebhookSecret issues a new signing secret and keeps the old one verifying for a grace.

func (RotateWebhookSecret) Descriptor

func (h RotateWebhookSecret) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry.

func (RotateWebhookSecret) Execute

Execute rotates.

Signatures are computed with the current secret from this moment on; what the grace buys is the subscriber's side of the check. That asymmetry is the whole point of the route - a subscriber cannot deploy atomically, and a rotation that took effect on both sides at once would drop every event arriving between the call and the deployment.

type SendWebhook

type SendWebhook struct {
	Writer Writer
	// Jobs is where the delivery is queued - the same kind, so one code path sends every webhook
	// this system has ever sent.
	Jobs   Queue
	Events EventSource
}

SendWebhook delivers one event to one named subscription (automation.md §1.3).

Through the subscriptions' one pipeline (automation.md §3.1): the same delivery table, the same signature, the same retry ladder and the same dead letter. It enqueues rather than calls - the actual HTTP happens on the delivery job, outside any transaction, because an external call from inside one holds a database connection for as long as somebody else's server feels like taking (observability-reliability.md §8). That is also what makes this safe as a rule action: the engine's handler runs inside the queue runner's transaction, and what this use case does there is two inserts.

The subscription's own event-type filter is deliberately not consulted. The fan-out's filter answers "what did this subscription ask to receive"; a send answers "what did this caller decide to deliver", and naming the subscription is the point of the call.

func (SendWebhook) Descriptor

func (h SendWebhook) Descriptor() usecase.Descriptor

Descriptor is the catalogue entry - and the automation action SEND_WEBHOOK with it, derived as every action is (automation.md §1.3).

func (SendWebhook) Execute

func (h SendWebhook) Execute(
	ctx context.Context, actor appshared.ActorContext, subscriptionID, eventID shared.ID,
) (domain.WebhookDelivery, error)

Execute queues the delivery.

type SetAiPersonOptOut

type SetAiPersonOptOut struct{ Writer AiSourceWriter }

SetAiPersonOptOut sets whether each person may keep their own content out of AI, or makes AI part of everybody's work on a legal basis the workspace names (data-protection.md §4.1).

The position lives in the archived workspace settings beside the narrowing: it is the controller's documented decision, and a restore puts back what was decided. Moving into NOT_OFFERED stamps the moment, which is what makes each objecting person's notice pending.

func (SetAiPersonOptOut) Descriptor

func (h SetAiPersonOptOut) Descriptor() usecase.Descriptor

Descriptor registers the position in every channel.

func (SetAiPersonOptOut) Execute

Execute writes the position.

type SetAiPersonOptOutCommand

type SetAiPersonOptOutCommand struct {
	PersonOptOut domain.AiPersonOptOut
	Basis        domain.AiOptOutBasis
	Contact      string
}

SetAiPersonOptOutCommand is the input, typed.

type SourcedResolver

type SourcedResolver interface {
	Sourced(ctx context.Context, actor appshared.ActorContext) (aiprovider.Provider, aiprovider.Source, error)
}

SourcedResolver answers the provider and which source it is - the adapter's Resolver.

type TargetQuota

type TargetQuota interface {
	WebhookTargets(ctx context.Context, tenant string) error
}

TargetQuota is the webhook-targets ceiling (multi-tenancy.md §4).

type UpdateWebhookSubscription

type UpdateWebhookSubscription struct{ Writer Writer }

UpdateWebhookSubscription changes a subscription.

func (UpdateWebhookSubscription) Descriptor

Descriptor is the catalogue entry.

func (UpdateWebhookSubscription) Execute

Execute applies the change.

Re-enabling a subscription that unreachability disabled goes through here like any other change, and is audited like one: somebody decided the target is reachable again, and the trail says who. What cannot be set by hand is DISABLED - that is the system's conclusion from a run of failures, and a state somebody can type is a state that says nothing about why.

type UpdateWebhookSubscriptionCommand

type UpdateWebhookSubscriptionCommand struct {
	ID         shared.ID
	TargetURL  *string
	EventTypes *[]string
	Filter     *string
	// Scope is the new scope; nil leaves it. The workspace's level widens it again.
	Scope           *domain.WebhookScope
	State           *string
	ExpectedVersion int
}

UpdateWebhookSubscriptionCommand is the input. Every field is a pointer, so that an omitted one is left alone and an explicit change is told apart from an absent field.

type WebhookResealer

type WebhookResealer struct {
	Subscriptions repository.WebhookSealings
	Encryptor     crypto.Encryptor
}

WebhookResealer moves a workspace's signing secrets under the current master key (ADR-0045) - the current secret and, while a rotation grace is running, the previous one too, because the verifier has to open both.

func (WebhookResealer) Reseal

func (WebhookResealer) Store

func (WebhookResealer) Store() string

type Writer

type Writer struct {
	Subscriptions repository.WebhookSubscriptions
	Deliveries    repository.WebhookDeliveries
	Authorizer    Authorizer
	// Encryptor seals the signing secret. The application never stores a plaintext and the
	// repository never holds a key: the sealing happens here, between the two.
	Encryptor  crypto.Encryptor
	Audit      audit.Sink
	UnitOfWork persistence.UnitOfWork
	Clock      clock.Clock
	IDs        clock.IDGenerator
	// Entropy is where a signing secret comes from. A port, so production draws from crypto/rand
	// and a test can fix the secret it asserts on (rule 4).
	Entropy clock.Entropy
	// Compiler checks a subscription's condition when it is saved. Nil refuses a condition, as the
	// rules do, rather than storing one nothing checked.
	Compiler expression.Compiler
	// Containers answers whether a scope's hub or collection is this workspace's, and which it is.
	Containers condition.Containers
	// Entitlements is what the workspace's plan leaves out: where it leaves out webhooks, a
	// subscription may be paused and nothing else changed (multi-tenancy.md §4.2). Creating one,
	// sending and replaying are refused on the registry. Nil includes every feature.
	Entitlements adminrepo.Entitlements
}

Writer is what the webhook use cases share.

One dependency set rather than six, for the reason the calendar feeds have one: the rule that decides who may touch a subscription is a single rule, and a second copy of it is how two answers to one security question get into a codebase.

Jump to

Keyboard shortcuts

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