aabenforms-workflow-builder

star 16

Design correct, compliant Danish municipal (kommune) workflows for AabenForms. Use when building or reviewing an ECA flow, a webform-to-workflow case, MitID/CPR identity gating, Digital Post, or any Danish gov-tech integration in this repo.

madsnorgaard By madsnorgaard schedule Updated 6/7/2026

name: aabenforms-workflow-builder description: Design correct, compliant Danish municipal (kommune) workflows for AabenForms. Use when building or reviewing an ECA flow, a webform-to-workflow case, MitID/CPR identity gating, Digital Post, or any Danish gov-tech integration in this repo.

AabenForms workflow builder

When to use: Use when designing or implementing a new Danish municipal (kommune) workflow in AabenForms - an ECA flow plus its Webform, MitID/CPR identity gating, Serviceplatformen lookups, Digital Post, GDPR retention, and WCAG-compliant Nuxt frontend. Triggers on building/modifying eca.eca.* flows, MitID/CPR/CVR handling, Digital Post (SF1601), parent-approval, FOI/offentlighedsloven cases, or any borger-facing form that collects personal data. Encodes the uniquely-Danish legal/service domain plus the project's hard engineering contracts so a correct, compliant flow can be designed from scratch.

This skill encodes what five expert reviews (Danish public-sector domain, security/GDPR, ECA correctness, Drupal architecture, and accessibility/UX) agreed a builder must know to ship a workflow a Danish kommune can actually run. Follow the sections in order: legal basis first, then the building blocks, then the engineering contracts.

1. Step 0 - Legal basis, case typology, and retention BEFORE any ECA

Never start in the modeler. First fill a one-page case charter: (a) Danish case type (byggesag, parkeringstilladelse, vielsestilladelse, folkeskole-indskrivning, klage, tilskud, FOI/aktindsigt, adresseaendring, MED-valg, foraeldregodkendelse). (b) Lawful basis - public authority's official duty is GDPR art. 6(1)(e); CPR lookups are recorded with purpose. (c) Retention / kassationsbestemmelse per case type (e.g. building permits ~10 years) and the erasure trigger (case closure). (d) Required integrations: SF1520 (CPR), SF1530 (CVR), SF1601 (Digital Post), Adressevælger (address), ESDH/SBSYS handoff. (e) Approval chain and deadlines (offentlighedsloven: acknowledge fast, respond within the statutory window, separate appeal deadline - use calendar days against the Danish holiday calendar, not 'working days' shortcuts). (f) Required eIDAS LoA - citizen forms default to Substantial (MitID); only step up to High when law demands. Output this charter into the flow's docs before coding.

2. Danish gov-tech building blocks (facts and contracts)

MitID/NemLog-in: OIDC eID; real MitID does NOT emit address/family_name claims (demo-IdP only) - treat them optional. assurance_level (acr) carries eIDAS LoA. CPR: 10 digits DDMMYYXXXX with modulus-11 check; validate format+checksum in the Webform element AND re-assert before any lookup; it is highly protected (encryption-at-rest + audit mandatory under databeskyttelsesloven). CVR: 8 digits; business identity needs NemLog-in Erhverv (CVR + procuration), a separate OIDC client from citizen MitID. Serviceplatformen SF15xx is SOAP, needs an X.509 client cert from Digitaliseringsstyrelsen/Kombit (exttest vs prod); SF1520=CPR lookup, SF1530=CVR lookup, SF1601=Digital Post. Digital Post sends to e-Boks via a MeMo XML envelope; sender must be a public authority (CVR); recipient is CPR or CVR; an idempotency key is mandatory to avoid duplicate official letters. Adressevælger (adressevaelger.dk, Klimadatastyrelsen) is the address autocomplete service; it requires a token (any >=10-char string today; real Brugerstyring ~late 2026) and works in prod - route it through a server-side proxy so the token stays off the client. Beskedfordeler covers email/SMS channels beyond SF1601. Datatilsynet enforces GDPR; breaches reported within 72h. Beskyttet person: SF1520 returns a protection flag; non-authorized access is a criminal offence.

3. The ECA gating contract (engineering rules - get this exactly right)

Conditions are PREDICATES, not nodes: define them only in the conditions: block with plugin: + configuration:; reference them by id from an action's successor condition: field. NEVER put successors: on a condition - it is a schema violation that silently creates dead code (see the caseworker_review_flow defect). eca_scalar contract: use operator: equal with left/right and the negate flag - NOT operator '==' with left_value/right_value (that wrong contract leaves the condition permanently dead). Status-token contract: any action whose result_token is referenced on a scalar condition's left side MUST also set a _status string companion ('verified'/'failed'/...); boolean result_token is backward-compat only - gating reads status. Always use DUAL gates on identity flows: gateok (verified) and gate_deny (!verified), both routed - deny goes to aabenforms_workflow_deny with a citizen message. Parallel branches launched from one event run concurrently; if they must join (both parents approve before next step) add an explicit gate action polling both branch tokens - do not assume ordering. Before any push, run modeler/CI validation: every referenced condition defined, no condition has successors:, every action reachable from an event, correct scalar contract.

4. Identity binding & session model

workflow_id is an unguessable wf_ bearer capability (128-bit, ~15-min expiry) linking a login session to the flow; treat it as a credential. CRITICAL prerequisites the platform still owes you: (1) the MitID id_token signature must be RS256-verified against the JWKS before any claim is trusted - do not build on the current unverified extractor; (2) never persist workflow_id in localStorage or read session PII from an open endpoint - use HTTPOnly cookies / one-time code exchange. Downstream actions that need the citizen's CPR must read it from the verified session ([session_data:cpr] / [citizen_data:cpr] populated by a post-MitID SF1520 lookup), NEVER from [webform_submission:values:cpr:raw] (citizen-supplied, spoofable). For parent approval use a deterministic scope id like parent_approval_{submission_id}_p{n}, collect parent1 CPR from the MitID session on first submit, and in the second action compare stored parent1 CPR vs parent2 session CPR with hash_equals / ParentCprVerifier::verify(); fail closed (403) on mismatch or missing CPR; audit both decisions with CPR hashes. Test gates with drush af:seed / the /admin/config/aabenforms/mitid/demo-session route - never hit the live gateway in dev. Gate sensitive flows on the MitID Validate action even if the user authenticated earlier, and fail closed (allow_mitid_demo_mode off by default).

5. Security, privacy & GDPR rules per workflow

For any flow collecting CPR: require MitID (no anonymous submit); verify identity before prefill; encrypt at rest via aabenforms_core.cpr_access.protect() and FAIL HARD if the key is missing - never silently store plaintext; run the SF1520 lookup to verify; audit the lookup (purpose + status, identifier hashed with hash_hmac + env-backed site secret, not bare sha256); mask CPR to DDMMYY-**** in every log, UI string, and audit context JSON (centralise in a CprMasker); delete on case closure per the retention schedule. Detect beskyttet person on the SF1520 response and deny with a neutral citizen message ('Din ansoegning kunne ikke behandles paa grund af saerlige forholdsregler. Kontakt kommunen direkte.'), auditing protected_person_rejected without leaking that the person is protected. Digital Post: recipient CPR from the verified session only; always generate+store a transaction_id for idempotency; sender = municipality CVR; redact PII in the message body (name + case id + link only). Inject all services via __construct() (never \Drupal::service() in action plugins - the container rebuilds on config import). Sanitise Serviceplatformen exceptions before returning to the frontend (log full fault, show generic Danish message); gate transient vs permanent errors so a timeout retries rather than rejecting the citizen. Set a default audit-log retention policy + cron purge; document it for the DPO. Add security.txt/SECURITY.md and CSP/HSTS/X-Frame-Options headers (NIS2).

6. Accessibility (WCAG 2.1 AA) & citizen UX

WCAG 2.1 AA is legally mandatory (webtilgaengelighedsloven) and currently unverified - bake it into every flow, do not claim it untested. Use real

7. Testing, deployment & multi-tenant

Test ECA execute() with REAL seeded Webform submissions, not mocked entities (field structure only validates end-to-end). For every new flow add an end-to-end test: export config -> import -> submit Webform with a workflow_id token -> assert the expected actions ran and output tokens are set (the dead caseworker_review_flow proves broken flows sit unnoticed without this). Mark stub actions stub: true and FAIL at runtime if allow_demo_mode is off - never silently skip a critical action in production. Define custom permissions in aabenforms_workflows.permissions.yml so route references resolve and survive config import. Protect runtime-created ECA/template configs from drush cim deletion via the STORAGE_TRANSFORM_IMPORT subscriber, and commit secret-free env-backed key/encrypt configs (keep the secret only in AABENFORMS_CPR_KEY on the server). In CI run unit+kernel AND functional/functional_javascript/Playwright AND axe, plus a config-validation step (cim --simulate / cex no-diff). Multi-tenant: bind tenant via domain_resolver to HTTP_HOST, scope CPR keys + workflow permissions + audit tenant_id per kommune, make CORS allowedOrigins env-driven, and add a TenantDataIsolation test proving submissions never cross tenants. Before go-live: security audit, accessibility audit, load test (18 flows x100 concurrent), security.txt + incident-response SLA + DR plan, and update PROMISES-VS-VERIFIED with only measured results.

Quick reference - the two mistakes that silently kill a flow

  1. Wrong eca_scalar contract. Use operator: equal + left + right + negate: true|false + type: value. NOT operator: '==' + left_value. The old contract evaluates to nothing and the gate is dead.
  2. Successors on a condition. Conditions never have successors. An action gates a branch by referencing a condition id in that successor's condition: field. Putting successors (or condition: 'true') on a condition makes the flow doubly dead.

8. Component catalogue - the actions you compose flows from

Compose flows from these ECA actions (id + the config keys that matter). Identity: aabenforms_mitid_validate (workflow_id_token, result_token, session_data_token; writes a <result_token>_status of verified/failed to gate on; fails closed - always route a deny_* on failure); aabenforms_parent_cpr_verify (parent_number, workflow_id_token, result_token - constant-time match of MitID-asserted CPR vs stored parent CPR, the dual-custody consent gate). Register lookups (cert-gated demo fallback): aabenforms_cpr_lookup (SF1520, cpr_token, result_token, use_cache), aabenforms_cvr_lookup (SF1530, cvr_token, result_token), aabenforms_case_income_lookup (eIndkomst demo, cpr_token, result_token). Case lifecycle (aabenforms_case): aabenforms_case_open (case_type, case_id_token - opens the sag, stamps modtagelsesdato, computes the frist), aabenforms_case_journal (case_id_token - SF1470 Sags- og Dokumentindeks, sets journal_ref, idempotent), aabenforms_case_transition (case_id_token, target_status, log_message - lawful move, audited revision), aabenforms_case_partshoering (case_id_token, state: afventer|afsluttet - FVL §19), aabenforms_case_decide (case_id_token, afgoerelse_type: medhold|delvist|afslag, klagevejledning, klagefrist_uger - FVL §25 enforcement), aabenforms_case_appeal (case_id_token, grounds_token - afgoerelse→paaklaget), aabenforms_case_sf2900_distribute (case_id_token - Fordelingskomponent handoff, the business receipt closes the case). Communication: aabenforms_digital_post_send (SF1601, recipient_token, recipient_type: cpr|cvr, sender_cvr_token, subject_template, body_template, type: 'Digital Post', result_token), plus aabenforms_send_approval_email, aabenforms_send_sms, aabenforms_send_reminder. Terminal/util: aabenforms_workflow_deny (event_type, step_label, message), aabenforms_audit_log, eca_token_set_value. Domain (mostly demo): booking (aabenforms_fetch_available_slotsaabenforms_book_appointment), aabenforms_validate_zoning, aabenforms_generate_pdf, aabenforms_process_payment, aabenforms_payroll_post, election (aabenforms_record_ballot/aabenforms_tabulate_election/aabenforms_publish_results), aabenforms_foi_route, aabenforms_contact_route. The case actions take case_id_token as a bracketed token [case_id] (the open_case result); pass it bracketed so token replacement resolves it.

9. Case engine & lawful lifecycle

A case (sag, entity aabenforms_case) is revisionable - every transition is an audited revision (who/when/why), the compliance backbone (no separate content_moderation needed). Status lifecycle and the ONLY lawful transitions (AabenformsCase::allowedTransitions()): modtaget → {oplyst, lukket}, oplyst → {partshoering, afgoerelse, lukket}, partshoering → {afgoerelse, lukket}, afgoerelse → {paaklaget, lukket}, paaklaget → {afgoerelse, lukket}, lukket → {}. decide only works from oplyst or partshoering - a flow must transition modtaget→oplyst before deciding (the #1 mistake building a case flow). Law the engine enforces in code: RSL §3 frist clock (per-area deadlines in aabenforms_case.settings frister: keyed by case_type/area, units hours/hverdage (weekends skipped)/straks; green/amber/red via FristClock); FVL §19 (decide rejected while partshoering_state == afventer); FVL §25 (decide with adverse afslag/delvist rejected unless klagevejledning supplied, then sets klagefrist); OFL journalisering (SF1470 via journal). Case fields: submission_ref (never copies raw CPR - that stays encrypted on the submission), kle_emne, handlekommune/betalingskommune, frist_due, partshoering_state, afgoerelse_type, klagefrist, journal_ref, assigned_to. Cases are access-gated (view aabenforms_case) so JSON:API never leaks them; inbox at /admin/aabenforms/cases (backend), /cases/inbox (frontend).

10. Flow recipes (copy-paste skeletons - correct contract)

Ship config in BOTH the module config/install/ AND config/sync/ (a config import on an already-enabled site only sees config/sync). New module + a flow referencing its action: add the module to config/sync/core.extension.yml BEFORE cim, and drush cr before cim so the new action plugin is discovered. Every flow: third_party_settings.modeler_api.modeler_id: workflow_modeler, valid hex uuid, events.on_submission = content_entity:insert, configuration.type: 'webform_submission <id>'.

  • Intake → case: open_case (case_type=X, case_id_token=case_id) → later steps read [case_id].
  • Income-gated auto-decision (friplads): open_case → journal → income_lookup → transition(oplyst) → [gate atmost threshold → decide(medhold) → digital_post → sf2900_distribute] | [else → stays oplyst for manual]. Gate = eca_scalar left: '[friplads_income]', right: '200000', operator: atmost, type: numeric, negate: false; else = a second condition negate: true; both sit as successors on the action, each with condition: <condition_id>.
  • Full lifecycle with adverse path (flagship merudgifter): mitid_validate → [verified → cpr_lookup ; failed → deny] → open_case → journal → income_lookup → transition(oplyst) → [eligible → decide(medhold) → letter → sf2900_distribute(→lukket)] | [ineligible → partshoering(afventer) → partshoering(afsluttet) → decide(afslag, klagevejledning, klagefrist_uger=4) → letter].
  • Dual-parent async consent: parent_request_form + a master flow branching on parents_together, plus per-parent parent<N>_approval_flow triggered on submission update; each parent gets a time-bound HMAC link (ApprovalTokenService, 7-day), logs in via MitID, and ParentCprVerifier constant-time-matches the asserted CPR vs stored parent<N>_cpr (fails closed). Use when two custody holders must each consent; inherently multi-step/async (not one synchronous submission).
  • Appeal / genvurdering: a klage webform (case_id, applicant_cpr, klage_begrundelse) + klage_flowaabenforms_case_appeal (case_id_token='[webform_submission:values:case_id]') (afgoerelse→paaklaget); genvurdering = a normal decide from paaklaget.
  • MitID in a scripted/test run: drush af:seed <persona> --workflow-id=<id> mints a session; submit via POST /api/webform/<id>/submit with workflow_id in the payload (the controller stashes it; resolveWorkflowId reads it) so mitid_validate passes.

11. Danish case-type catalogue - what to build + the integrations each needs

Map a requested workflow to a recipe + SF services + statutory frist. Underretning (concern about a child): two intake paths (borger may be anonymous; fagperson skærpet, not anonymous) → open case, frist vurder 24t/anerkend 6 hverdage; routes to DUBU/SBSYS. Økonomisk friplads: income from eIndkomst → auto-decision (straksbehandlet), monthly genberegning. Dagtilbudsplads: pasningsgaranti, waiting list; kommune (not parent) allocates. Skoleindskrivning: dual custody = two MitID signatures (dual-parent recipe). Befordring/skolekørsel: distance/trafikfarlig vej/lægeerklæring → visitation or mileage. Merudgifter SEL §41 / tabt arbejdsfortjeneste §42: documented funktionsnedsættelse + costs → manual assessment, appealable (the flagship full-lifecycle recipe). Aktindsigt: legal-basis selector (FVL partsaktindsigt / OFL §8 egenacces / OFL alm.), ~7-hverdages svarfrist. Klage: the appeal recipe. SF services per flow: CPR (SF1520 successor) prefill, SF1601/SF1606 Digital Post, SF1500 Organisation + SF1510 Klassifikation (KLE) for caseworker context + journalising, SF1470 Sags- og Dokumentindeks, SF2900 Fordelingskomponent for the fagsystem handoff. Prospect-facing copy: never name a competing product or developer; the OS2 community, public solution owners (KOMBIT, KL), providers and the libraries we depend on are fine to name.

Verify every new flow: no left_value, no operator: '==', no successors under conditions:, every mitid_validate routes to a deny_* terminal on failure, and the flow keeps third_party_settings.modeler_api.modeler_id: workflow_modeler (run drush af:modeler-adopt if it falls back).

Install via CLI
npx skills add https://github.com/madsnorgaard/aabenforms --skill aabenforms-workflow-builder
Repository Details
star Stars 16
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
madsnorgaard
madsnorgaard Explore all skills →