Every convention in CLAUDE.md serves one architectural goal: all logic should be expressible as composable pipelines whose execution can be traced and visually represented as DAGs.
This is not a style preference. It’s an architectural decision driven by how the user works — they spend the majority of their time in visual representations of flows.
Ok/Err is strictly pipeline plumbing — a universal interface contract that makes transforms composable with a standard error handling pattern regardless of position in the stack.
Think of it as the standard connector between any two pipeline stages. Every transform either produces a value (Ok) or an error (Err), and the next stage can pattern-match on that.
In agent loops with various iterations, something could be erroring and you can see the error but not know how it got there. Pipelines with Result types enable standardized tracing:
Pipelines are fractal. A node in a pipeline can itself be a pipeline — cycles, sub-agents, retry loops are all subgraphs that nest inside the parent as a single node.
Result[T, E]), same as any other transformResult[T, E] at the boundary whether it’s a pure function or a 50-step sub-agentExample in engagement-scout: the per-message flow is a scout_message pipeline whose one score_and_draft node expands into a jig.run_agent subgraph. Zoomed out it’s message in → ReplyCandidate out. Zoomed in it’s the agent loop — evaluate, draft, self-critique, optionally call revise_draft and iterate, then submit_output.
SysVista implication: any node should be expandable/collapsible in the viewer. The flow view / system view toggle is the first version of this zoom. Generalize to arbitrary nesting depth.
If you can’t draw the data flow as a DAG where each node is a pure-ish transform and each edge is typed data (including Result), the design needs rethinking. This applies recursively — every subgraph must satisfy the same invariant.
scan_runner.classify_outcome is the node that turns a scored ReplyCandidate into a terminal OutcomeDecision — synchronous, DB-free, with no logging or clock access. Its only collaborators are RELEVANCE_THRESHOLD, immutable input objects, and verify_draft_content. persist_outcome is the adjacent, separate node that writes an OutcomeDecision through StateManager; keeping classification and persistence as distinct typed-boundary nodes is what makes the full status ladder (surfaced, abstained, critic_rejected, gate_blocked, not_relevant, low_relevance, drafting_failed) independently testable without SQLite.
evaluations.abstain_reason is a deprecated compatibility column from an earlier terminal-reason shape. The active contract is evaluations.failure_reason, populated uniformly for every non-surfaced outcome (including abstained, where it holds StructuredDraftOutput.abstain_reason). abstain_reason is read by no code path and written by none going forward; it remains only for historical rows already classified by migration 17.
Relevance labels judge content only; whether the account is worth engaging is a separate dimension that the manual blocklist expresses. scanning.author_class.classify_author is the first automatic signal on that dimension: a pure transform from display name and handle to an AuthorClassification (aggregator or unknown, the rule version, and the text that matched), keyed on a lexicon of feed, bot and news terms. The scan runner’s _annotate_author node runs it for every persisted post, blocked authors included, and upserts one row per author into author_classifications (schema v42). It annotates, never gates: an unblocked aggregator still flows into evaluation, a storage failure is logged and evaluation continues, and the review UI shows the class beside the block button so a reviewer can act on it. tests/fixtures/author_classifier/acceptance.json pins the rule against the blocklist and the human-graded relevant authors; a rule change bumps AUTHOR_CLASS_RULE_VERSION and re-scores that fixture.
The typesafe classifier is a sibling of the author-annotation node, not a
stage in the relevance/drafting pipeline. After fetch and deduplication the scan
runner starts it as a background task, records its output in
shadow_relevance_runs, and later backfills the evaluation link when one is
available. Failure is logged and contained. Its answer and decision never alter
the pipeline result, relevance score, review status, or surfaced draft.
Question catalogues are versioned data, not code-generated SDK objects. A
canonical content hash identifies the exact question set and decision mapping;
each shadow row stores that catalogue version and the tagged answers. Offline
fitting loads an existing RelevanceTask and its FrozenPartition, hard-refuses
anything except train, restricts the SQL join to those train evaluation IDs,
and joins one catalogue version to the snapshot-pinned finalized grade
revisions. Pure feature extraction maps probability answers, score levels, and
choice questions with a none option to numeric features. Scikit-learn fits L2
logistic weights, which remain a versioned WeightSet on disk. The scan path
does not discover or load fitted weights; fitted_gate/<weight_set_version> is
registered explicitly only for tests and a possible future promotion.
“Shadow” therefore has three distinct meanings in Scout:
shadow is the autonomy position declared in
contracts/paa/*.yaml; it describes whether a declared task may affect the
world.shadow, selected by FEEDBACK_PROMPT_ENABLED in
config.py and retained by paa/replay_records.py, records feedback without
supplying it to live prompts.shadow_relevance_runs without
touching the pipeline result.Scout takes the PAA contract and its reference control plane from
RankOneLabs/paa as pinned
dependencies (paa-contracts, paa-runtime) and checks in three
declarations at contracts/paa/*.v1.yaml (inbound_reply_surfacing
shadow/hitl, canonical_promotion disabled/hitl, reply_draft
shadow/manual). All three are visible to the scout paa control plane.
The replay-only reply_draft task has no reachable transition from its
initial manual position; activation requires a new declaration. Scout no longer
implements the loader: paa_runtime.declarations does, and
scout.paa.declarations binds it to Scout’s declarations directory and
producer registry. Schema conformance is proved by
tests/test_paa_declarations.py against the packaged artifacts — no
checkout, no env var, no skip path.
That leaves the registry as Scout’s own job: proving every declared evaluator resolves to a real (or explicitly reserved future) Scout producer.
That resolution is the boundary this doc’s “typed edge” principle applies
to at the evaluator-identity level: each evaluator’s (property, target,
technique, evaluation_basis, epistemic_status, version, authority) tuple
is the typed contract between a PAA declaration and the Scout code that
actually produces its verdict. evaluation_basis and epistemic_status
were a single oracle field until this contract revision; one field could
not distinguish a rubric-graded proxy from a rubric-graded ground truth,
which collapsed two genuinely different producers of the same property
into one identity. scout.paa.declarations.PRODUCER_REGISTRY is the one place that boundary is
checked — CONTENT_INVARIANTS_EVALUATOR_VERSION, AUTHOR_RATE_EVALUATOR_VERSION,
LLM_CRITIC_PROMPT_VERSION, and HUMAN_GRADE_SCHEMA_VERSION each live
beside the verdict-producing code they version, not inside the declaration
or the loader, so a version bump and the code change it describes can
never drift apart. Three evaluators (draft_quality, claim_admissibility,
and canonical_truth) remain explicitly reserved future producers;
canonical-promotion runtime remains deferred. A version bump to any
producer constant and its PAA declaration reference must land together,
the same way a pipeline node’s Result[T, E] boundary and its caller must
agree on shape at every level of nesting.
Building on cohort 1’s declaration loader, this cohort adds the append-only
autonomy_events table (added v23, reshaped v33), paa_runtime.events’
typed event/position/status vocabulary, paa_runtime.evidence’s
content-addressed evidence store, and paa_runtime.service’s
propose/approve/reject/demote/resolve/show/
list workflows, exposed through scout paa. This is the same “typed edge,
fractal nesting” discipline the rest of this doc describes, applied to
autonomy state: current position is a pure fold over event history, never
a stored field. paa_runtime.service.resolve_current_position is the one
function that performs that fold — it loads the task’s declaration, reads
the latest position_changed event whose (task, declaration_version,
scope) triple matches exactly, and returns that event’s to_position or,
absent any such event, the declaration’s own initial_position. Nothing
else in the codebase is permitted to cache or shortcut that result; there
is deliberately no current_position column anywhere.
Exact-scope isolation and declaration-version reset are both consequences
of treating (task, declaration_version, scope) as one opaque compound
key rather than a hierarchy: two different scope strings under the same
task are fully independent — position earned under one never leaks to the
other — and a declaration version bump resets a task to its new
initial_position rather than inheriting a prior version’s earned
position — the same “no wildcard, no parent/child, no fallback” boundary
discipline PRODUCER_REGISTRY’s exact-tuple matching already applies to
evaluator identity, applied here to autonomy authority.
paa_runtime.service.approve is the one place a position actually changes, and it
is the clearest illustration of this cohort’s core invariant: authorization
and effect commit together or not at all. It runs inside one
Db.begin_immediate() transaction that re-verifies the proposal’s evidence
bytes against their recorded hash, reloads the declaration fresh (failing
if its version has drifted since the proposal), re-checks the transition
against that declaration’s exact promotion or demotion, and compares the
proposal’s exact position-event baseline with the latest event under the
lock. Any intervening change makes the proposal stale even if later events
cycle the scope back to the same position — only then does it insert
motion_approved and position_changed together. Every event type carries
non-null transition, evidence, actor, and reason identity; rejection copies
the proposal identity rather than storing a partial row. Idempotency is
deliberately narrow: a
retry is safe only when both sibling events already exist and exactly
match the proposal; any partial or contradictory combination (one event
without its sibling, or a rejection alongside an approval) is surfaced as
corrupt history rather than silently resolved, mirroring this doc’s
insistence elsewhere that a pipeline’s failure mode stay legible rather
than papered over. See docs/runbooks/paa-operations.md for the full
operator procedure and paa_runtime.service’s module docstring for the
complete state-machine contract.