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
- func AiKeyPurpose(tenantID shared.ID) crypto.Purpose
- func AiSourceOutput(state AiState) usecase.Output
- func OfferedAiKeyPurpose(providerID shared.ID) crypto.Purpose
- func OwnAiPositionOutput(own OwnAiPosition) usecase.Output
- func SecretPurpose(id shared.ID) crypto.Purpose
- type AcknowledgeAiPosition
- type AiBudget
- type AiObjectionReader
- type AiProviderResealer
- type AiProviderResolver
- type AiProviderWriter
- type AiRules
- type AiSelection
- type AiSourceWriter
- type AiState
- type Authorizer
- type Budgeted
- type BusFanOut
- type ChooseAiSource
- type ChooseAiSourceCommand
- type ConfigureAiProvider
- type ConfigureAiProviderCommand
- type CreateWebhookSubscription
- type CreateWebhookSubscriptionCommand
- type Cursors
- type DeleteWebhookSubscription
- type DeliveryListing
- type DisableNotifier
- type EmbeddingForgetter
- type EmbeddingJobs
- type EmbeddingReseeder
- type EmbeddingSeeder
- type EventRendering
- type EventSource
- type Exclusion
- type FanOut
- type GetWebhookSubscription
- type KeptOutSource
- type ListWebhookDeliveries
- type ListWebhookSubscriptions
- type MintedSubscription
- type ObjectionSource
- type OfferedAiProviderResealer
- type Outcomes
- type OwnAiPosition
- type Page
- type PollTriggerEvents
- type PollTriggerEventsCommand
- type PrivateReach
- type Queue
- type ReadAiProvider
- type ReadAiSource
- type ReadOwnAiPosition
- type RemoveAiProvider
- type ReplayWebhookDelivery
- type RestrictionSource
- type RotateWebhookSecret
- type SendWebhook
- type SetAiPersonOptOut
- type SetAiPersonOptOutCommand
- type SourcedResolver
- type TargetQuota
- type UpdateWebhookSubscription
- type UpdateWebhookSubscriptionCommand
- type WebhookResealer
- type Writer
Constants ¶
const ( ReadOwnAiPositionName = "ReadOwnAiPosition" AcknowledgeAiPositionName = "AcknowledgeAiPosition" OwnAiPositionReadAction audit.Action = "ai.own_position_read" AiNoticeAckAction audit.Action = "ai.notice_acknowledged" )
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" )
const ( ConfigureAiProviderName = "ConfigureAiProvider" ReadAiProviderName = "ReadAiProvider" RemoveAiProviderName = "RemoveAiProvider" )
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.
const ( RefusalNotAllowed = "ai.source_not_allowed" )
Refusals the resolver answers with.
const ( ReadAiSourceName = "ReadAiSource" ChooseAiSourceName = "ChooseAiSource" )
const ( AiSourceChosenAction audit.Action = "ai.source_chosen" AiSourceReadAction audit.Action = "ai.source_read" )
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" )
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" )
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 )
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).
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.
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).
const PollTriggerEventsName = "PollTriggerEvents"
PollTriggerEventsName is the use case behind GET /integrations/triggers/{eventType}.
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 ¶
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 ¶
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 ¶
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.
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 ¶
func (h AcknowledgeAiPosition) Execute(ctx context.Context, actor appshared.ActorContext) (OwnAiPosition, error)
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) 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) Select ¶
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 ¶
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 ¶
func (b Budgeted) For( ctx context.Context, actor appshared.ActorContext, ) (aiprovider.Provider, error)
For answers the workspace's provider, or one that refuses because the budget is spent.
type BusFanOut ¶
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 ¶
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) Wants ¶
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 ¶
func (h ChooseAiSource) Execute( ctx context.Context, actor appshared.ActorContext, cmd ChooseAiSourceCommand, ) (AiState, error)
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 ¶
func (h ConfigureAiProvider) Execute( ctx context.Context, actor appshared.ActorContext, cmd ConfigureAiProviderCommand, ) (domain.AiProvider, error)
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 ¶
func (h CreateWebhookSubscription) Descriptor() usecase.Descriptor
Descriptor is the catalogue entry.
func (CreateWebhookSubscription) Execute ¶
func (h CreateWebhookSubscription) Execute( ctx context.Context, actor appshared.ActorContext, cmd CreateWebhookSubscriptionCommand, ) (MintedSubscription, error)
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 ¶
func (h DeleteWebhookSubscription) Descriptor() usecase.Descriptor
Descriptor is the catalogue entry.
func (DeleteWebhookSubscription) Execute ¶
func (h DeleteWebhookSubscription) Execute( ctx context.Context, actor appshared.ActorContext, id shared.ID, ) error
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 ¶
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.
type EventRendering ¶
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 ¶
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.
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 ¶
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) Wants ¶
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 ¶
func (h GetWebhookSubscription) Execute( ctx context.Context, actor appshared.ActorContext, id shared.ID, ) (domain.WebhookSubscription, error)
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 ¶
func (h ListWebhookDeliveries) Execute( ctx context.Context, actor appshared.ActorContext, listing DeliveryListing, ) ([]domain.WebhookDelivery, bool, error)
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 ¶
func (h ListWebhookSubscriptions) Descriptor() usecase.Descriptor
Descriptor is the catalogue entry.
func (ListWebhookSubscriptions) Execute ¶
func (h ListWebhookSubscriptions) Execute( ctx context.Context, actor appshared.ActorContext, ) ([]domain.WebhookSubscription, error)
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) Store ¶
func (OfferedAiProviderResealer) Store() string
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) 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 ¶
func (h PollTriggerEvents) Execute( ctx context.Context, actor appshared.ActorContext, cmd PollTriggerEventsCommand, ) (Page, error)
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 ¶
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 ¶
func (h ReadAiProvider) Execute( ctx context.Context, actor appshared.ActorContext, ) (domain.AiProvider, error)
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 ¶
func (h ReadOwnAiPosition) Execute(ctx context.Context, actor appshared.ActorContext) (OwnAiPosition, error)
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 ¶
func (h RemoveAiProvider) Execute(ctx context.Context, actor appshared.ActorContext) error
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 ¶
func (s RestrictionSource) KeptOut(ctx context.Context, _ shared.ID) ([]domain.KeptPerson, error)
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 ¶
func (h RotateWebhookSecret) Execute( ctx context.Context, actor appshared.ActorContext, id shared.ID, grace time.Duration, ) (MintedSubscription, error)
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 ¶
func (h SetAiPersonOptOut) Execute( ctx context.Context, actor appshared.ActorContext, cmd SetAiPersonOptOutCommand, ) (AiState, error)
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 ¶
TargetQuota is the webhook-targets ceiling (multi-tenancy.md §4).
type UpdateWebhookSubscription ¶
type UpdateWebhookSubscription struct{ Writer Writer }
UpdateWebhookSubscription changes a subscription.
func (UpdateWebhookSubscription) Descriptor ¶
func (h UpdateWebhookSubscription) Descriptor() usecase.Descriptor
Descriptor is the catalogue entry.
func (UpdateWebhookSubscription) Execute ¶
func (h UpdateWebhookSubscription) Execute( ctx context.Context, actor appshared.ActorContext, cmd UpdateWebhookSubscriptionCommand, ) (domain.WebhookSubscription, error)
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) 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.