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
4. Identity binding & session model
workflow_id is an unguessable wf_
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
- Wrong eca_scalar contract. Use
operator: equal+left+right+negate: true|false+type: value. NOToperator: '=='+left_value. The old contract evaluates to nothing and the gate is dead. - Successors on a condition. Conditions never have
successors. An action gates a branch by referencing a condition id in that successor'scondition:field. Puttingsuccessors(orcondition: '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_slots→aabenforms_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_scalarleft: '[friplads_income]',right: '200000',operator: atmost,type: numeric,negate: false; else = a second conditionnegate: true; both sit assuccessorson the action, each withcondition: <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 onparents_together, plus per-parentparent<N>_approval_flowtriggered on submission update; each parent gets a time-bound HMAC link (ApprovalTokenService, 7-day), logs in via MitID, andParentCprVerifierconstant-time-matches the asserted CPR vs storedparent<N>_cpr(fails closed). Use when two custody holders must each consent; inherently multi-step/async (not one synchronous submission). - Appeal / genvurdering: a
klagewebform (case_id,applicant_cpr,klage_begrundelse) +klage_flow→aabenforms_case_appeal (case_id_token='[webform_submission:values:case_id]')(afgoerelse→paaklaget); genvurdering = a normaldecidefrompaaklaget. - MitID in a scripted/test run:
drush af:seed <persona> --workflow-id=<id>mints a session; submit viaPOST /api/webform/<id>/submitwithworkflow_idin the payload (the controller stashes it;resolveWorkflowIdreads it) somitid_validatepasses.
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).