Overview
This PR adds the kernel of the Temporal determinism review: a stdlib-only AST check that fails when a known-nondeterministic call appears lexically inside an @workflow.defn class. It is two files — the checker (scripts/check_determinism.py) and a dedicated Determinism CI workflow that runs it on every push and PR.
The design choice that defines the kernel: it looks only at code written directly in a workflow class body, never chasing imports or helper calls. That trades recall for precision — it will miss a hazard hidden three modules down, but it will almost never flag something that is fine.
The gate wiring
A dedicated workflow, not a step folded into the existing CI job — so it surfaces as a distinct, independently-gateable check named Determinism. The script needs no install (stdlib ast only), so the job is just checkout → setup-python → run.
.github/workflows/determinism.yml · 28 lines
The banned table
Four small tables drive everything. Each entry maps a hazard to its sanctioned replacement, so a failure tells the author exactly what to do.
The four banned tables · 242 lines
⋯ 37 lines hidden (lines 1–37)
⋯ 162 lines hidden (lines 81–242)
| Category | How it's matched | Replacement |
|---|---|---|
datetime.* / time.* wall clock | exact callee path | workflow.now() / workflow.sleep() |
uuid.uuid1/3/4/5, os.urandom | exact callee path | workflow.uuid4() / workflow.random() |
random / threading / requests / httpx / subprocess | any call on the module | workflow.random() / move to an activity |
os.environ access | attribute access (need not be a call) | read env in an activity, pass it in |
open, input | bare builtin, only if not shadowed | do I/O in an activity |
Scope is the precision lever
Three small functions do the precise work. _import_map records what each bound name refers to. _resolve rebuilds a call's dotted path through that map — and returns None when the root name was never imported. _is_workflow_class is the scope gate: only classes decorated with @workflow.defn (or an alias) are scanned.
Import map · resolution · scope · 242 lines
⋯ 92 lines hidden (lines 1–92)
⋯ 15 lines hidden (lines 111–125)
⋯ 92 lines hidden (lines 151–242)
The scan, and the gate
scan_file walks every @workflow.defn class body. Calls are checked against the exact table first, then the module list; bare builtins are checked by name (only when not shadowed); attribute accesses catch os.environ. A seen set dedupes overlapping nested nodes. The exit code is the gate: non-zero on any finding.
Visitor + exit code · 242 lines
⋯ 171 lines hidden (lines 1–171)
⋯ 27 lines hidden (lines 198–224)
Why ynab-agent's boundary is clean today
ynab-agent has six workflows (registry, receipt, monitor, dispatch, poll, txn). All read time via workflow.now() and ids via workflow.uuid4(), so the check passes green — and it does so without tripping on three patterns that look like hazards but are not:
Verified locally before opening this PR: 0 findings on src, and a planted-hazard fixture flags all six injected calls while ignoring the two sanctioned workflow.* calls and a datetime.datetime used only as a return annotation.