Skip to content

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). purged410 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_statuscannot 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:

{ "id": "...", "status": "received", "submitted_at": "...", "email_status": "queued" }

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 → processing → routed
                      ↘ failed
(purged is terminal/retention, out of routing scope)
  • 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 records happyfox_status='deferred' and does not fail the submission. Live data: 32 rows / 12 forms. happyfox_ticket_id/happyfox_status columns 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-notification and employee-termination-notification hit 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 different template ids.
  • Email recipients are per-action lists — comma-separated in destination — not per-form.

Consumption boundaries:

  • The 2.5(c) worker consumes email_template actions (and email_raw; 0 live rows).
  • happyfox_template actions are carried by the loop — recorded happyfox_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_template destinations are HappyFox email-ingest addresses — the Gate-4 close-out email to gpus-it-support@greenpeace.org auto-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) dispatches happyfox_template via 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_failed per 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 OK
  • submission_route_failed — permanent failure / retries exhausted / infected
  • email_sent — per email action success
  • email_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 → routed within seconds of readiness; failed rate 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 on routing_result->>'claimed_at' (§9 as-built). Four sweep duties: (1) processing rows older than the stuck threshold → reset to received + republish — safe because routing_result skips already-sent actions (§9); no discrete audit value for the reset (§10 lean decision); (2) ready-but-unrouted received rows → republish (recovers finalize-crash losses, α's log-only publish failures, and >7d-expired messages); (3) finalized rows holding an infected attachment → terminal failed (§8), audited submission_route_failed reason=infected_attachment; (4) DLQ drain by pull — retries-exhausted messages mark the row terminally failed (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.

  1. Project IAM roles — worker uses MAPLE's existing identity/path for DB access plus roles/pubsub.subscriber for the routing subscription. No new Cloud Run SA, no Secret Manager accessor (no new secret).
  2. Cloud SQL IAM userreuse forms_app (same trust domain as forms-backend). No new DB role. (A dedicated routing_app was considered for blast-radius isolation and rejected: it would reactivate the §13a.7 state-coverage work for marginal benefit.)
  3. Table GRANTs — already held by forms_app: SELECT/INSERT/UPDATE on submissions, submission_fields, attachments, audit_log (no DELETE). Nothing to add.
  4. USAGE on TYPEsubmission_status + audit_action enums; forms_app already has it; new 011 values inherit it.
  5. USAGE on SEQUENCEaudit_log id sequence; forms_app already granted (002_rls.sql).
  6. RLS policy coverageforms_app: submissions ALL USING(TRUE) WITH CHECK(TRUE); audit_log INSERT WITH CHECK(TRUE). All routing writes already covered. No new policy.
  7. RLS state coverageN/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)

  1. Transport / service shapeB-iii: routing worker runs on MAPLE as a Pub/Sub pull subscriber, sends via localhost:25 Postfix. Zero new secret. (B-i Cloud Run + authenticated SMTP rejected — secret duplication.)
  2. Attachment deliverysigned 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).
  3. Interim body before 2.5(d)minimal plaintext, not gated behind 2.5(d). Pipeline ships now.
  4. DB rolereuse forms_app (no routing_app). Keeps §13a.7 N/A.
  5. _status_to_wire mappingcode-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).
  6. Audit granularitylean: 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 value submission_finalized (migration 012, enum 30 → 31)submission_created marks draft persist; submission_finalized marks 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.yaml as a standalone cloud_services entry — 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 the inventory-schema.md enum 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_services discipline 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-attachments OBJECT_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 up finalize_submission (persist + evaluate_routing_readiness + publish); fix _status_to_wire per §16.5.
  • α worker — call evaluate_routing_readiness after 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:25 reusing report_mailer.py composition.
  • 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 the pubsub_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) — a ssh+prometheus-class signal. pubsub_dlq_alerting is 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, not host: maple. The schema's host-linking reference field is hosted_on, and linux_hosts IDs are UPPERCASE (MAPLE). host_resident_service entries do not enter servers.py (regenerate-servers-py.py reads linux_hosts only).
  • New schema vocabulary. host_resident_service and cloud_scheduler were added to the cloud_services type examples and pubsub_dlq_alerting to the monitoring_intent enum in governance/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.