@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,90 @@
1
+ # Choose which mail a school sends (mail level)
2
+
3
+ > **Version check first.** A deployment on a release older than the mail level
4
+ > ignores `--field mailLevel` WITHOUT an error and keeps reading the retired
5
+ > `tenant.enabledMailCategories` (`[]` = sign-in only, every category = all).
6
+ > If `config export` shows no `mailLevel`, stop and ask the platform operator.
7
+ > The same skew means a tenant scaffolded with a newer CLI against an older
8
+ > deployment starts at sign-in mail only.
9
+
10
+ **When to use:** a school wants only the sign-in links for FAMILIES for now
11
+ („Anmeldelinks ja, alles andere nein"), or is ready for every mail.
12
+
13
+ **The level is family mail only.**
14
+ Letters to the school's own staff (`audience: "internal"`: new tasks, passed
15
+ deadlines, family messages, mentions) go out at BOTH levels; each staff member
16
+ decides them in „Einstellungen" (personal categories, runbook „Which mail a
17
+ staff member wants"). Do not use the level to quiet staff mail.
18
+
19
+ **Current stance:** every tenant — prod, staging and the `test` demo tenant —
20
+ runs `loginOnly` until the platform operator decides otherwise. Switching a real
21
+ school to `all` is that decision, not a routine step. Locally, a reseeded `test` tenant is at `loginOnly` too; to see a
22
+ rendered admission, family-task or school-chat letter set it to `all` on your
23
+ own deployment (`--env local`). Staff letters render at both levels.
24
+
25
+ ## What it is
26
+
27
+ - A school has ONE setting, its **mail level** (`tenant.mailLevel`, required in
28
+ every tenant config):
29
+ - `loginOnly` — „An Familien nur Anmeldelinks": to families, sign-in links,
30
+ the access a school hands out, address confirmations („Bewerbung
31
+ begonnen/geparkt" included) and security notices about one's own account
32
+ (e.g. a changed login address).
33
+ - `all` — „Alle E-Mails": to families additionally admission, family task
34
+ reminders and the school's chat messages.
35
+ - Every letter still declares a **category** in code (`login`, `security`,
36
+ `admission`, `tasks`, `schoolMessages`; for staff the personal `staffTasks`,
37
+ `staffAppointments`, `staffDeadlines`, `familyMessages`, `mentions`). It names
38
+ the letter in a skipped delivery's reason; a school cannot switch single
39
+ categories.
40
+ - The `notifications` module must be enabled for `all`; with the module off only
41
+ sign-in and security mail go out whatever the level says.
42
+ - Refused by `config set` / `config apply`: a missing or unknown level
43
+ (`[notification/mailLevel]`), `all` without the `notifications` module
44
+ (`[notification/levelWithoutModule]`), and the retired spellings
45
+ `tenant.enabledMailCategories` (`[notification/mailCategoriesRetired]`)
46
+ and `tenant.mailEnabled` (`[notification/mailEnabledRetired]`) — a scratch
47
+ export from before the mail level must be rewritten, not applied.
48
+ - `MAIL_DELIVERY_MODE` of the deployment stays the outer boundary. Never change
49
+ it for this.
50
+
51
+ ## Set it (dry run first)
52
+
53
+ ```bash
54
+ # sign-in mail only
55
+ plexus-cli --env staging config set --tenant <slug> --target tenant \
56
+ --key <slug> --field mailLevel --value loginOnly
57
+ plexus-cli --env staging config set --tenant <slug> --target tenant \
58
+ --key <slug> --field mailLevel --value loginOnly --confirm
59
+
60
+ # everything (what the introduction of the level wrote for every school that sent mail)
61
+ plexus-cli --env staging config set --tenant <slug> --target tenant \
62
+ --key <slug> --field mailLevel --value all --confirm
63
+ ```
64
+
65
+ Through a scratch export instead: `config export` → set
66
+ `"tenant": { …, "mailLevel": "loginOnly" }` → `config apply --from snapshot:…`.
67
+
68
+ ## Verify
69
+
70
+ - `plexus-cli --env <env> config export --tenant <slug>` shows `mailLevel`
71
+ (always emitted).
72
+ - Platform rail → **Mandanten** (needs `_platform.config.read`): the column
73
+ „E-Mails" says „Nur Anmeldelinks" (amber) or „Alle E-Mails" — as the send
74
+ path answers it, so module off reads „Nur Anmeldelinks" too.
75
+ - Dashboard of the school: banner „Hinweis zur Konfiguration" on top of the
76
+ page, gap `mailDisabled` („Diese Schule versendet nur Anmeldelinks …") —
77
+ unless the school hides that notice: `config set --tenant <slug> --target tenant
78
+ --key <slug> --field hideMailLevelNotice --value true --confirm` (mail-module setting, shown under
79
+ „notifications" in Platform → Mandanten; the test tenant does). Only this
80
+ notice goes; a configuration error always shows.
81
+ - A suppressed letter is a row: `plexus-cli --env <env> notifications …` lists it
82
+ `skipped`, `tenant_delivery_disabled`, last error
83
+ `the school's mail level does not send "<category>" mail`.
84
+
85
+ ## Gotchas
86
+
87
+ - A skipped delivery is never re-sent when the level goes back to `all` — the
88
+ setting decides at enqueue AND at send time, not retroactively.
89
+ - `applicant.accessSent` („Zugang versenden → Per Mail senden") is `login`: it
90
+ goes out at `loginOnly`.
@@ -0,0 +1,155 @@
1
+ # Reword a letter for one school
2
+
3
+ **When to use:** a school wants one of the mails Plexus sends in its name to say
4
+ something else — a different opening paragraph, its own sign-off — and you want
5
+ to prove the new words actually reach a rendered mail before anyone relies on it.
6
+
7
+ ## Prerequisites
8
+
9
+ - `--tenant <slug>` on every command: a letter is reworded **per school**, never
10
+ platform-wide.
11
+ - Permissions `notifications._self.access` (read) and
12
+ `notifications.mail.configure` (write). The local CLI bootstrap has both.
13
+ - Locally: a running local development deployment (`--env local`). **Nothing is
14
+ ever sent from a local deployment** (`MAIL_DELIVERY_MODE=dry`) — the rendered mail
15
+ lands in `notification_previews`, which is exactly what makes this verifiable.
16
+
17
+ ## Steps
18
+
19
+ ```bash
20
+ # 1. What can this school reword, and what has it already changed?
21
+ plexus-cli --env local --tenant test notifications letters list
22
+
23
+ # 2. One letter, block by block, with the placeholders the LETTER may use
24
+ # printed above them — they belong to the letter, not to one paragraph, so a
25
+ # school may move a value into whichever block it likes. They are an
26
+ # ALLOWLIST: an undeclared {{x}} is refused, never silently printed. One
27
+ # marked "(Pflicht)" must appear somewhere in the BODY or the save is refused
28
+ # — that is what replaced the lock on the two sign-in letters.
29
+ plexus-cli --env local --tenant test notifications letters show applicant.tasksOpen --locale de
30
+
31
+ # 3. Reword ONE block, in ONE language. Untouched blocks are not written and
32
+ # keep following the platform text of that language; the other language is
33
+ # not touched at all (a German rewording never words the English letter).
34
+ plexus-cli --env local --tenant test notifications letters set applicant.tasksOpen --locale de \
35
+ --slot intro="bei {{schoolName}} warten {{taskCount}} Aufgaben auf Sie."
36
+ plexus-cli --env local --tenant test notifications letters set applicant.tasksOpen --locale en \
37
+ --slot intro="{{taskCount}} tasks at {{schoolName}} are waiting for you."
38
+
39
+ # A paragraph with line breaks belongs in a file, not in a shell argument:
40
+ # plexus-cli --env local --tenant test notifications letters set applicant.tasksOpen --locale de \
41
+ # --slot-file intro=./intro.txt
42
+
43
+ # 4. The shared letter frame (greeting, sign-off, sender name) is separate —
44
+ # it is the same frame for EVERY letter this school sends, worded in both
45
+ # languages at once (each letter carries its own language's line).
46
+ plexus-cli --env local --tenant test notifications chrome show
47
+ plexus-cli --env local --tenant test notifications chrome set \
48
+ --closing-de="Herzliche Grüße
49
+ Ihr Grundschul-Team" \
50
+ --closing-en="Warm regards
51
+ Your primary school team"
52
+ ```
53
+
54
+ ## Verify
55
+
56
+ Reading back the catalog only proves the row was stored. What has to be proven is
57
+ that the words reached a **rendered mail**:
58
+
59
+ ```bash
60
+ # a) The stored deviation
61
+ plexus-cli --env local --tenant test notifications letters list # "reworded: de, en"
62
+ plexus-cli --env local --tenant test notifications letters show applicant.tasksOpen --locale en
63
+
64
+ # b) Trigger the letter (for applicant.tasksOpen: open an external task on an
65
+ # application — e.g. a workflow task addressed to the family), then find its
66
+ # delivery and read the body that was actually rendered:
67
+ plexus-cli --env local notifications list --limit 20
68
+ plexus-cli --env local notifications show <deliveryId> --json | grep -A12 '"letter"'
69
+ ```
70
+
71
+ `letter.text` (and `letter.html`) carry the reworded sentence, and
72
+ `letter.version.reworded` names the blocks in which the school's wording stood,
73
+ `letter.version.locale` the language it went out in — the RECIPIENT ACCOUNT's
74
+ (`users.language`). To see the English letter go out, the recipient's account
75
+ must have chosen English; the preview in Administration → Benachrichtigungen
76
+ shows either language without one. The same rows are the Dev Rail →
77
+ **Mail** panel, which is the human version of the same check (and the place to
78
+ follow a sign-in link this deployment "sent").
79
+
80
+ Roll back — one block, or the whole letter:
81
+
82
+ ```bash
83
+ plexus-cli --env local --tenant test notifications letters reset applicant.tasksOpen --locale de --slot intro
84
+ plexus-cli --env local --tenant test notifications letters reset applicant.tasksOpen --locale en --all
85
+ plexus-cli --env local --tenant test notifications chrome set --closing-de="" --closing-en="" # back to the platform sign-off
86
+ ```
87
+
88
+ The next rendered mail carries the platform text again — no republish, no deploy.
89
+
90
+ ## Idempotency / gotchas
91
+
92
+ - **A deviation is a delta, not a copy.** Only the blocks a school rewrote are
93
+ stored. A block left alone keeps following the code, so a later platform
94
+ improvement to it still reaches that school. Setting a block to *exactly* the
95
+ platform text therefore stores nothing — the command's `deviating blocks` line
96
+ is the backend's answer, and it will not list that block.
97
+ - **`set` merges.** Passing `--slot intro=…` does not wipe a deviation on
98
+ `outro`. The way back is `reset`, which names what to drop.
99
+ - **`reset` without `--slot` needs `--all`.** Dropping every deviation of a
100
+ letter must be typed, not be what happens when a flag is forgotten.
101
+ - **Some letters have no blocks at all.** The two sign-in letters
102
+ (`applicant.magicLink`, `applicant.verifyEmail`) are category `login`: the mail
103
+ that carries a login link is not rewordable by anyone, so it declares nothing
104
+ to reword. They show `— (not editable)` in the list.
105
+ - **An unknown block or placeholder throws, naming every finding.** Nothing is
106
+ written — there is no partial save.
107
+ - **`--locale de|en` is required** on `show`, `set` and `reset`: every
108
+ letter exists in German and English, each worded on its own, and a command
109
+ that silently picked one would reword the letter you were not looking at. A
110
+ recipient gets the language of their own account; a school that rewords only
111
+ the German letter sends its English families the platform's English text —
112
+ the workshop says so on the letter.
113
+ - **This is configuration, not operational state.** A change made here is
114
+ exported, diffed and promoted like any other tenant setting — see
115
+ "Promote the wording to another environment" below. On staging/prod prefer
116
+ the config route so the change is reviewable; the direct `letters set` is for
117
+ local exploration and for a one-off correction with the platform operator watching.
118
+
119
+ ## Promote the wording to another environment
120
+
121
+ The deviations live in the tenant config under `notificationLetters` (identity
122
+ `templateId:locale:scopeKey`) and the frame under
123
+ `tenant.notificationChrome`, so the ordinary config-as-code verbs carry them:
124
+
125
+ ```bash
126
+ # Export → edit → dry-run → confirm, exactly as for any other tenant setting.
127
+ plexus-cli --env staging config export --tenant test --out /tmp/test.json
128
+ # …edit `notificationLetters` / `tenant.notificationChrome` in /tmp/test.json…
129
+ plexus-cli --env staging config apply --tenant test --from snapshot:/tmp/test.json
130
+ plexus-cli --env staging config apply --tenant test --from snapshot:/tmp/test.json --confirm
131
+
132
+ # One block, without an export round trip. The --key IS the identity triple
133
+ # (templateId:locale:scopeKey) — a letter is worded per language and per scope.
134
+ plexus-cli --env staging config set --tenant test \
135
+ --target notification-letters --key "applicant.tasksOpen:de:tenant" \
136
+ --field slots.intro --value "…" --confirm
137
+ ```
138
+
139
+ Two things follow from "a deviation is a delta":
140
+
141
+ - A letter **absent** from `notificationLetters` is a letter this school does not
142
+ deviate from, so applying a config without it takes that letter back to the
143
+ platform text. Removing an entry is how you undo a rewording in config.
144
+ - `config set` **edits an existing entry**; it does not create one. The first
145
+ deviation for a letter comes from `notifications letters set` or from an
146
+ edited export.
147
+
148
+ ## Related
149
+
150
+ - [Debug a mail delivery](./debug-mail-delivery.md) — when the mail did not
151
+ arrive at all (the operator's log, not the school's wording).
152
+ - [Update a tenant's config](../config/update-tenant-config.md) — the export →
153
+ edit → dry-run → apply loop these letters travel in.
154
+ - `plexus-cli notifications letters show --help` (and `set`/`reset`) — the letter /
155
+ block / override commands; a letter showing `— (not editable)` is one a school may not reword.
@@ -0,0 +1,50 @@
1
+ # Trace why someone could not sign in
2
+
3
+ **When:** „I asked for a sign-in link and got nothing", „the link said already
4
+ used", „my new password does not work" — on any environment, after the fact.
5
+
6
+ ## Prerequisites
7
+
8
+ A platform token with `_platform.users.manage` (sessions name IP addresses) and
9
+ `_platform.notifications.monitor` (the delivery list).
10
+
11
+ ## Steps
12
+
13
+ ```bash
14
+ # Everything the platform still knows about the address, oldest first.
15
+ plexus-cli --env prod users login-trail ada@example.com --since 24h
16
+
17
+ # Only the login page's letters to that address, with the relay's answer.
18
+ plexus-cli --env prod notifications list --template account.loginLink --to ada@example.com --since 24h
19
+ ```
20
+
21
+ `login-trail` prints one time-ordered list:
22
+
23
+ | Line | Means |
24
+ | --- | --- |
25
+ | `abgelehnt: <reason>` | the login page sent this KNOWN account no link (`platformAdmin`, `unprovenAddress`, `noSchoolWithOption`) |
26
+ | `Brief account.loginLink: sent — Relay: 250 … queued as <id>` | our relay accepted the mail; the queue id finds it in the relay's log |
27
+ | `Sitzung von <ip> — <browser>` | a sign-in happened. A session seconds after the letter from a foreign network is a **mail scanner** (Exchange Safe Links), not the person |
28
+ | `Passwort gesetzt (zuletzt)` | the credential was last changed then — compare with the failed attempts |
29
+
30
+ ## Reading it (an invented example)
31
+
32
+ - letter `sent` 09:12:05 → session from `203.0.113.7` at 09:12:29, a network the school
33
+ does not use → the person got „link already used": the scanner spent it. A link now opens a page first and only
34
+ its button spends the token, so a scanner no longer uses it up.
35
+ - password set 09:20:41, session from the school's network 09:20:42, failed
36
+ attempts before: the earlier resets went to **another** account (check the
37
+ `Passwort gesetzt` line of the account the person actually uses).
38
+
39
+ ## Gotchas
40
+
41
+ - Not recorded at all (the command lists it under „Nicht erfasst"): rate-limited
42
+ requests, requests for an address without an account (both only in the
43
+ function log, minutes), anything older than 30 days, ended sessions, and what
44
+ happened after our relay (inbox / spam / quarantine / bounce).
45
+ - `sent` in every list means „our relay said 250", never „in the inbox". The
46
+ console's word for it is „Versendet".
47
+
48
+ ## Related
49
+
50
+ - [Debug a mail delivery](./debug-mail-delivery.md)
@@ -0,0 +1,268 @@
1
+ # Add a workflow node to an offer bundle
2
+
3
+ **When to use:** an offer's admission workflow needs another step (an approval
4
+ gate, a waitlist holding node, a selection terminus, another form, …) and you want
5
+ the graph + progress board kept in sync automatically — instead of hand-editing
6
+ `workflow.json` (graph + `progressWiring.mappings`) and `manifest.json`
7
+ (`progressLayout` states + columns) in lockstep.
8
+
9
+ ## Prerequisites
10
+ - **Env / scope:** none for authoring — `offer workflow node add|rm|list` and
11
+ `offer workflow show` are **offline** (read/write the on-disk bundle, no
12
+ deployment). Only the final `offer apply --publish` is online and needs
13
+ `--env <env>` + a token with offer config rights (`plexus-cli --env <env> whoami`).
14
+ - **A bundle on disk:** scaffold one with `offer init`, or `offer export` an
15
+ existing offer into a directory.
16
+ - **Tooling:** an installed, current `plexus-cli` (`plexus-cli offer workflow node add --help`).
17
+ - **Both languages:** a node's label is ONE `{ de, en }` field — `--label` AND
18
+ `--label-en` are required (as `--reason` + `--reason-en` on a collectPayment
19
+ node and `<id>:<Deutsch>:<English>` per `--decision-outcomes` entry).
20
+ - **Supported types:** `form`, `decision`, `review`, `setFlag`, `approval`, `waitlist`,
21
+ `roleAssign`, `join`, `collectPayment`, `completenessCheck`, `countersign`, `engagePush`, `if`, `end`
22
+ (the list `offer workflow node add --help` prints). `decision` is the
23
+ configurable-decision builder (ATS primitives — assign-decision, composed
24
+ tasks, artifacts, decision-as-task); `approval` is a **deprecated alias** for
25
+ `decision` (maps `--decision-outcomes` → outcomes). `setFlag` flips an external
26
+ flag on the form instance it names (`--flag-key` + `--target '$node.<formNode>.admission'`).
27
+ `countersign` („Unterschrift (intern)") is the
28
+ school's countersignature of a checked contract — see
29
+ [Countersign the contract](./countersign-node.md).
30
+ `if` („Wenn") routes on one DSL expression over `$env` (`--condition
31
+ '$env.staffInput.<key>'`, outcomes `true` / `false` — route both with
32
+ `--outcomes`) — see [Branch on a staff input](./staff-input-if-node.md).
33
+ `completenessCheck` waits for outstanding documents (`--require`,
34
+ `--deadline-days`|`--deadline-until`, `--notification requested=…`, optional
35
+ `--guest-role` + `--person`) — see
36
+ [Wait for outstanding documents](./completeness-check.md). The folded
37
+ `governedDocument` (offer letter / contract) cannot be added by the CLI yet.
38
+ A `form` node binds a SmartForm via `--form` / `--new-form` (see
39
+ [Attach a form to a workflow](./attach-form-to-workflow.md)).
40
+ - **Letters with the node:** `--notification <event>=<templateId>` (repeatable)
41
+ binds a letter in the same call — required when the type declares the event
42
+ required (completenessCheck `requested`, countersign `countersigned`), since
43
+ `add` validates before writing.
44
+ - **Addresses with the node**: every flag that reads another node's data
45
+ takes a full address naming its producer, in single quotes — `--reads` /
46
+ `--require` (`'$node.<formNode>.admission.<field>'`), `--person`
47
+ (`'$node.<formNode>.admission.beneficiary'`), `--target` / `--adopt-form`
48
+ (`'$node.<formNode>.admission'`), `--contract`, engagePush `--form` / `--payment`.
49
+ A bare key is refused with the addresses visible at the new node; `add` binds
50
+ NOTHING implicitly. Bundles exported before addresses were introduced (with `wires`) are refused —
51
+ export again. See [Name what a node reads](./addresses.md).
52
+
53
+ ### Payment gate (`collectPayment`)
54
+ `--article <key>` and `--settlement online|separate` are **required** (no
55
+ default); both are written as the node's parameters (`inputs.article`,
56
+ `inputs.settlement` — the editor lists them under „Parameter"). The article is
57
+ a LINK: the key (`billing_item_definitions.configKey`) of a `one_time`
58
+ article assigned to the offer's program (Finanzen → Artikel, directly or through
59
+ an offered package). Amount, currency and the Stripe line name are read from the
60
+ article at runtime — the node stores no amount, and publish refuses `amount` /
61
+ `currency` on it (`[collectPayment/retiredField]`) as well as an unknown,
62
+ non-one-time or out-of-program article. A price change in Finanzen applies to
63
+ the next payment without a republish. `online`: the family gets a „Zahlung leisten" request row and pays
64
+ through Stripe Checkout; the process responsible can also close it
65
+ „Ohne Online-Zahlung abschließen" with a required reason (e.g. on an
66
+ installation without Stripe). `separate`: the school bills
67
+ the fee outside Plexus — nothing is asked of the family (no row, no letter; the
68
+ portal says „Die Anmeldegebühr wird separat abgerechnet."), and the process
69
+ responsible closes the step in the cockpit with **„Zahlung überspringen"**
70
+ (optional comment, feed entry „Zahlung übersprungen", nothing booked). The
71
+ routing edge is `paid` either way. Switch an existing node with
72
+ `offer workflow node set --id <node> --settlement separate` (or
73
+ `--article <key>` to charge another article); a `requested`
74
+ letter bound on a `separate` node is refused (`[notification/unknownEvent]`).
75
+ Applications already parked on the node stay on the version they are pinned
76
+ to — moving them onto the new version after `apply --publish` needs a platform
77
+ operator.
78
+
79
+ ### Terminal, fan-in, and role nodes
80
+ - **`end --outcome <outcome> --data-decision <commit|discard>`** authors a
81
+ terminal node's application outcome (`approved` | `rejected` | `withdrawn` |
82
+ `waitlist` | `expired`; default `approved`) and — required, no default —
83
+ whether the application's data is kept (`commit`) or discarded (`discard`)
84
+ at this end (the outcome is only a label and never decides it). It
85
+ writes the `terminalStatus` / `applicationOutcome` / `dataDecision` /
86
+ `terminationKind` literal inputs the end executor reads (fixing the old gap
87
+ where every CLI-authored end recorded `approved`), and contributes **one**
88
+ outcome-aware terminal column (e.g. a rejected end adds a red *Abgesagt*
89
+ column, not a misleading green *Eingeschrieben*). A second end is inserted
90
+ isolated — wire predecessors to it with `join --from` or `node set --outcomes`.
91
+ Override the ledger label with `--terminal-status`, the kind with
92
+ `--termination-kind`.
93
+ - **`join --from <nodeId>:<outcome>,…`** authors an explicit XOR fan-in
94
+ convergence. Each `--from` pair reroutes that source node's outcome onto the
95
+ join; the join's own onward edge defaults to the end node (override with
96
+ `--outcomes done:<target>`). Multi-input into a join is the one legal
97
+ many-to-one shape (the publish gate rejects multi-input on non-join nodes).
98
+ - **`roleAssign --role <key>`** grants a person-lifecycle role. `--role` is
99
+ validated **offline** against the manifest's declared `roleInstances` (fail
100
+ loud if absent — declare it first with `offer role add`). `--mode`
101
+ is `add` (default) | `remove` | `replace`; `--person '<address>'` is required —
102
+ the party that gets the role, a party leaf of a form
103
+ (`--person '$node.form.admission.beneficiary'`). Never defaulted.
104
+
105
+ ### Configurable decision node (`decision`)
106
+ The `decision` node is the **one configurable decision node** (it replaced the old
107
+ `approval` node; `--type approval` is refused as an unknown type). By default it
108
+ declares the type outcomes `approved` / `rejected` / `waitlisted`. To author a
109
+ **custom** outcome set (with your own ids + labels), pass `--decision-outcomes
110
+ <id>:<Deutsch>:<English>,…` (an outcome's label is `{ de, en }` — both languages
111
+ are required) and route each with `--outcomes`:
112
+
113
+ ```bash
114
+ # Inquiry decision: direkt aufnehmen / zum Interview / absage.
115
+ plexus-cli offer workflow node add --dir ./demo \
116
+ --type decision --id decisionInquiry --label "Erstentscheidung" --label-en "Initial decision" --after form \
117
+ --decision-outcomes 'direkt:"Direkt aufnehmen":"Admit directly",interview:"Zum Interview":"To the interview",absage:"Absage":"Rejection"' \
118
+ --outcomes 'direkt:formApplication,interview:interview,absage:joinAbsage' \
119
+ --assign-decision zulassung --decision-as-task 'Erstentscheidung treffen'
120
+ ```
121
+
122
+ Each entry becomes a `NodeOutcome {id,label}` on `node.outcomes`
123
+ (first = `isDefault`); the backend executor + publish gate + cockpit UI all validate
124
+ and render decisions against whatever set the node declares. `--decision-outcomes` is
125
+ decision-only (fails loud on any other type). Outcomes whose id maps to a known
126
+ semantic (`direkt`/`accepted`→granted, `absage`/`rejected`→rejected,
127
+ `warteliste`/`waitlisted`→waitlisted) drive the roster chip + annotation mirror; any
128
+ other id (e.g. `interview`) is a real routing decision with no typed annotation.
129
+ Route each declared outcome to a target that exists — the publish gate checks coverage.
130
+
131
+ #### ATS lifecycle primitives on a decision node
132
+ A decision node carries the full offer-lifecycle authoring surface:
133
+
134
+ - **`--assign-decision <groupKey>`** — the permission group whose members hold the
135
+ synthesized decision Work-Item (= may pick the routing outcome, in ONE act).
136
+ Pairs with `--decision-as-task` (each requires the other — fails loud). Absent
137
+ = super-user-only decide (the instance's responsible).
138
+ - **`--comment-required <id>[,<id>]`** — authored outcome ids that require a decider comment.
139
+ - **`--task …`** (repeatable) — a composed task the node spawns on enter,
140
+ instantiating a code-owned kind (the kind carries the behaviour: actions,
141
+ close protocol, respawn, gate default):
142
+ - `key:task:title:group[:dueInDays]` — an internal check task (trivial `complete`).
143
+ - `key:approval:title:group[:revisionGroup]` — an approval; gates the node by
144
+ default. With **`--task-on-reject spawnRevision`** a rejecting verdict
145
+ spawns a revision back to `revisionGroup` (then REQUIRED — fails loud).
146
+ (`binds` is DELETED — the offer arc is node-internal; use the
147
+ `offer` node type for the prepare/approve arc.)
148
+ - **`--artifact key:type:visibility:label`** (repeatable) — a versioned artifact SLOT
149
+ (`visibility` = `internal` | `external`). Each slot exposes a `<key>Uploaded` node-export.
150
+ - **Assignment rule** (GRAPH-level, no flag) — who becomes responsible and who owns
151
+ each team is `graph.assignmentRules` in the same `workflow.json`; `node add/set` do
152
+ not touch it. A node that gives work to a team the rule has no slot for makes
153
+ `offer validate` / publish refuse with `[assignment/slotMissing]` — add a
154
+ `team:<key>` list in the bundle or in the workflow editor („Team-Verantwortliche").
155
+ Recipe: `playbooks/offer/assignment-rule.md`.
156
+ - **`--decision-as-task title[:dueInDays]`** — surface the decision as a
157
+ first-class queue Work-Item. Requires `--assign-decision` (and vice versa).
158
+ Combining with gating approval tasks is fine: the HARD node gate blocks a
159
+ non-rejecting advance server-side while gate-effective rows are open.
160
+
161
+ ```bash
162
+ # Offer create-phase (mirrors the grundschule createOffer node): external artifact,
163
+ # a prepare task, a finance approval gate with a ping-pong revision on reject, and
164
+ # the Schulleitung send-offer decision row (hard-gated on the finance approval).
165
+ plexus-cli offer workflow node add --dir ./demo \
166
+ --type decision --id createOffer --label "Angebot erstellen" --label-en "Prepare offer" --after form \
167
+ --decision-outcomes 'offerReady:"Angebot freigegeben",absage:"Angebot abgebrochen"' \
168
+ --assign-decision schulleitung --decision-as-task 'Angebot versenden:3' \
169
+ --artifact 'offer:offer:external:Angebotsschreiben' \
170
+ --task 'prepareOffer:checkTask:Angebotsschreiben vorbereiten:schulleitung:5' \
171
+ --task 'offerApproval:approval:Angebot freigeben:finanzen:offer:offerUploaded:schulleitung' \
172
+ --task-on-reject spawnRevision
173
+
174
+ # Task-driven selection decision (grundschule approvalSchulleitung), gate-less:
175
+ plexus-cli offer workflow node add --dir ./demo \
176
+ --type decision --id freigabe --label "Freigabe" --label-en "Approval" --after form \
177
+ --decision-outcomes 'approved:Freigegeben,rejected:Abgelehnt,waitlisted:Warteliste' \
178
+ --assign-decision schulleitung \
179
+ --comment-required rejected \
180
+ --decision-as-task 'Über Freigabe entscheiden:5'
181
+ ```
182
+
183
+ All of the above is validated by the REAL publish gate on `offer validate`; the CLI adds
184
+ fail-loud cross-checks at the flag boundary (undeclared bound artifact, `spawnRevision`
185
+ without `revisionGroup`, `decision-as-task` ⇔ `assign-decision` pairing).
186
+
187
+ ## Steps
188
+ ```bash
189
+ # 1. Scaffold a minimal linear offer (start → form → end).
190
+ plexus-cli offer init --slug demo --title "Demo Schule" --title-en "Demo School" --tenant test --responsible sekretariat --out ./demo
191
+
192
+ # 2. Inspect the graph.
193
+ plexus-cli offer workflow node list --dir ./demo
194
+ plexus-cli offer workflow show --dir ./demo
195
+
196
+ # 3. Insert a decision gate between the form and the end node.
197
+ # --outcomes routes each declared outcome; any omitted outcome defaults to
198
+ # where --after currently points (here end), splicing into the chain.
199
+ plexus-cli offer workflow node add --dir ./demo \
200
+ --type decision --id approval --label "Freigabe" --label-en "Approval" --after form \
201
+ --assign-decision freigeber --decision-as-task 'Freigabe entscheiden' \
202
+ --outcomes approved:end,rejected:end
203
+
204
+ # 4. Validate offline against the REAL publish gate (add already ran it; this is
205
+ # the explicit re-check / CI gate).
206
+ plexus-cli offer validate --dir ./demo
207
+
208
+ # 5. Apply + publish (online). Needs --env + a token; cuts new versions on diff.
209
+ plexus-cli --env <env> --tenant test offer apply --dir ./demo --publish
210
+ ```
211
+
212
+ - **`--id`** is optional — defaults to the type, numbered from 2 when taken
213
+ (`approval`, `approval2`, …; `makeNodeId`). An id you pass must be a dot-free
214
+ identifier (`^[a-z][A-Za-z0-9]*$`, e.g. `decisionInquiry`) — `offer validate`
215
+ refuses anything else as `[graph/nodeIdFormat]`.
216
+ - **`--after <nodeId>`** inserts after that node and rewires its onward edge(s)
217
+ to the new node. Omit it to insert immediately before the end node.
218
+ - **`--type form`** binds a SmartForm — `--form <slug>` (existing) or
219
+ `--new-form --form <slug>` (scaffold + bind). See the dedicated playbook.
220
+ - **Remove** a node (rewires predecessors onto its single successor, strips its
221
+ progress entries, re-validates; refused while an address elsewhere names it):
222
+ ```bash
223
+ plexus-cli offer workflow node rm --dir ./demo --id approval
224
+ ```
225
+
226
+ ## Verify
227
+ ```bash
228
+ plexus-cli offer workflow node list --dir ./demo # the node appears, routing is wired
229
+ plexus-cli offer validate --dir ./demo # exit 0 = publish gate clean
230
+ ```
231
+
232
+ For the approval example, `offer validate` exits 0 with **no manual manifest
233
+ edit**: `offer workflow node add` materialised the `approval.released` process
234
+ state, its board column, and the `(approval, released) → process/approval.released`
235
+ wiring mapping. A decision that reads nothing of the form names no address
236
+ (`offer workflow node reads --dir ./demo --node approval` lists nothing). After `offer workflow node rm`, the graph returns to the linear
237
+ `form → end` shape and validation passes again.
238
+
239
+ ## Idempotency / gotchas
240
+ - **`add` validates before writing.** Any error-severity issue from the real
241
+ publish gate (`runWorkflowValidators` etc.) blocks the write and exits 1 — the
242
+ bundle is left untouched. A failed `add` never leaves a half-spliced bundle.
243
+ - **Splice defaults can over-route.** Every declared outcome you DON'T name in
244
+ `--outcomes` defaults to the anchor's current onward target. Inserting a
245
+ multi-outcome node (endauswahl, approval) after a node that points at the end
246
+ node routes *all* its outcomes to the end node — pass `--outcomes` to fan them
247
+ out (e.g. `warteliste:waitlist`).
248
+ - **Nothing is bound implicitly.** A node reads a form only at the address it
249
+ names (`$node.<formNode>.admission.<field>`), and the producer must run on every
250
+ path before it (`[address/notVisible]` otherwise). A `form` node's `smartForm` is
251
+ written as a `{ kind: "literal", value: <slug> }`; with
252
+ `--adopt-form '$node.<formNode>.admission'` it continues that instance instead
253
+ (`adoptForm` holds the address; the adopting node produces nothing — later
254
+ nodes still read the creator's address). Explicit-by-default, matching `offer init`.
255
+ - **`rm` refuses** to remove the start (`eventTrigger`) or `end` node, a node with
256
+ multiple distinct successors (ambiguous splice), and a node any address elsewhere
257
+ still names (its form instance, document, contract, payment, or a value one of
258
+ its tasks writes) — the refusal lists every naming place (config path +
259
+ address); edit those first. Orphans surface at re-validate and block the write.
260
+ - **Progress labels/tones** come from one built-in default table, shared with the
261
+ `offer init` scaffold. An event with no default falls back to its name +
262
+ neutral tone and prints a warning.
263
+
264
+ ## Related
265
+ - [Attach a form to a workflow](./attach-form-to-workflow.md) — the `--type form` flow.
266
+ - [Author a SmartForm as code](./author-smartform.md) — build the form itself.
267
+ - `plexus-cli offer --help` — the full `offer …` command reference; `offer validate`
268
+ runs the same publish-gate validators as `offer workflow node add`.