Documentation
¶
Overview ¶
Package automation evaluates a workspace's standing instructions and carries them out.
**Nothing here runs on the request path, and nothing can put it there.** The only entry point is Evaluate, the only caller is the scheduler, and the scheduler calls it under the Postgres advisory lock every other job takes. A redirect never touches this package; neither does a link write. That is the first of the two claims m43.md turns on, and the import graph is what enforces it — internal/link and internal/redirect do not import this package, and this package's dependencies are all interfaces it declares itself.
**The per-run cost is a product of four constants, all of them in internal/domain.** A run reads at most domain.AutomationRulesPerRun rules; each rule runs one indexed range query bounded at domain.AutomationMatchesPerRule; each rule runs at most domain.MaxAutomationActions actions, of which only the archive is per-subject; and one further statement closes the run by advancing the cursor over every rule it looked at. The arithmetic is written out beside those constants rather than left for a reader to assemble, because m43.md's Risks say the bound is the thing that has to be true and a bound nobody can state is not one.
**Every rule comes round, and the column that makes that true is not the watermark.** The cap means a run sees a hundred rules and an instance may hold more, so which hundred is a fairness question rather than a detail. `last_checked_at` answers it: the due query orders on it, and Evaluate advances it for every rule the pass reached — fired, matched nothing, or failed. The watermark cannot do that job, because it moves only on a firing and idle is exactly what keeps it old: ordering on it left the hundred oldest a fixed set, with rule 101 never evaluated on any run, which is F83 and what migration 03100 separates.
**A rule cannot trigger itself, and the mechanism is the watermark.** Every match query orders by (event time, id) and reads the window that opens strictly after the pair `(last_fired_at, last_fired_subject_id)` and closes at `now`; the claim that fires a rule advances both halves past the last subject it handled *before* any action runs. A pair rather than an instant, because a capped fetch can stop between two subjects sharing one timestamp and only a position in the full match order can say which of them were handled — see resumeCursor. A subject is therefore visible to a rule exactly once, whatever the actions did and however many times the scheduler ticks.
**A rule cannot loop through another rule either**, and that is a separate mechanism: no action writes anything any trigger reads. domain.TriggerReads and domain.ActionWrites declare both halves and TestNoAutomationActionWritesATriggerSource asserts they never intersect. It is why the webhook action emits only `automation.fired` — an event nothing triggers on — rather than letting a rule choose. m43.md names the failure as *an automation that fires a webhook that fires an automation*; the answer is that the vocabulary makes the second half unreachable, and that the assertion fails the build if anybody widens it.
Index ¶
Constants ¶
const SubjectsShown = 5
SubjectsShown is how many subjects a notification and a webhook payload name.
Five. A firing that matched twenty-five links should produce one inbox item somebody reads, not a wall of aliases; the count travels beside the list so a truncated list never reads as a complete one.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Archiver ¶
type Archiver interface {
ArchiveByRule(ctx context.Context, workspaceID, linkID uuid.UUID) (bool, error)
}
Archiver is internal/link's archive, as this package needs it.
Declared here rather than imported so the graph stays one-way: internal/link never imports this package, exactly as it never imports internal/webhook. The method takes no actor because there is none — see internal/link/automation.go for why a synthetic identity would have been the worse answer.
type Config ¶
type Config struct {
Links Archiver
Notifier Notifier
Events Emitter
Audit audit.Recorder
Logger *slog.Logger
Observer Observer
// Now overrides the clock. Nil takes time.Now.
Now func() time.Time
}
Config is what a Service needs. Its own struct rather than config.Config, matching every other service in this tree: the package that does the work does not read the environment.
type Emitter ¶
type Emitter interface {
Emit(ctx context.Context, workspaceID uuid.UUID, event string, data map[string]any)
}
Emitter is internal/webhook's writing half, as internal/link already declares it. Nil emits nothing.
type Notifier ¶
type Notifier interface {
AutomationFired(ctx context.Context, orgID, workspaceID, ruleID uuid.UUID,
ruleName, trigger string, matched int, subjects []string) error
}
Notifier is internal/notify's automation half. Nil notifies nobody, which is what a process built without a notifier gets.
type Observer ¶
type Observer interface {
ObserveAutomationFiring(trigger, outcome string)
}
Observer counts firings. Nil counts nothing.
The label vocabulary is bounded by construction: three triggers and two outcomes, so the whole metric is six series however many rules exist. M13's cardinality rule is what makes a rule name unacceptable as a label.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the evaluator.