Signal Graph Automation — Engineering Plan & Runbook
End-to-end design of the daily broker-research pipeline: ingest → review → mint →
import → QC → conviction → land → load → deliver. One unattended chain, gated where content is
load-bearing, non-gating where it is not, with every failure routed to a named owner.
as of 2026-08-24chain ACTIVE · cron 05:00Z30 nodes · versionId 32f93e26first trigger run: exec 53582 → halt qc (by design) → re-run 53781
Four producers feed one consumer chain. Everything left of the chain runs continuously or
pre-dawn; the chain itself is the single 05:00Z heartbeat that turns yesterday's records into a
rendered, delivered signal graph.
Continuous — ingest & review
Dropbox /Current
Broker PDFs land in the team space (link-mint refused here by policy)
POST /load-signal-graph — digest-checked upsert + server read-back
→
viska.gg
Signal graph renders; morning report delivered (daily_close.py --deliver-only)
Design invariant: report for batch D runs on morning
D+1. resolve · batch date = yesterday. The chain never writes the store on its
own initiative — it reads records, mints links onto them via the record protocol, and lands
derived artifacts through a PR.
2Workflow inventory
Workflow
ID
Trigger
Role
W1 ingest engine
QhwpQOcOCYWHH4D2
cron /15 min
PDF → OKF parcel; store write; Supabase RPC okf_parcel_upsert_s1; .okf.md export to /Backup. Writes origin_url: '', origin_ref: /Current/….
S2 reviewer
XohCrt2IIuJPJ0HJ
on-store (executeWorkflowTrigger)
LLM review; quotes/narratives/findings; Merge Review through parcel_writer (enrich, conformance-gated). No cron — no collision with the chain.
Backup mirror
HZ2VApZt8GVK37WV
cron 04:00Z
/Current → /Backup day copy by membership diff. Makes the day mintable.
Daily chain
surqRbwaJYHx9GL9
cron 05:00Z
The heartbeat. Legs below.
Origin URL mint
8uhLTBmQDcs2Yti1
webhook GET + sub-workflow
Mints public Dropbox links on /Backup paths, writes origin_url onto STORE records, mirrors merged record beside the PDF.
Re-drive load
JUOqd1sVsovWoxUL
webhook GET
Leg 6a replay for a batch whose payload changed on origin/main (replace/dry flags).
Error Sentinel
Z6tlnXRbPCqyZqlf
error trigger
Fires on any chain error; the halt is loud, never silent.
3The daily heartbeat — node-by-node
30 nodes on the deployed chain (surqRbwaJYHx9GL9, versionId 32f93e26,
read from origin/main — the branch copy is pre-0b; always name the ref). Two triggers converge on
one linear spine; every executable stage is wired to a single halt node. Numbering below is
execution order.
Execution topology
Triggers:Cron 05:00 Reykjavik (0 5 * * *, tz Atlantic/Reykjavik = UTC)
and Manual Trigger — both feed resolve · batch date. A manual run is
byte-identical to a cron run except for the trigger node.
Wrapper contract (every Execute Command node):out=$({ … } 2>&1); rc=$?; [ rc != 0 ] && printf … >&2 && exit rc —
all output captured; on failure the full text is re-emitted on stderr, because n8n keeps
only stderr on the error branch (probe exec 52793).
Halt topology: 16 stages carry onError: continueErrorOutput → error
branch → halt · print + stop, which throws
HALT at <leg>\n<refusal text> (first 6000 chars). The n8n execution log +
Error Sentinel are the red verdict surface. The only exception is leg 0b:
continueRegularOutput — a mint failure flows forward as data, never halts.
Phase A — resolve + preflights (~1 s total)
#
Node
What it runs
Green means / red means
1
resolve · batch date(code)
The single point a date enters the pipeline. batch = typed override (flagged
with a warning — the Dropbox folder is its only authority) or now − 86400s UTC,
regex-asserted YYYY-MM-DD. Emits {batch, report, warn, clocks};
report = batch + 1d. Every later leg references this node — no second clock exists.
Bad shape throws immediately; nothing downstream ever re-derives a date.
2
preflight · publish invocation bare
check_publish_invocation.py — asserts .github/workflows/daily.yml
invokes publish_graph.py bare (no args), the invariant leg 4b (#19) depends on.
Red = the publish contract drifted; halt before touching anything.
3
preflight · contract liveness
docs/contracts/hrafn-reviewer-profile.md must exist and not carry a
RETIRED/SUPERSEDED header (checked in first 40 lines); prints its sha256.
Red = the reviewer contract the whole day is judged against is gone or retired.
4
preflight · seeing control
Counts sha1:*.md in research/parcels/broker. Zero → REFUSE
("the runner is blind; any verdict would be about nothing"). Warns if
research/broker-docs/ is unexpectedly present (hosted-blind routing check).
The positive control: proves the runner can see before it judges. Counts the
pre-import checkout — nonzero because past days' parcels are tracked.
Phase B — ground the day (~2–3 min)
#
Node
What it runs
Notes
5
leg 0 · FOLDER (seat) — declare
Echo-only declaration: check_folder_complete.py --batch-date <batch> is
owed at the seat — the runner cannot see ~/Library/CloudStorage/Dropbox/Backup/….
"The folder IS the artifact; cards, chart and popup are projections of it."
Declared, not skipped silently — the verdict carries the debt.
6
leg 0 · sync checkout
git fetch origin +refs/heads/main:refs/remotes/origin/main && git reset --hard
origin/main in /files/ViskaRes; prints checkout = origin/main
<sha> + first 5 porcelain lines.
Consumer, never producer. Exists because land_payload_pr.py resets only on
merge days — without this, a no-change day would run every leg on stale code. Untracked store
imports survive the reset; tracked rewrites are discarded on purpose.
7
leg 0b · mint input(code)
Builds the webhook-shaped sub-workflow input:
{query: {batch, days: '3'}}.
3-day window = self-healing links: a /Backup copy the mirror missed one morning is minted
the next, not never.
8
leg 0b · origin urls (mint)(executeWorkflow)
Execute Workflow → 8uhLTBmQDcs2Yti1, waitForSubWorkflow: true.
Inside: select store records lacking origin_url in window → create/list shared
link per /Backup path (paced batchSize 1 / 400 ms, 3 retries — Dropbox 429s ~15-call bursts) →
resolve to the operator link form (scl/fi+rlkey, dl=0) →
parcel_writer enrich onto the STORE record → export merged .okf.md beside the PDF.
NON-GATING — continueRegularOutput; links are
not the morning report. ~1.8 s/record (65 records ≈ 2 min). Verdict line reports
selected · merged · refused or the error.
9
leg 1a · import store parcels
import_store_parcels.py --src /files/w1-parcels/incoming — store records
copied byte-verbatim into research/parcels/broker; differing repo copies
overwritten.
Baseline: written N · unchanged N · unreviewed ≤2 · refused 0. Runs AFTER 0b
so imported copies already carry urls.
A record was hand-mangled or a writer broke protocol.
11
leg 1b · substance self-check
check_review_substance.py --self-check — the gate proves it can FAIL before
it is trusted to pass (mutation-proof discipline).
The gate itself is broken; its green would be worthless.
12
leg 1b · substance
check_review_substance.py --batch-date <batch> — reviews carry real
substance (findings/quotes/narratives thresholds).
S2 emitted hollow reviews.
13
leg 2b · gate self-check
check_dispatch_contract.py --self-check — same mutation-proof pattern.
Gate broken.
14
leg 2b · dispatch contract
check_dispatch_contract.py --batch <batch> — the day's dispatch
artifacts match contract.
Contract drift.
15
legs 2+4 · COVERAGE+PLOT (seat) — declare
Echo-only: check_batch_coverage.py + check_plot_payload.py read
research/broker-docs/*.meta.json — gitignored (5,077 files), absent on the
runner. Owed at the seat.
—
Phase D — content gates (the long middle)
#
Node
What it runs
Notes
16
leg qc · quotesGATING
check_quotes.py --batch-date <batch> — every quote in every reviewed
parcel of the batch must be a contiguous byte-identical span of its source body. Normalization
is deliberately minimal: NFKC, curly→straight within each quote class, dash fold, whitespace
collapse. Single↔double substitution, dropped words, stitched spans all fail.
Halted the first trigger run (exec 53582: 2/72). The halt IS the product working — an
unverbatim quote one click from the client falsifies the Hrafn contract.
17
leg 3 · conviction (emit + check)
emit_conviction_payload.py --report-date <batch> then
check_conviction_payload.py research/analysis/<batch>/conviction-payload.json
— emit and immediately verify.
The dominant cost: ~55–60 min of the ~65-min wall time (inferred from run totals; the
remaining legs account for <5 min).
Phase E — publish (~1–2 min)
#
Node
What it runs
Notes
18
leg 4b · emit (publish_graph, bare)
publish_graph.py — bare, no arguments (the invariant preflight #2 guards);
writes research/payloads/graph-<batch>.json.
—
19
leg 4b · regression gate
check_reemit_no_regression.py — a re-emit must not degrade an
already-published day.
—
20
leg 4b · land the delivery (PR)
land_payload_pr.py --batch <batch> — branch, PR, merge to
origin/main.
The payload consumed by 6a is the LANDED one — the checkout is now at/behind the merge;
6a re-reads from disk + records git rev-parse HEAD as provenance.
21
leg 5 · delivered
check_day_delivered.py --batch-date <batch> — the day's delivery
preconditions hold.
stdout is the exact HTTP body — no reshaping downstream.
23
leg 6a · load (flex-apply-svc)(httpRequest)
POST https://flex-apply-svc-production.up.railway.app/load-signal-graph,
bearer header cred (same as /apply), jsonBody = {{ $json.stdout }} RAW —
re-serializing the payload broke the digest once (every payload 422'd). Timeout 180 s.
200 = upsert committed + server read-back · 422 = digest does not reproduce · 409 =
DIGEST MOVED (never auto-replace from the chain).
24
leg 6a · loaded?(code)GATING
Green ONLY on ok === true && read_back === true (DB contract,
ViskaRes#410). A 200 without read-back is not a load — the store must have read its own digest
back. Emits {state, batch_date, content_digest, nodes, edges, source_sha}.
state: NEW first load · UNCHANGED idempotent re-run.
25
leg 6b · deliver
daily_close.py --deliver-only --batch-date <batch> — the delivery row,
nothing else (no re-close of the day).
—
Phase G — verdict / halt
#
Node
Behaviour
26
verdict(code)
Success-path only: one line per leg incl. the two seat-declared debts
(0 · FOLDER, 2+4 · COVERAGE/PLOT), CHECKPOINT: not built
(phase 4), 6 · CONFIRM: owed at DB, and the live 0b mint line
(selected · merged · refused or not run). "A red leg is the gate
working."
27
halt · print + stop(code)
Shared error sink for all 16 gating stages. Reads the failed node's stderr (the wrapper
re-emitted the full gate output there), throws HALT at <leg> + refusal
text. Execution goes red → Error Sentinel (Z6tlnXRbPCqyZqlf) fires.
Measured timing profile
Clock (from 05:00:00Z)
Where
Evidence
+0–1 s
resolve + preflights ×3
exec 53582: resolve 52 ms, preflight #1 454 ms
+1 s – ~2.5 min
0 sync · 0b mint · 1a import
0b ≈ 1.8 s/record paced (65 records ≈ 2 min); 1a 224 records in seconds
~+3.5 min
qc verdict in hand
exec 53582 halted here at 3 m 40 s
+4 – ~60 min
leg 3 conviction
dominant leg (inferred: 64-min total, exec 53062, minus everything else)
~+60–65 min
4b emit/gate/land · 5 · 6a · 6b · verdict
exec 53062 total 64 m 40 s
Key leg mechanics
0 · SYNC — the checkout is a consumer, never a producer: git fetch +
reset --hard origin/main. Untracked store imports survive; tracked rewrites are
discarded on purpose. (Trap: land_payload_pr.py only resets on merge days — the
chain resets every day so no-change days never run on stale code.)
0b · MINT — Execute Workflow into 8uhLTBmQDcs2Yti1, waits, continues on
error. Window = batch + 2 prior days, so a /Backup copy the mirror missed one morning is minted
the next, not never. Runs after the mirror (by cron ordering) and before 1a so the
imported repo copy already carries the url.
1a · IMPORT — import_store_parcels.py copies store bytes verbatim into
the repo checkout; a differing repo copy is overwritten. This is why origin_url
must be written on the STORE record, never repo-side.
qc — check_quotes.py: every quote must be a contiguous, byte-identical
span of its source body (whitespace collapse + unicode fold only; single↔double quotes are NOT
folded — deliberate). Fails → chain halts, Error Sentinel fires, nothing renders.
6a · LOAD — jsonBody = the leg's stdout verbatim (re-serialization broke the
digest once: every payload 422'd). 200 = upsert + server read-back; 409 DIGEST MOVED is never
auto-replaced from the chain — replace is a human decision through the re-drive tool.
Leg ownership — who gets the red line
Red at
Owner
Meaning
0 / 0b / mirror / chain plumbing
ViskaN8N
Workflow, Dropbox, pacing, deploy issues
1a / qc / 3 / 4b (content)
ViskaRes
Record content, quotes, conviction scripts, payload contract
6a route / digest / Supabase
ViskaDB
flex-apply-svc load path
6b credentials / delivery
ViskaOps
Delivery-side auth
4Data contracts that hold this together
The OKF record
<h20>.okf.md — JSON-typed frontmatter + body. Identity keys
(revision, content_hash, edits[], last_edited)
advance on every write. All edits go through parcel_writer.write()
(enrich/replace modes, OKF §7 actor, conformance gate, verify(), tmp-then-os.replace).
Hand-editing a record is a protocol violation, not a shortcut.
The link form
Operator-validated only: scl/fi + rlkey, dl=0 forced.
Minted on /Backup paths re-rooted from each record's /Current origin_ref. Anything
else is refused and reported, never written.
The graph payload
research/payloads/graph-<batch>.json on ViskaRes origin/main, landed by leg
4b's PR. Loaded byte-verbatim; digest computed server-side; read_back: true is the
only success.
Webhook argv hygiene
Every public-webhook query value is re-derived to a fixed shape in the expression before it
can reach shell argv (/^\d{4}-\d{2}-\d{2}$/ for dates, whitelists for flags).
Proven with batch=$(id) → bad batch 'INVALID'.
5Runbook — the daily read
Timeline (all UTC; Reykjavik = UTC)
When
What
Expected
continuous /15 min
W1 ingest ticks
green; new parcels appear in store; S2 reviews follow on-store
04:00
Backup mirror
success in seconds with missing: [] (skip) or a small diff_copy. Any other shape = regression on #196.
05:00
Daily chain
~65 min run; verdict node prints one line per leg
~06:05
Verdict read
all legs green; graph for yesterday live on viska.gg; delivery row present
Reading the verdict
0 · SYNC checkout = origin/main <sha>
0b · ORIGIN URLS (mint, non-gating) selected N · merged N · refused 0
1a · IMPORT written N · unchanged N · unreviewed ≤2 · refused 0
qc N quotes across M parcels — PASS
3 · CONVICTION …
4b · LAND PR #N merged
6a · LOAD state NEW|UNCHANGED · read_back true
6b · DELIVER one delivery row
Healthy baselines:refused 0 everywhere; unreviewed ≤ 2
(shells awaiting S2); 6a state: NEW on first load of a batch,
UNCHANGED on idempotent re-runs.
Checking an execution:n8n_executions list workflowId=surqRbwaJYHx9GL9;
on error, get <id> mode=error names the halting leg and quotes the decisive
line. Node data is invisible while a run is in flight — status only.
Re-running a batch
Full chain re-run (after a content fix): execute the chain workflow directly
(production mode). Legs before the gate are idempotent — 0b skips has_url, 1a
reports unchanged, 4b lands only diffs. resolve computes batch =
yesterday, so a re-run on day D+1 still targets batch D.
Load-only replay (payload changed on origin/main, graph already loaded): re-drive
webhook — GET /webhook/signal-graph-redrive-load?batch=YYYY-MM-DD[&replace=1][&dry=1].
replace is the explicit human override for 409 DIGEST MOVED.
Mint-only: GET /webhook/origin-url-mint?batch=YYYY-MM-DD[&dry=1][&days=N][&all=1].
dry=1 resolves links, writes nothing — the standing readiness control.
6Failure playbooks (each one already exercised)
Symptom
Diagnosis
Action
Halt at qc · quotes, "NOT VERBATIM"
S2 wrote a paraphrase/stitch, or substituted quote characters (single↔double is not folded). Verify locally with the gate's own normalize() against the /Backup .okf.md export.
Route to RES with sha1/qN + measured evidence. RES fixes the record (strip or replace with the true span — no model spend needed); sync fixed bytes into the STORE (not the repo); re-run chain. Exercised 2026-08-24: exec 53582 → fix → re-run 53781.
0b refused > 0, reason "Try spacing your requests out"
Dropbox sharing 429s at ~15-call bursts.
Pacing is built in (batchSize 1 / 400 ms + 3 retries). Non-gating — links self-heal inside the 3-day window; re-run mint later if needed.
0b refused, reason path/not_found
/Backup copy missing — mirror hasn't copied that PDF.
Check 04:00Z mirror execution; restore the day; the 3-day window mints it tomorrow.
6a non-200: 422
Digest does not reproduce server-side — payload bytes were re-serialized somewhere.
jsonBody must be the raw stdout string. If payload legitimately changed: re-drive with replace=1 after human review. 409 = DIGEST MOVED — never auto-replace from the chain.
Mirror run copies when it should skip
Membership-diff regression (#196).
Compare missing[] against /Backup listing; route to N8N.
npm run deploy aborts "version conflict"
Remote versionId moved after another deploy.
Pin repo file's versionId to remote (or npm run sync), re-deploy, re-pin.
GET /webhook/signal-graph-redrive-load?batch=…&replace=1
Leg 6a replay only; same auth + digest semantics.
Store record sync
temp workflow pattern
When a record must be fixed out-of-band: Dropbox-node pull → readWriteFile to a staging dir → executeCommand with sha1-only argv (parcel_id match, revision-advance, conformance, verify, os.replace). Never push record bytes through argv (E2BIG at 128 KB) and never hand-edit the store.
GitOps deploy
npm run validate → N8N_ALLOW_MUTATIONS=1 npm run deploy -- --yes <kw> → node scripts/activate-workflow.js <id>
Repo JSON is the source; main is PR-only.
8Instance traps (paid for once, written down)
Execute Command runs ONCE per execution (executeOnce: true): N items in →
1 run. Batch work goes through one base64 argv (pack/unpack) — and if the payload can exceed
~128 KB, not through argv at all (Linux MAX_ARG_STRLEN → spawn E2BIG).
/Current refuses public links (settings_error/not_authorized) — mint on
/Backup, always. This is why the mirror is load-bearing, not a convenience.
Credential scopes differ per credential: the HTTP OAuth cred is sharing-scoped (no
files.content.read); content download/upload goes through the Dropbox-node cred.
MCP test client times out at 120 s; the execution continues server-side — read the
execution log, don't re-fire.
A url written repo-side only is reverted by the next 1a import — store record or
nowhere.
Verify landings by content (git diff branch origin/main empty), never by
ancestry — rebase rewrites SHAs.