Forms 2.5(c) — Submission Routing Pipeline (Design)¶
Status: Decisions locked — implementation in progress (migration 011 applied 2026-06-10)
Intended location: mkdocs-portal/docs/architecture/forms-phase2.5c-design.md
Version: v0.3
Date: 2026-06-10
Author: R. Chhetry / Claude (chat design pass)
Grounded in: Code read-only investigation 2026-06-04 (routes_phase2.py, 001_init.sql, 002_rls.sql, gpus-reports/, live enum query). All schema/enum claims below are verified against the live DB unless marked [CODE-TIME CONFIRM].
Changelog:
v0.3 (cont., Gate 4 implementation) — G4.0 decisions recorded (2026-06-12): MAPLE runtime = python3.11 (AppStream) + venv /opt/gpus-forms-routing/venv (system python is 3.6.8 — collision C1); signed URLs enabled via two standing bindings on maple-agent (storage.objectViewer on the attachments bucket + self-tokenCreator for IAM signBlob; flagged into Gate-5 IAR + admin-review scope), TTL 7d, signed URL = bearer capability for the TTL (§16.2 as-built); Slack reuses gpus-forms-clamav-slack-webhook (accessor grant, zero new secrets); §9 claim as-built: fused atomic claim + full readiness predicate + claimed_at stamped into routing_result (submissions has no updated_at — collision C6); §7 subject fallback [GPUS Forms] <form> — submission <id8> is the always-path until 2.5(d) (subject_template NULL on 100% of live actions — collision C5); §11 sweep as-built: in-process tick in the pull daemon (MAPLE has no inbound HTTP), DLQ drained by pull with retries-exhausted terminal; worker home gpus-forms-routing-worker/ (gsutil deploy + commit-hash VERSION drift guard); migration 013 = GRANT forms_app TO "maple-agent@gpus-infra.iam" (§16.4 as-built mechanism); ackDeadline 60→120s; infected terminal audited as submission_route_failed reason=infected_attachment. First drain runs with FORMS_ROUTING_RECIPIENT_OVERRIDE set (override = permanent drill tool, flipped off at G4.4).
v0.3 (cont., Gate 3 implementation) — G3.0 decisions recorded: C1 GET endpoint created in Gate 3 (§3a wording corrected — no GET previously existed); C2 finalize marker = routing_result seed + submitted_at re-stamp; C3 full readiness predicate at finalize; C4 α publishes unconditionally on clean (no grants migration; Gate-4 worker is sole authoritative evaluator); C5 migration 012 submission_finalized (enum 30 → 31, applied 2026-06-10, §16.6 amended). purged → 410 DECIDED. G3.1 infra live: topic gpus-forms-submission-ready, pull sub gpus-forms-routing-worker-sub (7d retention, never-expire, ack 60s), publisher bindings for backend + α SAs.
v0.3 — new §6a: routing configuration schema documented as-built (the existing actions: block in forms/*.yaml is canonical; no new routing.destinations schema). Grounded in the 2026-06-10 legacy-routing read-only investigation (Gate A) + YAML↔live-PG verification. Migration 011 applied to live DB 2026-06-10 (enum 26 → 30, commit 4bdfdf9). New §3a: GET wire-status moves to the honest 4-value vocab, _status_to_wire collapse retired (zero consumers verified — SPA has no getSubmission, never reads status at runtime, deployed bundle = f175810 = HEAD); purged handling left OPEN (404/410 vs wire value — R.C. before Gate 3). New §3b: finalize response contract made async-honest ({id, status: "received", submitted_at, email_status: "queued"}); Submitted.tsx queued branch MANDATORY in Gate 3 (else every submission falsely shows "Sent"). New §17: coverage/ops-triad — routing worker as standalone cloud_services entry (type: host_resident_service, host: maple, monitoring_intent: pubsub_dlq_alerting; both values new to the coverage standard, standard edited in the same commit) + α five-entry backfill (worker, ingest topic, DLQ, push subscription, sweep cron). Consistency: §1/§5 HappyFox dispatch relabelled "Phase 3" → "2.5(e)"; §16.5 marked resolved → §3a/§3b.
v0.2 — all §16 open questions resolved to recommended decisions (R.C. delegated, 2026-06-04). Transport = B-iii (MAPLE-resident). Attachments = signed URL. Interim body = minimal plaintext, not gated behind 2.5d. DB role = reuse forms_app. Audit = lean (4 new values). One code-time fact confirm remains (§3 wire mapping).
§1 Purpose¶
Routing is what happens to a submission after it is finalized and (if it has attachments) all attachments pass ClamAV scanning. A clean, complete submission must be delivered to one or more destinations defined per-form. 2.5(c) builds the dispatch abstraction: it reads the form's ordered actions rows, executes each one against the right destination, drives the submission's lifecycle state, and records the outcome.
2.5(c) is deliberately not 2.5(e). 2.5(e) is the HappyFox API client (the Phase 3 track in the priorities tracker). 2.5(c) is the orchestration layer that HappyFox plugs into as one more action_type — the dispatch loop, idempotency, state machine, audit, retry, and DLQ are all built here once. Today's in-scope destination is email via the existing Postfix→Gmail relay. happyfox_template actions are recognised by the loop but their dispatch is a deferred stub until 2.5(e).
Scope boundary with 2.5(d): 2.5(c) selects the template (actions.template_id) and passes it to the send step. 2.5(c) does not render bodies — that is 2.5(d). Decision: 2.5(c) email send is not gated behind 2.5(d). Until 2.5(d) ships, routed email uses a minimal plaintext body (§7) so the full pipeline is shippable and testable end-to-end now; 2.5(d) later swaps the body without touching routing.
§2 Architecture — service shape (DECIDED)¶
The investigation surfaced the constraint that drove this decision: the proven, zero-auth mail path is smtplib.SMTP("localhost", 25) against Postfix on MAPLE (report_mailer.py), and Postfix on MAPLE holds the Gmail SASL relay credentials. forms-backend runs on Cloud Run and cannot reach localhost:25 on MAPLE. So the choice is two stacked decisions — both now resolved.
Decision A — orchestration: Pub/Sub pull worker (your (c) instinct, kept). Claim row → iterate actions → drive state → audit, with ack-then-process and a stuck-row sweep, exactly parallel to α.
Decision B — transport: B-iii — the routing worker runs on MAPLE. It subscribes to the routing topic as a Pub/Sub pull subscriber (or, if pull proves awkward, a DB-poll daemon mirroring report_cron.sh) and sends via localhost:25 exactly like report_mailer.py.
Rationale (locked): B-iii reuses the single most load-bearing proven pattern — the exact mail path that already works — and adds zero new secret (no duplication of MAPLE's Gmail credential), keeps MAPLE's Postfix locked to localhost (no relay exposure to the VPC), and adds no new class of dependency (MAPLE is already critical-path: Wazuh, Prometheus, report cron, DB jump). The accepted cost: the worker is a MAPLE-resident systemd service rather than serverless, so it needs its own monitoring and the standard IR/DR/drill triad on MAPLE (tracked below).
Rejected alternative (for the record): B-i — a Cloud Run worker doing authenticated SMTP submission. Cleaner α parallel and fully serverless, but it duplicates MAPLE's Gmail credential into Secret Manager (double rotation burden) and adds an egress-auth surface. The zero-new-secret reuse argument won.
§3 Trigger — when routing fires¶
Readiness is: submission persisted AND (no attachments OR every attachment clamav_status='clean'). Attachments scan asynchronously via α, so readiness can be reached at two moments:
- No attachments / already-clean at finalize: readiness passes inside
finalize_submission. - Attachments still scanning at finalize: readiness is reached later, when α marks the last attachment clean.
Design: a single evaluate_routing_readiness(submission_id) predicate invoked from both seams — from finalize_submission after it persists, and from the α worker after it flips an attachment to clean. Whichever caller observes "all clean" publishes a routing Pub/Sub event. Double-publish is safe (idempotent claim, §9). Any attachment infected → submission goes to failed, never routes (IR per rb-008).
finalize_submission (routes_phase2.py:412–435) is currently a pure stub that persists nothing. So 2.5(c) also owns finalize's first real write: transition received → processing [CODE-TIME CONFIRM: actually finalize persists received-state then publishes; the worker performs the received→processing claim — keep the claim in the worker, §9], then publish readiness. The # TODO Phase 2 wire-up block (line 418) is the hook seam; _audit_v2() (line 75) and the Session(get_engine()) pattern already exist there.
~~[CODE-TIME CONFIRM (§16.5)]~~ Resolved 2026-06-10 — see §3a/§3b below. The code-time read found the current mapper collapses the 5 enum values to {draft, submitted} and that finalize's "submitted" is a hardcoded stub literal, not a mapper output.
§3a Wire-status contract (resolved 2026-06-10) — v0.3 amendment¶
The wire status vocabulary is the honest 4-value vocab {received, processing, routed, failed} — the real submission_status enum, minus purged. The _status_to_wire 2-value collapse (received/processing → "draft", everything else → "submitted") is retired in Gate 3.
Correction (G3.0 read-pass): no GET /api/submissions/<id> endpoint previously existed — the 2-value collapse's only caller was create_submission's response. Gate 3 creates the GET endpoint carrying this contract (G3.0 decision C1); the create response moves to the raw vocab in the same change.
Safe because zero consumers exist: the SPA's API client has no getSubmission method, no runtime code reads a submission status value anywhere (SubmissionStatus at contract.ts:148 is a compile-time-only type), and the deployed bundle is repo HEAD (f175810 — verified against the live Cloud Run revision 2026-06-10). contract.ts:148 is updated to the 4-value union in the same Gate 3 change to keep tsc honest.
DECIDED (2026-06-10) — purged answers HTTP 410 Gone on GET, not a wire status value (the user must never see a purged row as live). Finalize on a purged row also answers 410. Wire error code: gone.
Finalize marker (G3.0 decision C2): status alone cannot distinguish a draft from a finalized submission — create_submission already persists 'received', and submitted_at is NOT NULL DEFAULT NOW() (stamped at creation). Finalize therefore seeds routing_result = {"queued_at": …} as the finalize marker (NULL = draft, non-NULL = finalized) and re-stamps submitted_at with the true submit time. The Gate-4 worker keys readiness on routing_result IS NOT NULL and extends the same JSONB with per-action outcomes (§9). The GET response exposes the marker as a finalized boolean alongside the 4-value status. Double-finalize answers 409 conflict.
§3b Finalize response contract (resolved 2026-06-10) — v0.3 amendment¶
The stub's synchronous outcome fields — email_sent_to, happyfox_ticket_id/_url, an outcome-bearing email_status — cannot be truthful under B-iii: at finalize time the routing worker hasn't run, so no recipient is resolved and nothing has been sent. New contract:
Recipient and ticket fields are absent/null at finalize time; their truth lives in the audit trail and (later) admin surfaces, not the finalize response.
SPA consequence — MANDATORY in Gate 3, not polish: Submitted.tsx renders the "Sent" success pill for any email_status ≠ 'failed', so under 2.5(c) every submission would falsely show "Sent". A queued branch on the existing pill--warning path ("queued · will retry" already exists for the failed case) must land with the finalize wire-up.
§4 State machine¶
The submission_status enum already exists with exactly the values needed — no status enum extension required:
received— created/finalized, not yet dispatched.processing— claimed by the routing worker (atomic transition, §9).routed— all actions dispatched successfully.failed— permanent failure, retries exhausted, or attachment infected.
Verified: enum values received, processing, routed, failed, purged (001_init.sql:34), live DB identical, no drift.
§5 Routing destinations¶
- email (in scope) —
action_type∈ {email_template,email_raw}, sent via the §2 transport. Live data:email_template= 35 rows / 25 forms;email_raw= 0 rows (supported, not optimised for). - HappyFox (2.5(e)) —
action_type='happyfox_template'. Recognised by the loop; dispatch is a deferred stub that recordshappyfox_status='deferred'and does not fail the submission. Live data: 32 rows / 12 forms.happyfox_ticket_id/happyfox_statuscolumns already exist for 2.5(e) output. - dead-letter — persistent-failure path (§8), parallel to α's DLQ.
§6 Per-form routing config¶
Already modelled — no schema change to actions. actions (001_init.sql:153–166):
id BIGSERIAL PK
form_id TEXT → forms(id) ON DELETE CASCADE
action_order INT NOT NULL -- execution sequence
action_type enum (happyfox_template | email_template | email_raw)
template_id TEXT → templates(id) -- body template (2.5d renders it)
destination TEXT NOT NULL -- recipient(s) for email
happyfox_category TEXT
subject_template TEXT
UNIQUE (form_id, action_order)
One row = one ordered routing step. Source of truth is YAML: yaml_loader.py does DELETE FROM actions WHERE form_id=… then bulk-inserts on form load — config edits flow through YAML reload (admin_reload_yaml audit action already exists), not direct DB writes. The routing loop reads via the Form.actions ORM relationship (models.py:43) ordered by action_order. No production code consumes actions for dispatch yet — that is what 2.5(c) adds.
§6a Routing configuration schema (as-built) — v0.3 amendment¶
DECIDED: the canonical per-form routing config is the existing actions: block in forms/*.yaml. No new routing.destinations schema is introduced.
Provenance: extracted from the legacy in_formfeed MySQL on 2026-04-22 by forms-backend/migrate/from_mysql.py (report: forms/_migration-report.md), and verified against the live PG actions table on 2026-06-10 — 35 email_template + 32 happyfox_template = 67 actions, YAML ↔ DB exact match.
Shape (one YAML action = one actions row, executed in list order):
actions:
- type: email_template # → action_type enum
destination: gpus-people@greenpeace.org, rchhetry@greenpeace.org
template: 73ec1d33a4c000d1 # → templates(id), body in forms/templates.yaml
- type: happyfox_template
destination: '92' # legacy numeric HappyFox queue/category id
template: 73ec1d33a4c000d1
happyfox_category: '92'
Facts the dispatch loop must honour:
- Multi-queue routing is the norm, not the exception. 9 of the 12 HappyFox-routed forms fan out to 2–5 queues (
new-employee-notificationandemployee-termination-notificationhit 5 each). Per-action isolation (§12) is load-bearing, not theoretical. - Templates are per-destination field subsets. 43 templates in
forms/templates.yaml; each{{ FieldKey }}-references a subset of the form's fields. The same form routes different field subsets to different destinations via differenttemplateids. - Email recipients are per-action lists — comma-separated in
destination— not per-form.
Consumption boundaries:
- The 2.5(c) worker consumes
email_templateactions (andemail_raw; 0 live rows). happyfox_templateactions are carried by the loop — recordedhappyfox_status='deferred', never failing the submission (§5) — and consumed in 2.5(e) (the HappyFox client integration).- 2.5(e) PRECONDITION (G4.4 discovery, 2026-06-12): some
email_templatedestinations are HappyFox email-ingest addresses — the Gate-4 close-out email togpus-it-support@greenpeace.orgauto-opened ticket #USITS00357259 via email-to-ticket. The worker dispatched no HappyFox action (verified:routing_result+ audit); the ticket is downstream mailbox plumbing. Consequence: once 2.5(e) dispatcheshappyfox_templatevia API, a form whose email leg also lands in a HappyFox-watched inbox will double-ticket (API + ingest). Email recipients and API destinations must be deconflicted per form before 2.5(e) ships. (The org moved to API dispatch historically because HappyFox blocks greenpeace.us email — the ingest path predates that and is still live.) Tracked in the priorities tracker (T3, blocks 2.5(e)). - Template rendering is 2.5(d); the §7 interim minimal-plaintext body stands until then.
- Per-destination audit emission unchanged:
email_sent/email_failedper action (§10).
Known 2.5(d) reconciliation backlog (forms/_migration-report.md:99-108): 6 templates with unresolved legacy placeholder tags, 13 sanitized field keys, 4 deduplicated field keys — template-to-field reconciliation is owed before rendered bodies replace the interim plaintext.
Action dedup (G4.4, 2026-06-12): the first live dispatch (dcf1edf5) exposed literal duplicate action rows in laptop-retrieval-notification carried over by the from_mysql extraction (queue-81 twice, identical email action twice → double delivery per submission). Deduped to the minimal distinct (type, destination, template) set (form v1 → v2); an all-forms duplicate-tuple sweep found no other affected form. Recorded in forms/_migration-report.md § Post-migration corrections. Same change-set: dhcp-request gained a permanent attachment field (v1 → v2) so an email-routed form can carry attachments (previously the only attachment-bearing form was the zero-actions data-request-form). Build-trigger fix in the same work: gpus-forms-backend-trigger now includes forms/** — YAML-only commits previously never rebuilt the backend, making config edits silently inert.
§7 Email payload composition¶
Mirror report_mailer.py: MIMEMultipart("mixed"), sender gpus-it-security@greenpeace.org, recipients from actions.destination, subject from actions.subject_template. Body rendering is 2.5(d); interim body is minimal plaintext (submission id + form name + "details to follow") per the §1 decision.
Effective sender (G4.4 as-built, DECIDED 2026-06-12): the worker (and report_mailer.py) submit envelope+header From gpus-it-security@greenpeace.org to local Postfix — verified in maillog — but the Gmail relay authenticates as alerts@greenpeace.us (SASL account in /etc/postfix/sasl_passwd) and rewrites the visible From to that account because gpus-it-security@greenpeace.org is not a registered send-as alias. Delivered sender for ALL MAPLE-originated portal/report mail is therefore alerts@greenpeace.us today — the worker is wire-identical to production reports, which is the §7 mirror intent. SPF/DKIM align for greenpeace.us via Google. FROM_ADDR stays gpus-it-security@greenpeace.org in code (consistent envelope with reports; no visible effect). Optional follow-up (out of 2.5(c) scope): register gpus-it-security@greenpeace.org as a send-as alias on the alerts@ Workspace account to restore the intended From for reports and routing alike.
Subject fallback (G4.0 C5, as-built): subject_template is NULL on 100% of live actions (only the _schema.yaml example carries it; live forms express subjects via custom_subject fields, whose {{ FieldKey }} rendering is 2.5(d) and requires field decryption). The worker's fallback — [GPUS Forms] <form name> — submission <id8> — is therefore the always-path until 2.5(d) ships.
2.5(d) — subject-template enablement is a gated decision (audit-persist path)
2.5(d) renders subject_template via the same renderer as the body. The rendered body is never persisted (boundary = option 3), but the rendered subject IS persisted, in audit_details["subject"] on every email_sent/email_failed row (§10). Today this is inert — subject_template is NULL on 100% of live actions, so the persisted subject is the non-PII fallback. The moment any action's subject_template is set with {{ placeholders }}, the rendered subject — potentially PII (e.g. an employee name) — lands in audit_log. This is the ONLY never-persist-violation path in 2.5(d). Enabling a placeholder-bearing subject_template must therefore be gated on a deliberate revisit of the audit-persist path (either suppress the subject in audit, or accept the persistence explicitly). Tracked: T1 priorities tracker — "forms 2.5(d) — subject-template audit-persist gate".
Attachment handling — DECIDED: signed URL. Clean attachments live in GCS; the email body carries a time-limited GCS signed URL per attachment. This avoids large-MIME bounce risk and keeps the message light. Inline MIME attach is deferred as a possible per-form option later (would reuse 2.5b.cleanup's Config-level MIME/size authority). TTL = 7d (G4.0 DECIDED; V4 signed URLs cap there). Note the inherent property (§16.2 as-built): a signed URL is a bearer capability for its TTL — anyone holding the link (e.g. an email forward) can fetch the object. Signing runs as maple-agent via IAM signBlob, which requires two standing bindings recorded in the Gate-5 IAR and the recurring admin-review scope: roles/storage.objectViewer on gpus-forms-attachments and roles/iam.serviceAccountTokenCreator on maple-agent granted to maple-agent itself (self-sign).
Recipient override (G4.0, first-drain safety — permanent drill tool): when FORMS_ROUTING_RECIPIENT_OVERRIDE is set, ALL email goes to the override address; the real destinations are recorded in the body and in the per-action audit details. The first production drain runs WITH the override set; it is flipped off at G4.4 after operator review. The env knob stays in the worker permanently as a drill tool.
§8 Failure modes¶
| Class | Examples | Handling |
|---|---|---|
| Permanent | recipient rejected, MIME rejected, malformed action | mark action failed in routing_result; do not retry |
| Transient | relay temp error (4xx), rate limit, timeout | retry per Pub/Sub redelivery |
| Infected attachment | any attachment clamav_status='infected' |
submission → failed, no send, alert (rb-008) |
Retry policy mirrors α: max 5 delivery attempts → DLQ → Slack alert to #us-soc-alerts. report_mailer.py has no retry/backoff (single try/except, sys.exit(1)), so retry semantics are new and live in the routing worker, not the send call.
§9 Idempotency¶
Atomic optimistic-lock claim, same family as α — as-built (Gate 4, G4.0 C6): submissions has no updated_at column, and nothing else records when a row entered processing (which the §11 stuck-sweep needs). The claim therefore stamps claimed_at into the routing_result JSONB it already owns, and fuses the full §3 readiness predicate into the same statement — the candidate→authoritative promotion (C4) and the idempotency gate are one atomic write:
UPDATE submissions s SET status='processing',
routing_result = s.routing_result || jsonb_build_object('claimed_at', now())
WHERE s.id = :sid AND s.status = 'received'
AND s.routing_result IS NOT NULL -- finalized (C2 marker)
AND NOT EXISTS (SELECT 1 FROM attachments a
WHERE a.submission_id = s.id
AND a.clamav_status <> 'clean')
RETURNING s.form_id, s.submitted_at, s.routing_result;
Zero rows returned ⇒ spurious candidate (not finalized / scans pending / unknown id) or already claimed ⇒ ack and drop, log only. One email per submission is guaranteed by the state gate, not the send call. Because forms_app RLS is USING(TRUE) WITH CHECK(TRUE) on submissions, this transition is NOT subject to a state-coverage RLS gate — unlike α, no per-state RLS enumeration is needed. Per-action idempotency within a submission is tracked in routing_result JSONB (which actions already succeeded), so a redelivery after partial success — including a sweep-reclaimed stale row (§11) — re-sends nothing already completed.
§10 Audit trail¶
audit_log is append-only (UPDATE/DELETE revoked from all roles). Every routing attempt logs via _audit_v2(). The live audit_action enum (26 values, repo == live, no drift) has happyfox_success/happyfox_failure but no email/routing values. The one schema change 2.5(c) needs is 011_routing_audit_actions.sql adding exactly four values (audit kept lean — DECIDED):
submission_routed— all actions dispatched OKsubmission_route_failed— permanent failure / retries exhausted / infectedemail_sent— per email action successemail_failed— per email action failure
Dropped (lean decision): submission_route_retried and submission_route_reset_stuck — Pub/Sub redelivery counts and the eventual terminal submission_routed/submission_route_failed row carry enough signal; discrete retry/reset audit rows would be noise. ALTER TYPE … ADD VALUE is safe (readonly RLS is allow-except-purged, so new values are auto-visible — the safe direction).
2.5(d) never-persist boundary (decrypted content NEVER enters audit): the rendered email body and the decrypted {field_key: value} dict are held in local scope only — never logged, never written to routing_result, never placed in an audit row. The email_sent/email_failed audit_details carry only action_order, action_type, destination, recipients, subject, attachment_links (count), override_active. The one residual exposure is subject — see the §7 "subject-template enablement" danger note: rendered subjects persist here, inert today (NULL subject_template), but a placeholder-bearing subject would persist PII to audit_log. This is the sole never-persist-violation path in 2.5(d); gated on the T1 tracker row "forms 2.5(d) — subject-template audit-persist gate".
§11 Operational signals¶
- Healthy: rows move
received → processing → routedwithin seconds of readiness;failedrate near zero; DLQ empty. - Stuck: rows lingering in
processing(worker died mid-dispatch). Recovery: a sweep tick — as-built (Gate 4): an in-process periodic tick inside the pull daemon, not a Cloud Scheduler push (MAPLE exposes no inbound HTTP; firewalld default-drop stays intact). Stuck detection keys onrouting_result->>'claimed_at'(§9 as-built). Four sweep duties: (1)processingrows older than the stuck threshold → reset toreceived+ republish — safe becauserouting_resultskips already-sent actions (§9); no discrete audit value for the reset (§10 lean decision); (2) ready-but-unroutedreceivedrows → republish (recovers finalize-crash losses, α's log-only publish failures, and >7d-expired messages); (3) finalized rows holding aninfectedattachment → terminalfailed(§8), auditedsubmission_route_failedreason=infected_attachment; (4) DLQ drain by pull — retries-exhausted messages mark the row terminallyfailed(reason=retries_exhausted) + Slack to#us-soc-alerts. Always-ack on the DLQ drain (no DLQ-of-DLQ, α parallel). - Metrics surface on the SOC / status portal later (out of 2.5c scope; noted for the SOC Ticketing tab roadmap).
§12 Failure isolation¶
Per-submission (per Pub/Sub message) and per-action within a submission. One recipient's bounce must not block other submissions, nor other actions of the same submission — routing_result records each action's outcome independently; the submission lands routed only if all non-deferred actions succeeded, else failed with the partial result preserved.
§13a Access surface (7-point Cloud SQL spec) — resolved for B-iii + forms_app reuse¶
DECIDED: reuse the existing forms_app role; worker runs on MAPLE. This makes the access surface markedly lighter than α's.
- Project IAM roles — worker uses MAPLE's existing identity/path for DB access plus
roles/pubsub.subscriberfor the routing subscription. No new Cloud Run SA, no Secret Manager accessor (no new secret). - Cloud SQL IAM user — reuse
forms_app(same trust domain as forms-backend). No new DB role. (A dedicatedrouting_appwas considered for blast-radius isolation and rejected: it would reactivate the §13a.7 state-coverage work for marginal benefit.) - Table GRANTs — already held by
forms_app: SELECT/INSERT/UPDATE onsubmissions,submission_fields,attachments,audit_log(no DELETE). Nothing to add. - USAGE on TYPE —
submission_status+audit_actionenums;forms_appalready has it; new 011 values inherit it. - USAGE on SEQUENCE —
audit_logid sequence;forms_appalready granted (002_rls.sql). - RLS policy coverage —
forms_app:submissionsALLUSING(TRUE) WITH CHECK(TRUE);audit_logINSERTWITH CHECK(TRUE). All routing writes already covered. No new policy. - RLS state coverage — N/A by construction.
USING(TRUE)permits every status the worker writes (processing,routed,failed). The α GATE-4 lesson is rendered moot by the existing permissive app-role policy and the decision to reuse it.
§14 Cost¶
B-iii is ~$0 incremental: existing MAPLE VM, existing Postfix, Pub/Sub pull within free tier at this volume. No new always-on infra, no new Cloud Run service. Target <$5/mo comfortably held.
§15 Reuse¶
Postfix→Gmail relay (sender gpus-it-security@greenpeace.org), report_mailer.py MIME composition, _audit_v2(), Session(get_engine()), α's Pub/Sub claim + sweep + DLQ + #us-soc-alerts Slack pattern, 2.5b.cleanup Config-level MIME/size authority, GCS signed-URL generation. Net new secrets: zero.
§16 Decisions (locked 2026-06-04)¶
- Transport / service shape → B-iii: routing worker runs on MAPLE as a Pub/Sub pull subscriber, sends via
localhost:25Postfix. Zero new secret. (B-i Cloud Run + authenticated SMTP rejected — secret duplication.) - Attachment delivery → signed URL in the body (inline MIME deferred). As-built (Gate 4): TTL 7d; signed URL = bearer capability for the TTL; signing via IAM signBlob under two standing maple-agent bindings (§7).
- Interim body before 2.5(d) → minimal plaintext, not gated behind 2.5(d). Pipeline ships now.
- DB role → reuse
forms_app(norouting_app). Keeps §13a.7 N/A. _status_to_wiremapping → code-time confirm (read routes_phase2.py:57, align wire vocab to the real enum). Not a design fork; the only fact left to verify before/at code start. Resolved 2026-06-10 → §3a/§3b (4-value wire vocab; 2-value collapse retired, zero consumers verified; finalize contract made async-honest).- Audit granularity → lean: four new values (
submission_routed,submission_route_failed,email_sent,email_failed); no retry/reset audit rows. Amended 2026-06-10 (G3.0 decision C5): +1 finalize-side valuesubmission_finalized(migration 012, enum 30 → 31) —submission_createdmarks draft persist;submission_finalizedmarks the user submit that starts the routing clock. Lean total: 5 new values across migrations 011+012. 012 carries no grants — the α worker publishes readiness unconditionally (C4) and never writes audit rows with routing/finalize values.
§17 Coverage & ops-triad (v0.3 amendment)¶
Per the asset-propagation standing rule, the routing worker is a first-class asset and must be covered before it's "done":
- Inventory: the routing worker enters
inventory.yamlas a standalonecloud_servicesentry —type: host_resident_service,host: maple,monitoring_intent: pubsub_dlq_alerting. Both values are new to the coverage standard;governance/component-coverage-standard.md(and theinventory-schema.mdenum tables) get the matching edit in the same ops-triad commit, so the standard is never out of step with the inventory that uses it. - α backfill in the same work — the α ClamAV pipeline predates the
cloud_servicesdiscipline and is currently invisible to the coverage model. Five first-class entries land alongside the routing worker: the clamav worker (Cloud Run service), the ingest topic (gpus-forms-attachmentsOBJECT_FINALIZE), the DLQ, the push subscription, and the sweep cron (Cloud Scheduler). - The IR runbook / DR entry / red-blue drill triad for the MAPLE-resident worker remains tracked in the implementation footprint below.
Implementation footprint (what code will touch, once committed)¶
forms-backend/schema/011_routing_audit_actions.sql— +4 enum values (the only migration).forms-backend/routes_phase2.py— wire upfinalize_submission(persist +evaluate_routing_readiness+ publish); fix_status_to_wireper §16.5.- α worker — call
evaluate_routing_readinessafter marking last attachment clean (publish to routing topic). - New MAPLE-resident routing worker — Pub/Sub pull subscriber; claim/iterate-actions/dispatch/audit/sweep; sends via
localhost:25reusingreport_mailer.pycomposition. - Pub/Sub: routing topic + pull subscription + DLQ + dead-letter alert to
#us-soc-alerts. - Ops triad: IR runbook (rb-009 routing-failure?), DR entry, red/blue drill for the new MAPLE worker (per the every-new-asset rule).
Gate: design locked. Next step is your final read → Code commits this doc to mkdocs-portal/docs/architecture/forms-phase2.5c-design.md → then §1 code.
§18 Portal propagation matrix (Gate 5 — as-built)¶
Gate 5 backfills the 2.5(c) routing worker and the α ClamAV pipeline into
inventory.yaml as first-class cloud_services entries and propagates
them across the three operations portals. Portal presence for these
entities is now an enforced gate (validate_portal_presence() in
scripts/check-component-coverage.py), not documented-only policy.
| Entity ID | type | status | monitoring_intent | status-site | soc-site | mkdocs |
|---|---|---|---|---|---|---|
gpus_forms_db |
cloud_sql |
planned | cloud_sql_metrics+iam_audit_log |
Backend Services card | Pipeline posture row | forms-portal.md + this doc |
gpus_forms_routing_worker |
host_resident_service |
live | ssh+prometheus |
Backend Services card | Pipeline posture row | this doc |
gpus_forms_clamav_worker |
cloud_run |
live | pubsub_dlq_alerting |
Backend Services card | Pipeline posture row | this doc |
gpus_forms_attachment_uploaded |
pub_sub |
live | pubsub_dlq_alerting |
Backend Services card | Pipeline posture row | this doc |
gpus_forms_attachment_uploaded_dlq |
pub_sub |
live | pubsub_dlq_alerting |
Backend Services card | Pipeline posture row | this doc |
gpus_forms_clamav_worker_sub |
pub_sub |
live | pubsub_dlq_alerting |
Backend Services card | Pipeline posture row | this doc |
clamav_sweep_stuck |
cloud_scheduler |
live | pubsub_dlq_alerting |
Backend Services card | Pipeline posture row | this doc |
As-built reconciliations vs §17¶
- Routing worker
monitoring_intent:ssh+prometheus, not thepubsub_dlq_alerting§17 planned. As-built, the worker's liveness telemetry is its systemd unit state, exported by the node_exporter systemd collector on MAPLE (G Option B) — assh+prometheus-class signal.pubsub_dlq_alertingis carried by the DLQ topic/subscription entries instead. The routing worker is a Pub/Sub pull service with no HTTP/metrics endpoint by design, so unit-state is the correct liveness source; the textfile-collector heartbeat (which would also catch a stuck-but-running loop) is deferred to T5. hosted_on: MAPLE, nothost: maple. The schema's host-linking reference field ishosted_on, andlinux_hostsIDs are UPPERCASE (MAPLE).host_resident_serviceentries do not enterservers.py(regenerate-servers-py.pyreadslinux_hostsonly).- New schema vocabulary.
host_resident_serviceandcloud_schedulerwere added to thecloud_servicestypeexamples andpubsub_dlq_alertingto themonitoring_intentenum ingovernance/inventory-schema.md, in the same change-set that uses them.
DLQ alerting (Gate 5 F-decision)¶
DLQ operator alerting is already live via Slack and was not built this
gate: the ClamAV worker's /dlq-alert push route and the routing worker's
in-process DLQ-drain both post to #us-soc-alerts (webhook secret
verified wired). A DLQ-depth dashboard view is deferred to T5. The
status/soc card "Status" reflects this (DLQ alerting live; depth view
pending).
Rule-ID allocation (deconfliction)¶
Wazuh custom rule IDs 100020-100029 are allocated to the forms portal
(100020-100025 forms-backend, 100026-100029 2.5(c) routing + α
ClamAV). Meraki Wave 4 was moved to the reserved block 100050-100099
(access-control/admin-review-meraki-2026-04.md) to remove the prior
100020+ overlap.