Skip to content

Targets and Interceptors#

A capture target is a directory under the server root holding one Python module that marks a procedure with @cgserver.target. The directory's path relative to the root is the target's name, so directories double as groups: Plants/Tulip and Plants/Sunflower appear together under Plants in the app and the panel. The module's file name is free (the management panel names a new one after its directory); everything else about a target lives in the same directory:

my-deployment/
├── config.toml
├── .cg-scratch/              ← the recipe scratch every interceptor's memory lives in
└── PlantJournal/
    ├── plant_journal.py      ← the target module (you write this)
    ├── .history/             ← snapshots of validated saves
    └── sessions/             ← schema-defined directories of captured files
        ├── 00063B40E29D696A/
        │   ├── photo.heic
        │   └── notes.json
        └── ...

The schema fixes each file's stem; its extension is whichever of the scalar's accepted ones the client uploaded (photo.heic from a phone, photo.jpeg from a test). A target has no separate manifest file. Its on-disk layout is its schema, the cg.Struct you declare, and the schema travels with the procedure definition the app downloads. For a guided build of a complete target, read From Zero to a Scheduled Target; this page is the reference.

The main procedure#

The module declares a schema, records a procedure against it with @cg.procedure, and marks that procedure as the target's main procedure with @cgserver.target. There is exactly one per module, and one module per directory:

"""A target that captures a daily photo and a short note."""

import capturegraph as cg
from server import authoring as cgserver


class Session(cg.Struct):
    photo: cg.Image
    notes: cg.String
    _metadata: cg.Metadata


class PlantJournal(cg.Struct):
    sessions: cg.Map[cg.Date, Session]
    _saw_instructions: cg.Bool
    _metadata: cg.Metadata


@cgserver.target
@cg.procedure(PlantJournal)
def plant_journal(root):
    root._saw_instructions |= cg.ShowInstructions(
        text="Photograph the plant from the marked spot."
    )
    session = root.sessions[cg.CaptureTime()]
    session.photo &= cg.CaptureImage(label="Photograph the plant")
    session.notes &= cg.UserInputString(label="Notes")

@cgserver.target marks the recorded procedure and returns it unchanged. The server reads its {schema, root, nodes} wire form straight off the function and serves it at GET /api/v1/targets/{target}/definition; the schema is embedded in that response, so it doubles as the data layout the app, panel, and sync tool walk.

Drop the directory into the server root and it is hosted within target_refresh seconds, no restart. The server re-executes the module as soon as the file changes on disk (it stats the file per request), so saving an edit is deploying it: a participant pulling to refresh sees the new procedure within seconds.

Recording against the schema#

Inside the body, root is a Path into the schema. You navigate it by field, index, and key, and cache results into positions with two operators:

  • path &= valueoverwrite: capture this fresh in every session.
  • path |= valueskip if it already exists: configuration, references, and once-per-target values.

cg.do(step) records a side-effect-only step (a metronome, an assertion). Instructions are the exception: cg.ShowInstructions returns the user's acknowledgement, so cache it with |= into a hidden cg.Bool field (as above) to show it once instead of every session. Control flow uses context managers: with cg.when(cond):, with cg.optional():, and with cg.while_repeat(cond, array_path) as elem:. See the DSL guide for the full recorder surface.

Keep module import cheap

An unchanged module is served from cache, but anything at module scope still runs on every edit, at validation, and at server startup. Defer expensive work into interceptor bodies, and memoize it with a cg.pure_function so the recipe cache serves the result after the first run.

Interceptors#

An interceptor fills a schema position dynamically: instead of reading that position's file from disk, the server computes it on every download, for the requesting user. This is the hook that turns a passive file host into a capture coordinator. The positions the iOS app polls on its own are s._metadata._next_notification (a cg.Time, the next capture time), s._metadata._location (a cg.Location, the next capture point), and each session's _metadata._thumbnail (a cg.Thumbnail, for its gallery).

Hidden (_-prefixed) schema fields map to dotfiles on disk, so _metadata._next_notification is served as .metadata/.next_notification.

Declaring one: intercept#

@cgserver.intercept(Schema, /, select, *, skip_if_exists=False) names a position with a selector, a plain function of the schema that walks from its root to the scalar the function fills:

@cgserver.intercept(PlantJournal, lambda s: s._metadata._next_notification)
def notification(profile) -> cg.Time:
    return cg.Time(next_capture_time)

The selector runs once at import against a stand-in that records the fields it walks, so a field the schema does not have fails the target load there, never as a silent missing file at serve time. Three rules govern the walk:

  • Crossing a container is explicit. A Map or Array is a stop, not a step: lambda s: s.sessions selects the map itself, and lambda s: s.sessions.for_each() every session in it. So lambda s: s.sessions.for_each()._metadata._thumbnail claims the thumbnail of every session, including the ones whose keys nobody can name ahead of time; the pattern is sessions/*/.metadata/.thumbnail. There is no way to claim one key.
  • A field of a container needs the for_each(). lambda s: s.sessions.photo fails at import naming the fix, s.sessions.for_each().photo, and so does a for_each() anywhere but at a container. for_each is a reserved name in a selector, as _cg_selection is: a schema field cannot be called either.
  • Indexing is a load error. lambda s: s.sessions["…"] fails at import with a selector cannot index.

The selector must end at a scalar: the root (lambda s: s), a struct, or a container (lambda s: s.sessions) is rejected at decoration with the type it reached and, for a container, the for_each() that reaches its elements. The same selectors declare page positions, which may end anywhere.

@cgserver.intercept(
    PlantJournal,
    lambda s: s.sessions.for_each()._metadata._thumbnail,
    skip_if_exists=True,
)
def thumbnail(node: Session) -> cg.Thumbnail:
    return render(node.photo)

A request is matched against the declared patterns most specific first, so an intercept at an exact position wins over one crossing a container that also covers it. Two functions claiming one pattern, or a declaration made against a schema other than the target's, are caught when the module loads: the target still serves captures, but with every interceptor disabled and the reason in its status.

skip_if_exists chooses between two modes:

Mode Reads Client writes to the position
Shadowing (False, default) The function always answers. Refused with 409.
Disk-first (True) A file on disk is served as is; the function only fills the absent case. Land, and serve thereafter.

Function parameters#

An interceptor declares any subset of three parameter names, in any order; the server passes exactly the ones the signature asks for, as keyword arguments:

Parameter Type Description
profile cgserver.RequestProfile The identified caller: profile.user_id is the requesting user (a str; different users can get different answers), and profile.location their position (cg.Location \| None, present only when they opted in to sharing it). A request without a user id never reaches a function that declares profile; the position is served from disk instead.
target schema value The target's captured data, loaded from disk against the schema (target.sessions is the captured Map). Refreshed after every upload.
node schema value The nearest enclosing Struct: for s.sessions.for_each()._metadata._thumbnail the Session whose thumbnail was requested, and for s.sessions.for_each().photos.for_each() that same session. The whole target when no crossed container holds structs (s.photos.for_each() at the root).

So def notification(profile, target) and def count(target) are both valid. Declaring any other required parameter raises DefinitionError at import time. The return annotation is not checked; the returned value is, on every request (see below).

Interceptor errors fall back to disk

If an interceptor raises, or returns a value that does not coerce to the position's type, the server logs the error and the request falls back to whatever is on disk at that path, usually a 404. Clients never see a 500. When an intercepted file mysteriously "doesn't exist", read the server log or the target status in the management panel.

Persistence#

Interceptors hold no durable state of their own: everything they memoize or carry across requests lives in the recipe scratch the server opens at startup, <root>/.cg-scratch or the scratch_path in config.toml (see the recipes guide). An interceptor never configures the scratch itself; it just calls recipes. Two mechanisms cover the two needs:

  • Pure memoization — expensive, deterministic work (forecasting, void-and-cluster selection, thumbnail rendering) in a cg.pure_function, keyed by the content of its arguments, so the body runs once per distinct input and every later request with the same inputs is served from the cache.
  • Keyed impure state — coordination that evolves (who holds which slot) in a cg.ImpureState class: fields are the persistent book, methods its operations, and each call is one serialized, cross-process read-modify-write. The open key shards one book per target. The scheduling books (ReservationBook and CoverageBook) are exactly this.

The scratch is pure speedup plus in-flight coordination: the captured data on disk is the durable record, so deleting .cg-scratch forgets current reservations and forces a recompute, nothing else. The server relies on that: a scratch left behind by an older layout is discarded and rebuilt at startup, so an upgrade never leaves every recipe-backed position failing.

What the server enforces#

  • Position by path. A request is intercepted only when its full relative path matches a claimed pattern, * standing for exactly one component; a file with the same name elsewhere is served from disk as usual. A pattern crossing no container names one file and never reaches into a Map or Array entry.
  • Type resolution and coercion. The result type is resolved from the schema at the position, and the value is coerced to it before serving: a bare value fills a single-field scalar (a datetime for a cg.Time, a number for a cg.Number), a path fills a file scalar if its extension is one the type accepts, and anything else is rejected.
  • Write protection. A client upload to a shadowed position is refused with HTTP 409; skip_if_exists=True lifts the refusal.
  • Delivered dynamically. On the wire an intercepted position resolves as a dynamic leaf: a present verdict with dynamic: true, a freshly computed version, and bytes carried inline. A raw download carries the version as its ETag, so a matching If-None-Match still yields 304. See the captured-data format for the full contract.
  • Dry run on load. Every interceptor whose selector crosses no container is called once when the target loads, with a throwaway user and inside a throwaway scratch, and its result or error is reported in the target's status. One crossing a container has no single position to dry-run and is skipped.

Scheduling lives here#

Interceptors are where the scheduling library runs: a pure recipe such as [notification_candidates][server.notifications.candidates.notification_candidates] chooses which times to offer, and an impure book such as ReservationBook gives each participant a distinct one. The walkthrough builds the temporal and the spatial version line by line and ends with both complete modules.

Editing safely: the validation pipeline#

Procedure saves from the management panel never touch the live module directly:

  1. The candidate is written to a hidden pending file at the target root.
  2. A subprocess imports it, finds and builds the @cgserver.target procedure, serializes the result, and checks every interceptor declaration, killed after validation_timeout seconds if it hangs.
  3. Only on success is the current live module snapshotted into .history/ (newest history_limit kept) and the pending file swapped in atomically.

A broken save therefore can never brick a live target, and every previous version is one click away in the panel's History view; restores run through the same pipeline, so they are themselves undoable.

Editing the module directly on disk (over SSH, say) skips all of this: the edit goes live on the next request, valid or not. That is sometimes what you want, but the panel's editor is the safer default.