Viska · n8n-client-viska-ai · ViskaN8N seat

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-24 chain ACTIVE · cron 05:00Z 30 nodes · versionId 32f93e26 first trigger run: exec 53582 → halt qc (by design) → re-run 53781

1System map

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)
W1 ingest engine
15-min cron · docling parse · Compose OKF Parcel · Supabase RPC upsert · store write + /Backup export
QhwpQOcOCYWHH4D2
S2 reviewer
on-store trigger (no cron) · LLM review — gemini-3.7-flash primary, luna fallback · Merge Review via parcel_writer
XohCrt2IIuJPJ0HJ
Parcel store
/files/w1-parcels/incoming/<h20>.okf.md — record of record (ruling 2026-08-22)
Pre-dawn — 04:00Z
Backup mirror
Copies /Current day PDFs → /Backup by membership diff, never folder presence (#196). /Backup is the only space where public links mint.
HZ2VApZt8GVK37WV · 04:00Z
Daily heartbeat — 05:00Z Reykjavik (=UTC)
Signal-graph daily chain
resolve → preflights → sync → 0b mint1a import → declares → qc gate3 conviction4b land PR → 5 → 6a load6b deliver → verdict
surqRbwaJYHx9GL9 · 30 nodes · errorWorkflow: Error Sentinel Z6tlnXRbPCqyZqlf
flex-apply-svc (Railway)
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

WorkflowIDTriggerRole
W1 ingest engineQhwpQOcOCYWHH4D2cron /15 minPDF → OKF parcel; store write; Supabase RPC okf_parcel_upsert_s1; .okf.md export to /Backup. Writes origin_url: '', origin_ref: /Current/….
S2 reviewerXohCrt2IIuJPJ0HJon-store (executeWorkflowTrigger)LLM review; quotes/narratives/findings; Merge Review through parcel_writer (enrich, conformance-gated). No cron — no collision with the chain.
Backup mirrorHZ2VApZt8GVK37WVcron 04:00Z/Current → /Backup day copy by membership diff. Makes the day mintable.
Daily chainsurqRbwaJYHx9GL9cron 05:00ZThe heartbeat. Legs below.
Origin URL mint8uhLTBmQDcs2Yti1webhook GET + sub-workflowMints public Dropbox links on /Backup paths, writes origin_url onto STORE records, mirrors merged record beside the PDF.
Re-drive loadJUOqd1sVsovWoxULwebhook GETLeg 6a replay for a batch whose payload changed on origin/main (replace/dry flags).
Error SentinelZ6tlnXRbPCqyZqlferror triggerFires 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

Phase A — resolve + preflights (~1 s total)

#NodeWhat it runsGreen means / red means
1resolve · 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.
2preflight · 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.
3preflight · 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.
4preflight · 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)

#NodeWhat it runsNotes
5leg 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.
6leg 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.
7leg 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.
8leg 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-GATINGcontinueRegularOutput; links are not the morning report. ~1.8 s/record (65 records ≈ 2 min). Verdict line reports selected · merged · refused or the error.
9leg 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.

Phase C — readiness gates (~30 s)

#NodeWhat it runsRed means
10leg 1b · conformance parcel_conformance.py --parcels research/parcels/broker — every parcel structurally sound (frontmatter, identity keys, edits ledger). A record was hand-mangled or a writer broke protocol.
11leg 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.
12leg 1b · substance check_review_substance.py --batch-date <batch> — reviews carry real substance (findings/quotes/narratives thresholds). S2 emitted hollow reviews.
13leg 2b · gate self-check check_dispatch_contract.py --self-check — same mutation-proof pattern. Gate broken.
14leg 2b · dispatch contract check_dispatch_contract.py --batch <batch> — the day's dispatch artifacts match contract. Contract drift.
15legs 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)

#NodeWhat it runsNotes
16leg qc · quotes GATING 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.
17leg 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)

#NodeWhat it runsNotes
18leg 4b · emit (publish_graph, bare) publish_graph.py — bare, no arguments (the invariant preflight #2 guards); writes research/payloads/graph-<batch>.json.
19leg 4b · regression gate check_reemit_no_regression.py — a re-emit must not degrade an already-published day.
20leg 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.
21leg 5 · delivered check_day_delivered.py --batch-date <batch> — the day's delivery preconditions hold.

Phase F — load + deliver (~1 min)

#NodeWhat it runsNotes
22leg 6a · read payload Inline python: load graph-<batch>.json, assert payload.batch_date == batch, capture git rev-parse HEAD, print ONE JSON body: {batch_date, payload, source_sha, dry_run: false}. stdout is the exact HTTP body — no reshaping downstream.
23leg 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).
24leg 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.
25leg 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

#NodeBehaviour
26verdict (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."
27halt · 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)WhereEvidence
+0–1 sresolve + preflights ×3exec 53582: resolve 52 ms, preflight #1 454 ms
+1 s – ~2.5 min0 sync · 0b mint · 1a import0b ≈ 1.8 s/record paced (65 records ≈ 2 min); 1a 224 records in seconds
~+3.5 minqc verdict in handexec 53582 halted here at 3 m 40 s
+4 – ~60 minleg 3 convictiondominant leg (inferred: 64-min total, exec 53062, minus everything else)
~+60–65 min4b emit/gate/land · 5 · 6a · 6b · verdictexec 53062 total 64 m 40 s

Key leg mechanics

Leg ownership — who gets the red line

Red atOwnerMeaning
0 / 0b / mirror / chain plumbingViskaN8NWorkflow, Dropbox, pacing, deploy issues
1a / qc / 3 / 4b (content)ViskaResRecord content, quotes, conviction scripts, payload contract
6a route / digest / SupabaseViskaDBflex-apply-svc load path
6b credentials / deliveryViskaOpsDelivery-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)

WhenWhatExpected
continuous /15 minW1 ingest ticksgreen; new parcels appear in store; S2 reviews follow on-store
04:00Backup mirrorsuccess in seconds with missing: [] (skip) or a small diff_copy. Any other shape = regression on #196.
05:00Daily chain~65 min run; verdict node prints one line per leg
~06:05Verdict readall 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

Re-running a batch

6Failure playbooks (each one already exercised)

SymptomDiagnosisAction
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: 422Digest 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 skipMembership-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.

7Manual tools

ToolCallNotes
Mint (dry / live)GET /webhook/origin-url-mint?batch=…&dry=1&days=NReceipt separates has_url / minted / refused-with-reason. ~1.8 s/record with pacing.
Re-drive loadGET /webhook/signal-graph-redrive-load?batch=…&replace=1Leg 6a replay only; same auth + digest semantics.
Store record synctemp workflow patternWhen 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 deploynpm run validateN8N_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)