@smartkomm/plexus-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +826 -2
  2. package/dist/cli.mjs +483 -0
  3. package/dist/skill/.managed-by-plexus-cli +1 -0
  4. package/dist/skill/SKILL.md +185 -0
  5. package/dist/skill/references/accounts/email-changes.md +46 -0
  6. package/dist/skill/references/applications/inspect-process-store.md +61 -0
  7. package/dist/skill/references/applications/start-a-test-application.md +66 -0
  8. package/dist/skill/references/audit/answer-access-request.md +115 -0
  9. package/dist/skill/references/config/attach-a-template.md +117 -0
  10. package/dist/skill/references/config/init-tenant.md +121 -0
  11. package/dist/skill/references/config/onboard-tenant.md +71 -0
  12. package/dist/skill/references/config/rename-program-stage.md +99 -0
  13. package/dist/skill/references/config/set-tenant-branding.md +150 -0
  14. package/dist/skill/references/config/set-tenant-terms.md +50 -0
  15. package/dist/skill/references/config/set-up-billing-catalog.md +73 -0
  16. package/dist/skill/references/config/update-tenant-config.md +170 -0
  17. package/dist/skill/references/config/wipe-tenant-config.md +107 -0
  18. package/dist/skill/references/export/create-named-export.md +47 -0
  19. package/dist/skill/references/feedback/create-and-triage-feedback.md +69 -0
  20. package/dist/skill/references/feedback/manage-feedback-tags.md +57 -0
  21. package/dist/skill/references/finance/change-contract-grant.md +36 -0
  22. package/dist/skill/references/finance/record-submission-date.md +33 -0
  23. package/dist/skill/references/interessenten/list-interessenten.md +54 -0
  24. package/dist/skill/references/notifications/debug-mail-delivery.md +77 -0
  25. package/dist/skill/references/notifications/mail-level.md +90 -0
  26. package/dist/skill/references/notifications/reword-a-letter.md +155 -0
  27. package/dist/skill/references/notifications/trace-a-login.md +50 -0
  28. package/dist/skill/references/offer/add-workflow-node.md +268 -0
  29. package/dist/skill/references/offer/addresses.md +129 -0
  30. package/dist/skill/references/offer/appointment-task.md +79 -0
  31. package/dist/skill/references/offer/assignment-rule.md +195 -0
  32. package/dist/skill/references/offer/attach-form-to-workflow.md +79 -0
  33. package/dist/skill/references/offer/author-form-scenarios.md +97 -0
  34. package/dist/skill/references/offer/author-manifest-meta.md +94 -0
  35. package/dist/skill/references/offer/author-smartform.md +102 -0
  36. package/dist/skill/references/offer/bind-node-input.md +76 -0
  37. package/dist/skill/references/offer/completeness-check.md +137 -0
  38. package/dist/skill/references/offer/countersign-node.md +91 -0
  39. package/dist/skill/references/offer/create-pedagogy-unit.md +55 -0
  40. package/dist/skill/references/offer/declare-node-reads.md +99 -0
  41. package/dist/skill/references/offer/edit-live-tenant-offer.md +144 -0
  42. package/dist/skill/references/offer/edit-smartform.md +121 -0
  43. package/dist/skill/references/offer/edit-workflow-node.md +126 -0
  44. package/dist/skill/references/offer/engage-push-node.md +147 -0
  45. package/dist/skill/references/offer/insert-and-rewire-node.md +67 -0
  46. package/dist/skill/references/offer/node-tasks.md +162 -0
  47. package/dist/skill/references/offer/optional-assessment.md +65 -0
  48. package/dist/skill/references/offer/publish-offer-bundle.md +117 -0
  49. package/dist/skill/references/offer/set-offer-capacities.md +78 -0
  50. package/dist/skill/references/offer/staff-input-if-node.md +112 -0
  51. package/dist/skill/references/offer/task-appointment-result.md +69 -0
  52. package/dist/skill/references/offer/task-family-request.md +69 -0
  53. package/dist/skill/references/offer/task-result-files.md +52 -0
  54. package/dist/skill/references/offer/template-document-node.md +113 -0
  55. package/dist/skill/references/offer/workflow-default-channels.md +69 -0
  56. package/dist/skill/references/resolver/manage-contract-resolver.md +159 -0
  57. package/dist/skill/references/retention/erase-due-applications.md +99 -0
  58. package/dist/skill/references/retention/erase-due-parties.md +107 -0
  59. package/dist/skill/references/roles/migrate-parent-assignments.md +31 -0
  60. package/dist/skill/references/signing/README.md +113 -0
  61. package/dist/skill/references/signing/connect-a-school.md +90 -0
  62. package/dist/skill/references/signing/rotate-the-key.md +41 -0
  63. package/dist/skill/references/stammdaten/purge-deleted-person.md +68 -0
  64. package/dist/skill/references/stripe/connect-a-school.md +107 -0
  65. package/dist/skill/references/stripe/request-and-verify-a-payment.md +78 -0
  66. package/dist/skill/references/test-actions/drive-a-test-application.md +59 -0
  67. package/dist/skill/references/users/add-platform-admin.md +59 -0
  68. package/dist/skill/references/users/add-platform-user.md +59 -0
  69. package/dist/skill/references/users/add-tenant-admin.md +75 -0
  70. package/dist/skill/references/users/change-login-email.md +55 -0
  71. package/dist/skill/references/users/cli-access-for-tenant-admin.md +117 -0
  72. package/dist/skill/references/users/delete-permission-group.md +76 -0
  73. package/dist/skill/references/users/grant-cross-tenant-access.md +57 -0
  74. package/dist/skill/references/users/grant-feedback-assignable.md +43 -0
  75. package/dist/skill/references/users/set-password.md +57 -0
  76. package/dist/skill/references/users/use-test-account.md +79 -0
  77. package/dist/skill/references/worker-ops/debug-worker-jobs.md +90 -0
  78. package/dist/skill/references/worker-ops/verify-migrations.md +51 -0
  79. package/dist/spec.snapshot.json +145815 -0
  80. package/package.json +53 -4
@@ -0,0 +1,129 @@
1
+ # Name what a node reads (addresses)
2
+
3
+ **When to use:** a node must read data another node produces — the form's answers in a
4
+ contract, a value a review task records, a derived price, the contract a countersignature books
5
+ on — or the gate refuses a bundle with an `[address/*]` code. A read **names its
6
+ producer** in the field that reads it: a full address. There are no data edges any more
7
+ (`node.wires` is gone), no form implied by the node and no bare keys. Control edges (`next`) only
8
+ route. What a node may read is what is produced on every path before it.
9
+
10
+ ## Prerequisites
11
+ - An offer bundle on disk (`offer init`, or `offer export --slug <slug> --out <dir>`; a live
12
+ tenant's bundle only into a private scratch dir — see
13
+ [Edit a live tenant's offer](./edit-live-tenant-offer.md)). Fully offline: every verb runs the
14
+ publish gate on the result and writes nothing when the edit introduces an error.
15
+ - **A bundle exported before addresses were introduced is refused** (`[graph/retiredField]` for `wires`, a binding
16
+ with `path`, `adoptForm: true`; `[address/bareIdentifier]` for a bare key). Do not patch it by
17
+ hand — export it again (the server migration folded the live graph onto addresses).
18
+ - The spelling — **always in single quotes on the command line**, or the shell expands `$node`:
19
+
20
+ | Address | Names | Example (demo tenant `test`) |
21
+ |---|---|---|
22
+ | `$node.<formNode>.admission` | the form instance a form node creates | `$node.form.admission` |
23
+ | `$node.<formNode>.admission.<field>` | one field (or export, or party leaf `beneficiary` / `signers`) of it | `$node.form.admission.kind.geburtsurkunde` |
24
+ | `$node.<node>.document` | the newest revision of a document node's slot | `$node.platzangebot.document` |
25
+ | `$node.<node>.contract` | a machine-produced contract (`production: resolver`) | `$node.contract.contract` |
26
+ | `$node.<node>.payment` | a payment node's result | `$node.zahlung.payment` |
27
+ | `$task.<task>.<key>` | the value the ONE task `<task>` writes under store key `<key>` | `$task.reducedFee.reducedFee` |
28
+ | `$derived.<key>` | a computed price (`processStore[].derive`) | `$derived.schulgeld` |
29
+ | `$env.applicant.language` | a fact about the application nobody produces | |
30
+
31
+ An adopting form node (`adoptForm`, e.g. `formZusage` in the demo tenant `test`) **produces nothing**: the
32
+ instance stays its creator's, so every later node still reads `$node.form.admission.…`.
33
+
34
+ ## Steps
35
+ ```bash
36
+ # What arrives at a node: every address it reads, where it is written, who produces it —
37
+ # including what it reads by carrying (the evidence of a task whose value it reads, the
38
+ # sources of a derived price, the price check):
39
+ plexus-cli offer workflow node reads --dir <dir> --node contract
40
+
41
+ # A bare key is refused with the list of what that node can read — use it to pick:
42
+ plexus-cli offer workflow node set-reads --dir <dir> --node contract --address kind.vorname
43
+ # → --address (reads) "kind.vorname": ein nackter Schlüssel …
44
+ # An „Vertrag" (contract) lesbar:
45
+ # Bewerbung Grundschule Havelland › Anmeldung ($node.form.admission)
46
+ # Prüfung › Schulgeld mit Ermäßigung › Einkommensermäßigung ($task.reducedFee.reducedFee)
47
+ # …
48
+
49
+ # The flags that read data (each takes full addresses):
50
+ plexus-cli offer workflow node set-input --dir <dir> --node contract --input schulgeld --address '$derived.schulgeld'
51
+ plexus-cli offer workflow node set-reads --dir <dir> --node contract --from form --field kind.vorname,kind.nachname
52
+ plexus-cli offer workflow node set-requires --dir <dir> --node nachreichen --address '$node.form.admission.kind.masernschutz'
53
+ plexus-cli offer workflow node task set --dir <dir> --node reviewEingereicht --key reducedFee \
54
+ --when '$node.form.admission.ermaessigungBeantragt' --reads '$node.form.admission.gebuehren.einkommensnachweise'
55
+ plexus-cli offer workflow node add --dir <dir> --type setFlag ... --target '$node.form.admission'
56
+ plexus-cli offer workflow node add --dir <dir> --type form ... --adopt-form '$node.form.admission'
57
+ plexus-cli offer workflow node add --dir <dir> --type countersign ... --contract '$node.contract.contract'
58
+ ```
59
+
60
+ The short form `--from <formNode> --field <path>` (set-input, `*-reads`, `*-requires`) only
61
+ composes `$node.<formNode>.admission.<path>` — it names the node, it never picks one.
62
+
63
+ Which flag sets which field:
64
+
65
+ | Field | Verb / flag |
66
+ |---|---|
67
+ | an input binding | `node set-input --address` (or `--from/--field`) |
68
+ | `reads` (decision / review / governedDocument) | `node set-reads|add-reads|remove-reads --address`; review at add: `node add --reads` |
69
+ | `requires` (completenessCheck) | `node set-requires|add-requires|remove-requires --address`; at add: `--require` |
70
+ | setFlag `target` | `node add|set --target` |
71
+ | form `adoptForm` | `node add --adopt-form` |
72
+ | resolver governedDocument / engagePush `form` | `node set --form '<instance address>'` (engagePush also at add) |
73
+ | countersign / engagePush `contract`, engagePush `payment` | `node add|set --contract|--payment` |
74
+ | roleAssign / completenessCheck `person` | `node add|set --person` |
75
+ | task `when`, `reads`, `documents` | `node task add|set --when|--reads|--documents` (`field:<address>`, `document:$node.<node>.document`; an own slot stays `artifact:<key>`) |
76
+ | task family request | `--family-request-fields <addresses>` or `--family-request-from '$node.<form>.admission'` |
77
+ | record field suggestion | `--field '{…,"suggest":{"kind":"form","address":"$node.form.admission.alleinerziehend"}}'` |
78
+ | `pricingRules[].rows[].when`, `processStore[].derive.reduction` | **no CLI verb** — edit the bundle JSON; a condition's identifiers are canonical addresses (`$task.geschwisterrang.geschwisterRangSchule >= 3`), a reduction is `{ "scope": "task", "owner": "reducedFee", "key": "reducedFee" }` |
79
+
80
+ ## In the IDE instead
81
+ The Workflow-IDE offers exactly what the gate accepts — the same rules
82
+ (`addressFieldOptions`), one picker in every editor, never a typed key:
83
+ - **What arrives at a node:** select it — the bottom bar's „Eingang" column lists
84
+ its reads and what it may read (Formular · Dokumente · Zahlungen · Aufgaben ·
85
+ Abgeleitet · Umgebung), each with producer, address and how it is read; a rose
86
+ entry is a read publish refuses (`[address/notVisible]`). Drag an entry onto a
87
+ parameter to bind it.
88
+ - **Node fields** (`reads`, `requires`, setFlag `target`, `adoptForm`, `form` /
89
+ `contract` / `payment`): node → „Details" → „Einstellungen".
90
+ - **Task fields** (`when`, `reads`, `documents`, `familyRequest`, a record's
91
+ suggestion): node → „Details" → „Team".
92
+ - **Pricing-rule conditions and reductions:** header → „Preisregeln" („Adresse
93
+ einfügen" puts the `$task…` identifier into the condition).
94
+
95
+ ## Verify
96
+ ```bash
97
+ plexus-cli offer validate --dir <dir> # exit 0, no [address/*] error
98
+ plexus-cli offer workflow node reads --dir <dir> --node <id> --json
99
+ plexus-cli --env staging --tenant <t> offer plan --dir <dir> # ~ workflow; address:/reads: rows against the live head (see edit-live-tenant-offer.md)
100
+ ```
101
+
102
+ ## Idempotency / gotchas
103
+ - **The gate judges every address** before writing: `[address/shape]`, `[address/bareIdentifier]`,
104
+ `[address/unknownOwner]` (no such node / task), `[address/unknownKey]` (the node does not
105
+ produce it — a form produces `admission`, a resolver contract `contract`, not `document`),
106
+ `[address/ownerNotWriter]` (the task does not write that store key), `[address/notVisible]`
107
+ (the producer is not on EVERY path before the reader — on a detour the reader would wait
108
+ forever), `[address/pathNotInTarget]` (the form has no such field), `[address/typeMismatch]`
109
+ (a `when` must be a yes/no export, a binding must fit its slot), `[address/notFormInstance]`
110
+ (`target`, `adoptForm`, `form`, `familyRequest.from` name `$node.<creating form>.admission`
111
+ without a path), `[address/mixedForms]` (one list — node `reads`, `requires`, a task's
112
+ `when` + `reads`, `familyRequest.fields` — names ONE form), `[address/missing]` (setFlag
113
+ `target`, countersign `contract`, a resolver's / engagePush's `form`). Only errors the edit
114
+ INTRODUCES block it; errors the bundle already had are printed as „vorbestehend".
115
+ - **A node reads its own results** (a value one of its tasks writes, its own document slot)
116
+ without dominance; it is still in its read list.
117
+ - **`node rm` refuses while an address names the node** — its form instance, document,
118
+ contract, payment, or a value one of its tasks writes — and lists every place (config path +
119
+ address). Edit those first; nothing is dropped silently.
120
+ - **Renaming a task key or moving a writing task to another node** changes the address
121
+ (`$task.<task>.<key>`): every reader must be edited, and running applications cannot re-pin
122
+ across it.
123
+ - Nothing reaches a live process until `offer apply --publish`; running applications keep their
124
+ pinned version.
125
+
126
+ ## Related
127
+ - [Bind a node input](./bind-node-input.md) · [Declare what a document node reads](./declare-node-reads.md)
128
+ - [Add a workflow node](./add-workflow-node.md) · [Countersign the contract](./countersign-node.md) · [Node tasks](./node-tasks.md)
129
+ - [Edit a live tenant's offer](./edit-live-tenant-offer.md) — export, plan and apply against a live tenant
@@ -0,0 +1,79 @@
1
+ # Give an appointment its Terminaufgabe („Taster Day durchführen")
2
+
3
+ **When to use:** a „Termin vereinbaren" task books an appointment (see
4
+ [Make a task close with an appointment](./task-appointment-result.md)), and the work
5
+ done AT that appointment — hold the interview, run the taster day, give feedback with
6
+ attachments — belongs to whoever hosts it, due a few days after it ends.
7
+
8
+ ## The model in three sentences
9
+
10
+ The **Terminaufgabe** is an ordinary task of the same node with
11
+ `appointment: { of: '<booking task>', dueDaysAfterEnd: N }`. It spawns with its round
12
+ and waits („Wartet auf: <booking task>") until the booking binds it: its holder is the
13
+ appointment's **host** (a person of the task's team, or that team's owner on the
14
+ application), its due day the appointment's end + N. It closes with its result — the
15
+ feedback, a recommendation / choice / free text with `files: true` — and holds whatever
16
+ it `blocks` until then.
17
+
18
+ ## Prerequisites
19
+
20
+ - The booking task exists with `result.kind: appointment`.
21
+ - The Terminaufgabe's team (`--team`) is the team the hosts come from: the booking
22
+ dialog offers only that team and its members, and the server refuses others.
23
+ Teachers who host need cockpit access (`bewerbungsmanagement.applications.read`,
24
+ `aufgaben.items.read/write`).
25
+ - The target environment runs a platform version that knows Terminaufgaben (`offer apply`
26
+ publishes against the deployed gate; if publish does not know `appointment`, ask the
27
+ platform operator).
28
+
29
+ ## Steps
30
+
31
+ ```bash
32
+ # 1. The booking task no longer blocks the follow-up — the hold is derived.
33
+ plexus-cli offer workflow node task set --dir $WORK/$S --node decision --key tasterDayTermin --clear-blocks
34
+ # 2. The Terminaufgabe: team, link, due rule, feedback with files.
35
+ plexus-cli offer workflow node task add --dir $WORK/$S --node decision --key tasterDayDurchfuehren \
36
+ --title 'Taster Day durchführen' --title-en 'Hold the taster day' --team teachers \
37
+ --appointment-of tasterDayTermin --due-days-after-end 2 \
38
+ --result recommendation --chat all --files
39
+ # 3. If the booking task is part of an optional action, the Terminaufgabe joins it —
40
+ # and the action is repeatable: a cancelled appointment ends the round.
41
+ plexus-cli offer workflow node optional-action set --dir $WORK/$S --node decision --key tasterDay \
42
+ --tasks tasterDayTermin,tasterDayDurchfuehren --repeatable
43
+ plexus-cli offer validate --dir $WORK/$S
44
+ ```
45
+
46
+ Then the live path ([Edit a live tenant's offer](./edit-live-tenant-offer.md)):
47
+ `--env prod offer plan` → `--env prod offer apply --allow-prod --publish` → plan again
48
+ until every row is `same`.
49
+
50
+ ## Verify
51
+
52
+ - `offer validate` shows no `[workItems/appointment*]` error.
53
+ - In the cockpit: trigger the action → the Terminaufgabe reads „Wartet auf: …";
54
+ book with a host of the team → the card shows the appointment line
55
+ („Taster Day · Di., 21.10. 09:00–12:00 · Raum 2 · Gastgeber: …") and the host holds it.
56
+
57
+ ## Gotchas
58
+
59
+ - Publish refuses: `of` naming no appointment task of the same node
60
+ (`appointmentOfUnknown`), two Terminaufgaben for one booking task (`appointmentOfTwice`),
61
+ `--due-in-days` beside the end rule or N outside 0–60 (`appointmentTaskDue`), any
62
+ `blocks` aimed at it (`appointmentTaskBlocked`), a different optional action than the
63
+ booking task (`appointmentTaskBinding`), an optional action spawning the booking task
64
+ without `repeatable: true` (`appointmentActionRepeatable` — a cancellation ends the
65
+ round, and without a second one a decision waiting for the action via `requiresOneOf`
66
+ would hang for good), an approval / `when` / `optional` /
67
+ family request / missing result (`appointmentTaskShape`). Warning
68
+ `appointmentChatUnreadable`: the feedback chat is another team's — the host may not
69
+ read it; prefer `all` or the task's own team.
70
+ - Holder and due day have ONE writer, the appointment: „Zuweisen" refuses a bound
71
+ Terminaufgabe, and so does „Wiederöffnen" (edit the feedback text instead).
72
+ - A staff member who hosts an appointment with an open Terminaufgabe cannot be
73
+ withdrawn (`[appointment/hostInUse]`) — change the appointment's host first.
74
+ - Running applications keep their pinned version.
75
+
76
+ ## Related
77
+
78
+ - [Make a task close with an appointment](./task-appointment-result.md)
79
+ - [Files on a task result](./task-result-files.md)
@@ -0,0 +1,195 @@
1
+ # Change an offer's assignment rule (Zuständigkeitsregel)
2
+
3
+ **When to use:** decide who becomes responsible for a new application and who owns
4
+ each team's tasks — for a new offer, after staff changes, or when a workflow starts
5
+ giving work to a team the rule does not cover yet. Also: a staff member a rule names
6
+ is leaving.
7
+
8
+ ## What it is (one paragraph)
9
+
10
+ The assignment rule is a **section of the workflow graph**: `graph.assignmentRules`
11
+ in the offer bundle's `workflow.json`. It is **one ordered rule list per slot**: per
12
+ slot the first row whose condition holds wins, the last row has no condition
13
+ (`"when": null`, shown as „sonst"). It is versioned with the offer version — saved
14
+ with the draft, checked at publish, pinned with the graph. Each new application
15
+ evaluates its pinned graph's rule **once** at its start (only the slots its workflow
16
+ needs) and records the matched row per slot; a published change reaches only new
17
+ applications. Slots: `responsible` („Prozesszuständig" — always a person, always
18
+ **first**) and `team:<teamKey>` for every team the workflow gives work to. Targets:
19
+ `{kind:"person", configKey}` (the staff member's `user_tenant_access.configKey`),
20
+ `{kind:"creator"}` („wer die Bewerbung anlegt" — only in a row whose own `when` is,
21
+ or has as an `&&` conjunct, exactly `$env.applicant.channel == 'staff'`),
22
+ `{kind:"slot", slot:"responsible"}` („die zuständige Person") and `{kind:"queue"}`
23
+ („Team-Queue") — both team slots only —, `{kind:"rotation", configKeys:[…], mode}`
24
+ („Reihum" — any slot: ≥ 2 distinct people, `"strict"` takes turns after the
25
+ rotation's last pick, `"leastLoaded"` takes whoever has the fewest open tasks, ties
26
+ in turn; on a team slot every pool member must be in the team) and
27
+ `{kind:"unassigned"}` („Person wählen"),
28
+ which a draft may hold but publish refuses. The process slot's „sonst" row is always
29
+ a fixed person.
30
+
31
+ ## Prerequisites
32
+
33
+ - `plexus-cli` installed; a token that may apply offers on the tenant (for the
34
+ export/apply steps). `offer validate` and `assignment simulate` run offline.
35
+ - The people are active staff of the tenant and members of any team they are named
36
+ for directly. A person is named by config key: choosing a staff member in the
37
+ editor gives them one if they have none (opaque `staff_<random>`, never derived
38
+ from the name).
39
+
40
+ ## Steps — in the workflow editor
41
+
42
+ 1. Open the offer's workflow in the editor → toolbar „Team-Verantwortliche". One
43
+ table per slot. Rows can be added (they land before „sonst"), removed and moved
44
+ up/down; the condition is built from field / operator / value („Erweitert" for
45
+ the raw DSL); the target picker offers a person, „wer die Bewerbung anlegt",
46
+ „die zuständige Person", „Team-Queue" and „Reihum …" (tick ≥ 2 people — team
47
+ members only on a team slot — and „Streng reihum" or „Wer am wenigsten offen
48
+ hat"). Errors show inline, in German, from the
49
+ same checks publish runs.
50
+ 2. Save and publish the workflow like any other change — the rule goes with it.
51
+ 3. Nothing to keep elsewhere: a live tenant's bundle lives on the server only.
52
+ Anyone who changes the offer later starts from a fresh
53
+ `offer export`, so the rule you published is what they edit.
54
+
55
+ ## Steps — in the bundle
56
+
57
+ 1. Export the live offer into a private scratch dir (a live tenant's bundle is
58
+ never kept in a shared or versioned place — see
59
+ [edit-live-tenant-offer](./edit-live-tenant-offer.md)) and edit
60
+ `workflow.json` → `graph.assignmentRules`:
61
+
62
+ ```bash
63
+ WORK=$(mktemp -d) # any private scratch dir
64
+ plexus-cli --env prod offer export --tenant <slug> --slug <offer-slug> --out $WORK/bundle
65
+ ```
66
+
67
+ ```jsonc
68
+ "assignmentRules": [
69
+ { "slot": "responsible", "rules": [
70
+ { "when": "$env.applicant.channel == 'staff'", "assign": { "kind": "creator" } },
71
+ { "when": "$env.unit.key == 'secondary'",
72
+ "assign": { "kind": "person", "configKey": "admissions_secondary" } },
73
+ { "when": null, "assign": { "kind": "person", "configKey": "aufnahme" } }
74
+ ] },
75
+ { "slot": "team:bewerbungsteam", "rules": [
76
+ { "when": null, "assign": { "kind": "slot", "slot": "responsible" } }
77
+ ] },
78
+ { "slot": "team:principals", "rules": [
79
+ { "when": null, "assign": { "kind": "queue" } }
80
+ ] }
81
+ ]
82
+ ```
83
+
84
+ Every list ends with exactly one `"when": null` row; a row below a constant or a
85
+ repeated condition is refused (`[assignment/shadowedRule]`). `$env.unit.key` is
86
+ the unit's **configKey**, never its name or URL slug. A condition reads the
87
+ application's environment variables (`$env.*`, the same vocabulary as the graph
88
+ and the forms — IDE tab „Umgebungsvariablen"). A variable that can be
89
+ EMPTY — `$env.applicant.language` (no applicant account: started by staff),
90
+ `$env.stage.gradeLevel`, `$env.run.startDate` — needs a `… != null &&` guard
91
+ before `!=` / `!` / an ordering (`[condition/unguardedNullable]`); `==` needs
92
+ none. A bundle still carrying `$ctx.*` (exported before `$env` replaced it) is refused —
93
+ export again.
94
+
95
+ 2. Validate offline — the same checks the editor shows and publish runs
96
+ (`[assignment/slotMissing]`, `…/queueOnProcessSlot`, `…/unguardedTarget`,
97
+ `…/targetMissing`, `[condition/*]`, …):
98
+
99
+ ```bash
100
+ plexus-cli offer validate --dir $WORK/bundle
101
+ ```
102
+
103
+ 3. Test bench — evaluate the bundle's rule over an application's environment
104
+ (`$env`), offline:
105
+
106
+ ```bash
107
+ plexus-cli assignment simulate --dir $WORK/bundle \
108
+ --env-json '{"applicant":{"language":"de","channel":"self-service"},"unit":{"key":"secondary","name":"Secondary"}}'
109
+ # A staff start: no applicant account, so no language (null).
110
+ plexus-cli assignment simulate --dir $WORK/bundle --creator "Sekretariat" \
111
+ --env-json '{"applicant":{"language":null,"channel":"staff"},"unit":{"key":"school_primary","name":"Primary"}}'
112
+ ```
113
+
114
+ Every slot is evaluated; the output names the matched row (`#1`, `#2`, …) per slot.
115
+ A „Reihum" row shows mode, pool, last pick and pick. Offline there is no rotation
116
+ position: pass the last pick with `--rotation-previous team:finanzen=finanzen`
117
+ (none = the rotation's first start), open-task counts for `leastLoaded` with
118
+ `--open finanzen=3 --open finanzen_2=1` (absent = 0), and someone who left with
119
+ `--left <key>`. What a real start picked is in its evaluation record
120
+ („Regel anzeigen" in the cockpit).
121
+ Only the keys the conditions read are needed; a key a condition reads but the
122
+ environment lacks fails loud, like a start.
123
+
124
+ 4. Publish — the DB half of the check runs here (people active staff, a team slot's
125
+ fixed person a member, live unit/offer keys):
126
+
127
+ ```bash
128
+ plexus-cli --env prod offer plan --tenant <slug> --dir $WORK/bundle # only your rule change
129
+ plexus-cli --env prod offer apply --allow-prod --tenant <slug> --dir $WORK/bundle --publish
130
+ ```
131
+
132
+ ## A staff member leaves
133
+
134
+ A person a rule names — as a row's person or in a „Reihum" pool — cannot leave the staff (`users remove`, `users unset-group`,
135
+ `users delete`), leave a team whose slot names them, and a team a rule has a slot
136
+ for cannot be deleted — refused (`[CONFLICT] [assignment/ruleReferences] …`), naming offer
137
+ (label + status), slot and row. The check reads every `wip` and published offer
138
+ version (archived ones only run pinned test starts, which fail loud instead). Order:
139
+
140
+ 1. Workflow editor → „Team-Verantwortliche" → „Person ersetzen": pick the leaving
141
+ person and the successor; the preview shows exactly the rows that change (in a
142
+ pool the successor takes their place; one already in the pool is not doubled).
143
+ 2. Publish the workflow — for every offer the refusal named (the draft counts too).
144
+ 3. Only then remove the group membership:
145
+
146
+ ```bash
147
+ plexus-cli --env staging --tenant <slug> users unset-group ada@example.com --group sekretariat \
148
+ --replace-with <successorConfigKey>
149
+ ```
150
+
151
+ `--replace-with` only hands the person's **active applications** over (required
152
+ while they are responsible for any); it never rewrites a rule.
153
+ 4. Nothing to export or keep elsewhere — the server is the source of truth.
154
+
155
+ ## Verify
156
+
157
+ - `plexus-cli offer validate --dir <bundle>` is clean; `offer status` shows the
158
+ change live (see [Release an offer bundle](./publish-offer-bundle.md)).
159
+ - `plexus-cli offer export` of the live offer → `graph.assignmentRules` equals what
160
+ you meant.
161
+ - In the cockpit, a new application's „Zuständig: …" → „Regel anzeigen" shows one
162
+ table per slot („Prozesszuständig", „Team …"): rows above the deciding one read
163
+ „nicht zutreffend", rows below it „übersprungen"; the deciding row itself carries
164
+ no text label (only its accessible name says it applied).
165
+
166
+ ## Idempotency / gotchas
167
+
168
+ - **Edit a fresh export, never an older copy.** An older copy (an earlier export,
169
+ a backup) can be behind the live offer in some places and ahead in
170
+ others; applying it rolls prod back where it is behind.
171
+ - **A config key like `aufnahme` is a role, not a name.** A school can name its
172
+ admissions person by such a key; which user carries it may differ per deployment
173
+ (prod vs. staging). Handing the role over is `Person ersetzen` or a config key
174
+ move — never a bundle edit.
175
+ - **A person the editor picks is not a config user.** Picking staff created in the
176
+ admin UI draws them a key (`staff_…`) marked `configKeyOrigin: "rule"`;
177
+ `config export` leaves such a person out of `foundation.users`. Declaring the key
178
+ in tenant config later makes them an ordinary config user.
179
+ - **Edits in the editor live only in the DB** until exported — an `offer apply` from
180
+ an older bundle reverts them.
181
+ - **A new workflow** starts with the process slot „Eingangsweg intern → wer die
182
+ Bewerbung anlegt; sonst → Person wählen"; a team slot the rule lacks shows
183
+ „sonst → Team-Queue". Publish refuses until „Person wählen" is replaced.
184
+ - Applications already started keep the rule of the version they are pinned to —
185
+ a published change reaches only new applications.
186
+ - **The bundle is canonical JSON (keys sorted).** When you edit `workflow.json` by
187
+ hand or by script, keep that shape — a re-serialised file differs from a fresh
188
+ export far from your edit — and check the result with `offer validate` and
189
+ `offer plan` (only your rule change should show).
190
+
191
+ ## Related
192
+
193
+ - [Release an offer bundle](./publish-offer-bundle.md) — publishing and checking it went live.
194
+ - [Edit a live tenant's offer](./edit-live-tenant-offer.md) — export, plan, apply.
195
+ - `plexus-cli assignment simulate --help` — the test bench's flags.
@@ -0,0 +1,79 @@
1
+ # Attach a SmartForm to a workflow form node
2
+
3
+ **When to use:** an offer's workflow needs a `form` step — bind an existing
4
+ SmartForm to a new form node, or scaffold a brand-new form and bind it in one
5
+ step. Replaces hand-editing `workflow.json` + `bundle.yaml` + `smartforms/<slug>.json`
6
+ in lockstep.
7
+
8
+ ## Prerequisites
9
+ - A bundle on disk (`offer init` / `offer export`). Authoring is **offline**;
10
+ only `offer apply --publish` is online (needs `--env` + an API token).
11
+ - An installed, current `plexus-cli` (`plexus-cli --help`).
12
+
13
+ ## Steps
14
+ ```bash
15
+ # A. Bind an EXISTING form (created earlier with `offer form add`).
16
+ plexus-cli offer workflow node add --dir <bundle> \
17
+ --type form --form <form-slug> --label "<Step label>" --label-en "<English step label>" --after <nodeId>
18
+
19
+ # B. Scaffold a NEW form AND bind it, in one command.
20
+ # --form is the new form's slug; --label is the node + form label.
21
+ plexus-cli offer workflow node add --dir <bundle> \
22
+ --type form --new-form --form zusatzformular --label "Zusatzangaben" --label-en "Additional details" --after approval
23
+
24
+ # C. CONTINUE the instance an earlier form node created (a second step of the same
25
+ # form, e.g. the Grundschule's `formZusage` after the Zusage): no --form; the node
26
+ # names the instance it continues (single quotes — the shell expands $).
27
+ plexus-cli offer workflow node add --dir <bundle> \
28
+ --type form --id formZusage --label "Angaben nach der Zusage" --label-en "Details after the offer" \
29
+ --after setFlagZusage --adopt-form '$node.form.admission'
30
+
31
+ # Then fill the (new) form out — see Author a SmartForm as code.
32
+ plexus-cli offer form field add --dir <bundle> --form zusatzformular \
33
+ --section section.allgemein --key zusatz.notiz --type string --label "Notiz"
34
+
35
+ # Validate offline against the real publish gate, then publish.
36
+ plexus-cli offer validate --dir <bundle>
37
+ plexus-cli --env <env> --tenant test offer apply --publish --dir <bundle>
38
+ ```
39
+
40
+ The form node is written with `inputs.smartForm = { kind: "literal", value: <slug> }`,
41
+ a default `standard` outcome, and the SmartForm lifecycle progress states
42
+ (`started` / `submitted`) materialised into the process board + wiring automatically.
43
+ The outcome only routes: later nodes read the answers at the node's address,
44
+ `$node.<formNode>.admission.<field>` (see [Name what a node reads](./addresses.md)).
45
+ An adopting node (C) produces nothing: the instance stays its creator's, so every later
46
+ node still reads `$node.<creator>.admission.…`.
47
+
48
+ ## Verify
49
+ ```bash
50
+ plexus-cli offer workflow show --dir <bundle> # the form node + routing appear
51
+ plexus-cli offer form list --dir <bundle> # the bound form shows bound=yes
52
+ plexus-cli offer validate --dir <bundle> # "local validation clean"
53
+ ```
54
+
55
+ ## Idempotency / gotchas
56
+ - **`--type form` requires `--form <slug>`** — bind target (existing) or new slug
57
+ (with `--new-form`) — or `--adopt-form '<instance address>'` (never both). Without
58
+ either, the command fails loud. A bare `--adopt-form form` is refused with the
59
+ addresses visible at the node; an adopting node pointing at a non-creating form is
60
+ `[address/notFormInstance]`.
61
+ - **`--new-form` fails if the slug already exists** (no silent overwrite) and is
62
+ only valid with `--type form`.
63
+ - **Binding a non-existent form fails loud** (`no smartform "<slug>"`) — create it
64
+ with `offer form add` first, or use `--new-form`.
65
+ - **A scaffolded form starts empty** (one section, default outcome). Add fields
66
+ with `offer form field add` before publishing anything user-facing.
67
+ - **Publish pins the form version.** `offer apply --publish` publishes the
68
+ smartform(s) *then* the workflow, so the workflow pins the just-published version.
69
+ Re-publishing the whole bundle after a form edit re-pins it; a standalone form
70
+ publish would leave the workflow on the old version.
71
+ - **`offer workflow node rm`** of the form node leaves the form carried-but-unbound
72
+ (a warning); remove the form too with `offer form rm` if it's no longer needed. It is
73
+ refused while any address still names the node (`$node.<formNode>.admission…` in a
74
+ binding, `reads`, `requires`, a task, `target`, `adoptForm`) — the refusal lists them.
75
+
76
+ ## Related
77
+ - [Add a workflow node](./add-workflow-node.md) — other node types + splice rules.
78
+ - [Author a SmartForm as code](./author-smartform.md) — build the form's contents.
79
+ - `plexus-cli offer --help` — the full `offer …` command reference.
@@ -0,0 +1,97 @@
1
+ # Author a form's test scenarios and environment variables
2
+
3
+ **When to use:** give a SmartForm one-click test-mode prefills (`testScenarios`) or let
4
+ its expressions read where the form is filled in (`env`: `$env.stage.key`, …) —
5
+ without hand-editing the form JSON. `$env` is the ONE root for a fact about the
6
+ application; the graph and the assignment rules read the same variables.
7
+
8
+ ## Prerequisites
9
+ - An offer bundle on disk carrying the form (`offer export …` / `offer init …`).
10
+ - Fully offline; every command runs the publish gate on the result and writes nothing
11
+ when it fails.
12
+
13
+ ## Steps
14
+ ```bash
15
+ # A scenario: id (kebab-case), label, prefill. Values are typed by the FIELD:
16
+ # boolean true|false, a multi-select enum a,b, everything else a string.
17
+ plexus-cli offer form scenario add --dir <bundle> --id standard --label "Standardfall" \
18
+ --label-en "Standard case" \
19
+ --prefill kind.vorname=Anna --prefill kind.nachname=Muster --prefill agb=true
20
+
21
+ # Addresses and repeated rows do not fit key=value — give the whole prefill as JSON:
22
+ plexus-cli offer form scenario add --dir <bundle> --id geschwister --label "Mit Geschwistern" --label-en "With siblings" \
23
+ --prefill-json '{"kind.vorname":"Anna","geschwister":[{"name":"Ben"}]}'
24
+
25
+ # A variant of an existing scenario: --from copies its prefill and environment sample,
26
+ # --prefill overrides single keys (e.g. „Gemeinsames Sorgerecht“ + reduced-fee request).
27
+ plexus-cli offer form scenario add --dir <bundle> --id joint-reduced-fee --from joint \
28
+ --label "Gemeinsames Sorgerecht mit Gebührenreduzierung" --label-en "Joint custody with reduced fee" \
29
+ --prefill elternteil_1.financialAid=true
30
+
31
+ # Edit: --prefill MERGES, --prefill-json REPLACES, --unset removes keys.
32
+ plexus-cli offer form scenario set --dir <bundle> --id standard --prefill kind.vorname=Lea --unset agb
33
+ plexus-cli offer form scenario rm --dir <bundle> --id geschwister
34
+
35
+ # Environment: declare the keys the form reads AND sample them for every scenario in ONE
36
+ # step (the gate refuses a declared key a scenario does not sample, and a sample of an
37
+ # undeclared key — so the two cannot be done one after the other).
38
+ plexus-cli offer form env set --dir <bundle> --keys stage \
39
+ --sample 'standard={"stage":{"key":"klasse-1","name":"Klasse 1","gradeLevel":1}}'
40
+
41
+ # The applicant's language and the intake channel are ONE key, `applicant`:
42
+ plexus-cli offer form env set --dir <bundle> --keys stage,applicant \
43
+ --sample 'standard={"stage":{"key":"klasse-1","name":"Klasse 1","gradeLevel":1},"applicant":{"language":"de","channel":"self-service"}}'
44
+
45
+ # Remove the declaration (the scenarios' samples of dropped keys go with it):
46
+ plexus-cli offer form env set --dir <bundle> --keys ""
47
+ ```
48
+
49
+ Environment keys: `offer`, `unit`, `stage`, `run`, `applicant` — the one vocabulary,
50
+ listed with labels in the IDE tab „Umgebungsvariablen".
51
+ A sample carries every field of its key (`stage` = `key`, `name`, `gradeLevel`;
52
+ `applicant` = `language`, `channel`), an empty one as `null`. `stage.key` is the
53
+ BUNDLE key (`program.stages[].key`).
54
+
55
+ **No fallback.** `$env.applicant.language` is EMPTY (`null`) for an application
56
+ without an applicant account (started by staff); `$env.stage.gradeLevel` and
57
+ `$env.run.startDate` can be empty too. `==` is false over null and needs nothing; `!=`
58
+ and `!` are TRUE over null, so the gate refuses them without a guard
59
+ (`[env/unguardedNullable]`) — write what an empty value means:
60
+ `$env.applicant.language != null && $env.applicant.language != 'de'`. The names
61
+ (`offer.title`, `unit.name`, `stage.name`, `run.name`) are in the applicant's language,
62
+ German without an applicant account (declared at the variable).
63
+
64
+ ## Verify
65
+ ```bash
66
+ plexus-cli offer validate --dir <bundle> # scenarios are submitted through the real submit gate
67
+ plexus-cli offer form show --dir <bundle>
68
+ ```
69
+
70
+ After publishing, run a scenario on a test application from the terminal:
71
+ `plexus-cli --env staging --tenant <t> test-actions run --application <id> --action load-scenario:<scenarioId>`
72
+ — see [Drive a test application through test mode](../test-actions/drive-a-test-application.md).
73
+
74
+ ## Idempotency / gotchas
75
+ - A scenario that leaves a visible required field empty, or reaches no outcome, is
76
+ refused (`scenario.required-field-unfilled` / `scenario.no-outcome`). docRef fields are
77
+ never prefilled — test mode stubs files itself.
78
+ - A bundle exported before `$env` replaced `context` carries `context` (declaration and samples) and
79
+ `$ctx.*` expressions: refused (`[env/retiredField]`, unknown identifier). Export
80
+ again — the stored definitions on the server already use `$env`.
81
+ - `--from` copies prefill and environment sample, not the base's description (it describes the base);
82
+ `--from` with `--prefill-json` is refused — that would discard the copy. The copy is a
83
+ snapshot: a later change to the base does not reach the variant.
84
+ - File fields need no prefill: with „Automatisch absenden“ (default on) loading a scenario
85
+ fills every docRef field of the form's sections and groups with an openable stub file
86
+ (named after the field, of a type the field accepts) and then submits — also the fields
87
+ the scenario only makes visible (income proofs behind `financialAid=true`). The fill runs
88
+ as a scheduled action, so the submit lands a moment later. Without auto-submit, run the
89
+ test action „File-Felder mit Dummy füllen“ (`auto-fill-files`) yourself. A docRef inside
90
+ a repeated block is not filled.
91
+ - `--prefill` names an unknown key → refused; so does a docRef or an address key (use
92
+ `--prefill-json` for the address).
93
+ - Label and description need their English in the same call (`--label-en`,
94
+ `--description-en`); see [Edit a SmartForm in depth](./edit-smartform.md) → English texts.
95
+
96
+ ## Related
97
+ - [Author a SmartForm as code](./author-smartform.md) · [Edit a SmartForm in depth](./edit-smartform.md)