Concepts

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.

PythonWatchPlan
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.

PythonJudge request
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:

  1. SDK defaultsThe typed tunables registry — every behavior-affecting knob enumerated once.
  2. Source and watch profilesNamed, versioned, content-addressed profiles — diffable, reviewable, reversible.
  3. 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.
  4. Deployment environmentPERCEPT_* variables through the Settings tree.
  5. 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.