Documentation
¶
Overview ¶
Package campaign is one go-to-market push across paid, organic and email at once.
A campaign — audience, creatives, schedule, budget — launches to every channel and reads back as one funnel with each channel's spend.
A Campaign is a VALUE — {name, audience, content[], schedule, budget, channels[], status} — that SPANS channels and fans out to orthogonal executors. It is the capability layer that CONSUMES the connector plane: the campaign object never touches a credential; each channel executor resolves the org's connector token itself through the integrations.TokenFor custody seam.
THE DECOMPLECT (HIP-0126 — Integrations, Connectors & the Extension Runtime): a Connector is a connection (credential custody + auth); a capability is what you DO with it. /v1/campaign is a CONSUMER of connectors — the role HIP-0126 gives Flows — never a second credential path. "campaign" used to be braided across three packages — an ad campaign (apps/ads), an email campaign (apps/marketing), social posts (apps/social). This plane lifts the GTM campaign to the ONE value it is and makes the channels orthogonal EXECUTORS it fans out to (channel.go):
paid → apps/ads (meta_ads/google_ads/tiktok_ads/… — REGISTERED) organic → apps/social (the social connectors — NO executor registered yet) email → apps/marketing (sendgrid/mailchimp/… — NO executor registered yet)
Only the paid executor is wired today (plugin/campaign/seams.go). A campaign carrying an organic or email channel launches its paid channels and records the others "unavailable" — honest, never a faked launch. Until those two executors exist, /v1/social and /v1/marketing are the ONLY way to run those channels, and each is ALSO usable standalone once they are; this plane composes them. Metrics are NOT stored here — a campaign's results are read at query time from the ONE analytics plane (metrics.go: analytics.CampaignMetrics over the utm_campaign-tagged events) plus each channel connector's reported spend. A creative A/B is an experiment whose variant = creative and whose metric = the campaign result from analytics; it composes the experiment seam (experiment.go), never a second assignment or evidence store.
Tenant isolation is enforced SERVER-SIDE on every request: the org is principal.Org(c) — the value SanitizeIdentity minted from the VALIDATED bearer owner claim (HIP-0026) — and NEVER a client-supplied header. Every store query filters WHERE org=?, and the org is the value passed to every channel executor, so a campaign can only ever resolve its OWN org's connector token.
Surface (all org-scoped; /v1 only):
GET /v1/campaign/summary per-org roll-up + wired channels
GET /v1/campaign list campaigns (?status=) -> {data:[…]}
POST /v1/campaign create a campaign (draft) -> Campaign (201)
GET /v1/campaign/:id campaign detail -> Campaign
PUT /v1/campaign/:id update a draft campaign -> Campaign
DELETE /v1/campaign/:id delete a campaign
POST /v1/campaign/:id/launch fan out to channels -> Campaign
POST /v1/campaign/:id/pause pause every live channel -> Campaign
GET /v1/campaign/:id/metrics analytics results + spend -> Metrics
POST /v1/campaign/:id/channels add a channel -> Campaign
DELETE /v1/campaign/:id/channels/:kind remove a channel -> Campaign
serve.go auto-registers GET /v1/campaign/health (no OwnsHealth here).
Index ¶
- Constants
- func ExperimentKey(campaignID string) string
- func Mount(app cloud.Router, deps cloud.Deps) error
- func RegisterChannel(ch Channel)
- func SetExperiment(assign AssignFunc, analyze AnalyzeFunc)
- func Shutdown() error
- type AnalyzeFunc
- type AssignFunc
- type Campaign
- type Channel
- type ChannelMetric
- type ChannelSpec
- type LaunchFunc
- type Metrics
- type PauseFunc
- type Plan
- type Ref
- type SpendFunc
- type Store
- func (s *Store) Close() error
- func (s *Store) Counts(ctx context.Context, org string) (total, live int, budget int64, err error)
- func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (Campaign, error)
- func (s *Store) DeleteCampaign(ctx context.Context, org, id string) (bool, error)
- func (s *Store) GetCampaign(ctx context.Context, org, id string) (Campaign, error)
- func (s *Store) ListCampaigns(ctx context.Context, org, status string, limit int) ([]Campaign, error)
- func (s *Store) Save(ctx context.Context, c Campaign) (Campaign, error)
Constants ¶
const ( KindPaid = "paid" // ad campaigns across Meta/Google/TikTok/… (the ad connectors) KindOrganic = "organic" // content syndication / social posts (the social connectors) KindEmail = "email" // drip / broadcast through the org's email provider (the email connectors) )
Kinds — the three orthogonal go-to-market channels. A campaign fans out to a subset of these; each is also usable standalone at its own /v1 surface (paid=/v1/ads, organic=/v1/social, email=/v1/marketing). Only paid has a registered executor; a channel with none records "unavailable" at launch.
const ( StatusDraft = "draft" StatusScheduled = "scheduled" StatusLive = "live" StatusPaused = "paused" StatusCompleted = "completed" StatusFailed = "failed" )
Campaign lifecycle. draft is the only fully-mutable state; launch fans the campaign out to its channels and moves it to live (or failed if every channel failed); pause stops every live channel.
Variables ¶
This section is empty.
Functions ¶
func ExperimentKey ¶
ExperimentKey is the stable experiment identity for a campaign's creative A/B. A campaign opts into A/B by creating an experiment with THIS id (variants = creatives) on the experiments plane; if none exists, Assign fails and the campaign runs a single creative — no coupling, no auto-creation.
func Mount ¶
Mount wires the campaign surface onto app per HIP-0106. It keeps a package global (mounted) for Shutdown, so it constructs the Service value directly — the same "complex flavour" clients/ads uses.
func RegisterChannel ¶
func RegisterChannel(ch Channel)
RegisterChannel installs a channel executor. Last registration for a kind wins (idempotent re-wire). Called from the composition root, never from the orchestrator.
func SetExperiment ¶
func SetExperiment(assign AssignFunc, analyze AnalyzeFunc)
SetExperiment wires the experiments primitive into the campaign plane. Called once at the composition root (apps/wire_seams.go) with the experiments.Assign + experiments.Analyze adapters. Passing nils clears the seam (single-creative mode).
Types ¶
type AnalyzeFunc ¶
type AnalyzeFunc func(ctx context.Context, org, experimentID string, start, end time.Time) (json.RawMessage, error)
AnalyzeFunc returns an experiment's current analysis JSON — it composes experiments.Analyze (pull-model: it reads the metric from analytics itself). The result is opaque JSON so campaign stays decoupled from the experiments analysis type; the metrics endpoint embeds it verbatim.
type AssignFunc ¶
type AssignFunc func(ctx context.Context, org, experimentID, subject string) (variant string, err error)
AssignFunc resolves the creative variant a launch runs — it composes experiments.Assign (the root supplies the project and extracts the variant key from the flags.Assignment). campaign maps the returned key to utm_content. An error or empty result means "no assignment" → single creative.
type Campaign ¶
type Campaign struct {
ID string `json:"id"`
Org string `json:"-"` // tenant key — server-set from the validated owner claim, never client
Name string `json:"name"`
Audience string `json:"audience,omitempty"` // segment/audience selector ref
Content []string `json:"content"` // creative(s)
Channels []ChannelSpec `json:"channels"` // fan-out targets
ScheduleAt int64 `json:"scheduleAt,omitempty"`
Budget int64 `json:"budget"` // cents
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
Campaign is the top-level GTM object — a VALUE that spans channels. Budget is minor units (cents). Content is the ordered creative set (Content[0] is the active creative; the rest are A/B variants when an experiment is composed). Metrics are deliberately NOT a field: they are read at query time from the ONE analytics plane (metrics.go), never stored here.
type Channel ¶
type Channel interface {
Kind() string
Launch(ctx context.Context, org string, p Plan) (Ref, error)
Spend(ctx context.Context, org string, ref Ref) (int64, error)
Pause(ctx context.Context, org string, ref Ref) error
}
Channel is a go-to-market executor. Launch runs the campaign's slice on the channel's provider (via the org's connector token); Spend reads the provider-reported spend for a launched Ref; Pause stops it. Every method is org-scoped and fails closed when the org has not connected the channel's connector — the orchestrator records that honestly, never fabricates a launch.
func NewChannel ¶
func NewChannel(kind string, launch LaunchFunc, spend SpendFunc, pause PauseFunc) Channel
NewChannel builds a Channel from a kind and its injected executor funcs. The composition root calls it once per channel (apps/wire_seams.go) with the concrete ads/publish/marketing execution funcs, then RegisterChannel-s it.
type ChannelMetric ¶
type ChannelMetric struct {
Kind string `json:"kind"`
Platform string `json:"platform"`
Status string `json:"status"`
ExternalID string `json:"externalId,omitempty"`
SpendCents int64 `json:"spendCents"`
SpendError string `json:"spendError,omitempty"` // honest: connector spend read failed
}
ChannelMetric is one channel's spend contribution to a campaign's metrics.
type ChannelSpec ¶
type ChannelSpec struct {
Kind string `json:"kind"` // paid | organic | email
Platform string `json:"platform"` // meta | google | x | instagram | (email provider)
Account string `json:"account,omitempty"` // provider account ref (ad-account/page/list id)
ExternalID string `json:"externalId,omitempty"`
Status string `json:"status"` // pending | live | paused | failed | unavailable
Detail string `json:"detail,omitempty"` // honest last-outcome detail (never a secret)
}
ChannelSpec is one fan-out target on a Campaign: which kind (paid/organic/ email), the provider platform + account it runs on, and — after launch — the provider-side id + status the orchestrator recorded. It carries NO credential; the executor resolves the org's connector token itself at launch time.
type LaunchFunc ¶
The three executor operations, as primitive-typed function seams so a concrete channel package never has to import campaign to be registered.
type Metrics ¶
type Metrics struct {
CampaignID string `json:"campaignId"`
Name string `json:"name"`
Status string `json:"status"`
Range string `json:"range"`
Start string `json:"start"`
End string `json:"end"`
Available bool `json:"available"`
Impressions int64 `json:"impressions"`
Clicks int64 `json:"clicks"`
Conversions int64 `json:"conversions"`
Revenue float64 `json:"revenue"`
Visitors int64 `json:"visitors"`
SpendCents int64 `json:"spendCents"`
CTR float64 `json:"ctr"` // clicks / impressions
CVR float64 `json:"cvr"` // conversions / clicks
CAC float64 `json:"cac"` // spend $ per conversion
ROAS float64 `json:"roas"` // revenue per spend $
Channels []ChannelMetric `json:"channels"`
Source string `json:"source"`
// ABTest is the creative A/B analysis from the experiments primitive
// (experiments.Analyze, pull-model), present only when the campaign runs
// more than one creative and an experiment is wired. Opaque JSON — campaign
// stays decoupled from the experiments analysis type.
ABTest json.RawMessage `json:"abTest,omitempty"`
}
Metrics is a campaign's results view: the analytics-sourced funnel + the connector-sourced spend + the derived growth KPIs. Available reflects the analytics events lens (false = warehouse not yet emitting, honest-empty).
type PauseFunc ¶
The three executor operations, as primitive-typed function seams so a concrete channel package never has to import campaign to be registered.
type Plan ¶
type Plan struct {
CampaignID string
Name string
Objective string
Platform string // paid→meta|google|tiktok|…; organic→x|instagram|…; email→(provider)
Account string // provider account ref (ad-account id, page id, list id)
Content []string // creative(s); Content[0] is the active creative for this launch
Variant string // A/B creative id (utm_content) assigned by the experiment seam, or ""
BudgetCents int64
ScheduleAt int64 // unix; 0 = launch now
}
Plan is the org-scoped, channel-specific slice a campaign hands ONE executor at launch. It carries no credential — the executor resolves the org's connector token itself (integrations.TokenFor), so credential custody never crosses this seam. Variant is the A/B creative assigned for this fan-out (the utm_content the executor tags its events with); "" when the campaign runs a single creative.
type Ref ¶
Ref is an executor's durable handle on a launched execution — the provider-side id plus the platform/account needed to read spend or pause it later. The orchestrator records it onto the campaign's channel row and hands it back verbatim for Spend / Pause.
type SpendFunc ¶
type SpendFunc func(ctx context.Context, org string, ref Ref) (int64, error) // provider-reported spend, minor units (cents)
The three executor operations, as primitive-typed function seams so a concrete channel package never has to import campaign to be registered.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the campaign database. ONE SQLite file — the system namespace's "campaign" — holds every org's records; tenant isolation is the `org` column, enforced on EVERY query. Mirrors clients/ads exactly (the ONE storage pattern). MaxOpenConns(1) serializes writes against the single-writer file.
func (*Store) Counts ¶
Counts returns the per-org campaign roll-up: total campaigns, how many are live, and the summed budget (cents) — a real, non-fabricated overview.
func (*Store) CreateCampaign ¶
func (*Store) DeleteCampaign ¶
func (*Store) GetCampaign ¶
func (*Store) ListCampaigns ¶
func (s *Store) ListCampaigns(ctx context.Context, org, status string, limit int) ([]Campaign, error)
ListCampaigns lists the org's campaigns, optionally filtered by status (status=="" means all). Most-recently-updated first.
func (*Store) Save ¶
Save persists the full campaign row (name, audience, content, channels, schedule, budget, status). It is the ONE write used by update AND by launch/ pause (which rewrite channels + status), always org-scoped: a cross-tenant id affects zero rows → errNotFound, never a foreign mutation.