Say it in plain language.
A caller states intent three ways — a bare string, a hand-built Goal, or a typed, portable WatchPlan. The plan layer derives ids and configuration, and never reads your words for meaning: a plain-language condition is carried verbatim to the judge, never compiled to a rigid predicate.
WatchPlan
WatchPlan is a frozen dataclass. Its one required field is condition; every other field is optional, and an unset field defers to the resolved profile. There is no YAML plan format — plans are Python WatchPlan dataclasses or plain strings.
import percept
from percept.contracts.watchplan import WatchPlan
plan = WatchPlan(
condition="the milk is boiling over", # plain language, verbatim — never compiled to attr=value
source="camera",
explicit={"Settings.edge.salience_stat": "block_max"}, # dotted-path override, highest precedence
)
async for event in percept.watch(stream, plan):
handle(event)Other optional fields include sense, target, admission, evidence, rearm, uncertainty, detectors, extractors, judge, judge_context, consequence, schedule, profile, and id.
Goal desugaring and stable ids
Bare strings desugar into Goal objects whose ids are stable content hashes of the form g_<slug>-<h8>. The same wording always produces the same id, so goals survive restarts, diffs, and re-arms without identity drift — and a goal re-armed under new wording is a new goal, never served another goal's cached verdict.
Two-phase atomic apply
Arming is all-or-nothing. Applying a plan first validates and resolves every goal — configuration, judge authorization, context binding — and only if the entire plan is viable does anything arm. A plan that partially fails arms nothing. The apply returns a machine-actionable WatchReceipt with per-field diagnostics, so a rejected plan tells you exactly which field stopped it.
Judges follow the same discipline: WatchPlan.judge is a request, not an override. If the requested judge does not resolve against the deployment catalog, the arm stops — it is never silently substituted. See fail-closed judging.
from percept.contracts.watchplan import WatchPlan, ArtifactBinding
plan = WatchPlan(
condition="a package is left unattended at the door",
judge="door-safety@1", # a request — validated against the deployment judge catalog
judge_context=ArtifactBinding( # pins the exact rubric/policy artifact by content hash
ref="door_rubric.md", # ref: [A-Za-z0-9._@-]+ only — can never smuggle a path
sha256="<64-hex digest>", # exact-bytes SHA-256; any mismatch aborts the arm
),
)
# If the requested judge does not resolve, the arm STOPS (a pre-arm error) —
# it is never silently substituted.Profiles and configuration provenance
Where does a threshold's value come from? Always from exactly one place, and the resolution can tell you which. A layered ConfigResolver merges independently-authored configuration into one immutable ResolvedWatch with per-field provenance (SettingOrigin), in a fixed precedence order:
- SDK defaultsThe typed tunables registry — every behavior-affecting knob enumerated once.
- Source and watch profilesNamed, versioned, content-addressed profiles — diffable, reviewable, reversible.
- Domain pack / execution policy / optimization lockEach layer may set only the fields it owns; an out-of-ownership write is a loud ConfigError, never last-writer-wins.
- Deployment environmentPERCEPT_* variables through the Settings tree.
- WatchPlan explicitDotted-path overrides on the plan itself — the highest precedence, and still recorded with provenance.
The result: every threshold answers "where did this value come from?" — per-field provenance, ownership-gated precedence, no magic numbers.