npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@smartkomm/plexus-cli

v0.1.142

Published

Unified Plexus admin + admission-offer config CLI (Convex backend).

Readme

plexus-cli — the unified Plexus admin + config CLI

One pure-TypeScript CLI for operating and configuring Plexus against any environment (local / staging / prod) — runtime ops, introspection, and admission-offer config-as-code from a single binary. The system command is plexus-cli.

Install (system tool)

Published publicly on npmjs.org as @smartkomm/plexus-cli (Node.js 22 or newer); install it globally and run from anywhere:

npm i -g @smartkomm/plexus-cli

plexus-cli --help              # all commands, grouped by category
plexus-cli <command> --help    # per-command help (clipanion auto-generates it)

The command is plexus-cli; only the package name is scoped (plexus-cli on npm belongs to someone else). The package holds no credential — every call needs your own plx_… token.

The agent skill

plexus-cli agent tools --json exposes the shared agent tool schemas. plexus-cli agent invoke --tool <name> --json accepts tool input as JSON on stdin. Commands use the same parser as ordinary CLI invocations; workspace files are relative to the current directory. See the shared-tools recipe.

On every start the installed CLI keeps an agent skill current for Claude Code (~/.claude/skills/plexus-cli/) and Codex (~/.codex/skills/plexus-cli/) — whichever of the two exists: SKILL.md plus every playbook as references/, each marked with the right it needs (tenant token or platform operator). A directory of that name the CLI did not write is left alone (one warning); remove it to get the bundled skill. A failed write warns once and never fails the command. The installed directory belongs to the CLI: an edit inside it is replaced by the next release. PLEXUS_CLI_NO_SKILL=1 switches the installation off (a development build, a CI machine).

Or link the working tree as the global binary (dev machines)

Instead of the published package you can point the global plexus-cli at your local build — from the primary checkout only (never a worktree):

cd <primary checkout> && pnpm --dir app/tools/plexus-cli link:global

This installs the app's and the tool's dependencies from their lockfiles (pnpm install --frozen-lockfile — a stale node_modules would build against the wrong packages), builds dist/cli.mjs and symlinks ~/.local/bin/plexus-cli at it. The symlink tracks the checkout, so a later pnpm build refreshes the binary. A local build reports plexus-cli --version as <floor>-dev+<commit> (e.g. 0.1.0-dev+1bf2c2c77); only the publish workflow's build carries a release number. link:global refuses to run inside a worktree — a worktree's dist/ is deleted on teardown, which would leave a dead symlink (the recurring "No such file" breakage).

Develop in-repo

The CLI lives in app/tools/plexus-cli/. To run the working tree (not the published build) against a backend — this is also how you test CLI code changes against a specific worktree's backend (run it from inside that worktree; --env local follows your cwd), without touching the global binary:

cd app/tools/plexus-cli
pnpm install                          # one-time: tool deps (clipanion, esbuild, tsx…)
pnpm dev -- <command> [...flags]      # = node --import tsx cli.ts
pnpm build                            # → dist/cli.mjs (the published bundle), version <floor>-dev+<commit>
pnpm link:global                      # (primary checkout only) frozen install + build + symlink the global binary
pnpm typecheck                        # tsc against the live backend (build-from-truth gate)
pnpm test                             # node --import tsx --test (from app/: `pnpm test:cli` — same files, capped and recordable)
pnpm spec                             # regenerate spec.snapshot.json from the code (no backend)

How it's built — in-repo source, bundled to ship

The CLI imports the backend's real types, the generated api, and the publish-gate validators directly from app/convex/**. The publish workflow type-checks against that live tree and bundles it (esbuild) into a standalone artifact — so the benefits are baked in at build time:

  1. Typed api, zero drift — every call's args + returns are inferred end-to-end from the live function definitions; the published bundle is the real code, type-checked and versioned, never a hand-maintained re-derivation.
  2. no-explicit-any everywhere — the call surface is fully typed; the only unavoidable casts are as unknown as T (admin auth, the generic call).
  3. Offline validation against the real publish gate — offer validate runs the same runWorkflowValidators / runFormValidators the server runs, inlined into the bundle (works on any machine, no repo, no deployment).

The only caveats: the installed CLI is a point-in-time release (rebuild/republish to pick up backend changes), and --env local resolves the deployment from your current worktree (run it from inside a checkout) — the staging/prod token path works anywhere.

Auth & environments

Every connection value resolves by ONE rule: flag (--env, --tenant) → env var (PLEXUS_*) → ~/.config/agent-tools/plexus.yml → built-in default (local). The env vars apply with or without the file, to the environment that was selected:

# ~/.config/agent-tools/plexus.yml
plexus:
  default_env: local
  default_tenant: test
  environments:
    local:   {}   # DYNAMIC — follows your current rdu worktree (see below); no values needed
    staging: { convex_url: "<staging Convex URL>", site_url: "<staging site URL>", token: "plx_…" }   # from the platform operator
    prod:    { convex_url: "https://db.plexus.swop.schule",         site_url: "https://actions.db.plexus.swop.schule",         token: "plx_…" }

| Value | Flag | Env var | File key | |---|---|---|---| | environment | --env | PLEXUS_ENV | default_env | | tenant | --tenant | PLEXUS_TENANT | default_tenant | | Convex URL | — | PLEXUS_CONVEX_URL | environments.<env>.convex_url | | site URL | — | PLEXUS_SITE_URL | environments.<env>.site_url | | API token | — | PLEXUS_TOKEN | environments.<env>.token |

The file has a fixed schema: under plexus: only default_env, default_tenant, environments; per environment only convex_url, site_url, token and admin_key (kept there for direct Convex admin work — the CLI accepts it but never reads or prints it). Any other key, a non-string value or a URL that does not parse is refused with the file path. Every environment other than local must name both convex_url and site_url (file or env var) — nothing is inferred. local exists even when the file does not list it. Mind that an exported PLEXUS_CONVEX_URL/PLEXUS_TOKEN applies to WHICHEVER environment is selected, --env prod included; production is still recognized by the resolved URL (the file's prod URL), so a write there needs --allow-prod. config show lists the env vars that supplied a value.

Token path (faithful): a plx_… API token (mint one in the app under Settings → API-Token) is exchanged at {site_url}/cli/token for a short-lived RS256 JWT, cached at ~/.config/agent-tools/plexus-jwt-<env>-<hash>.json (mode 0600; the hash covers env, Convex URL and token, so a repointed environment or a new token never reuses another's JWT). The JWT is renewed before it expires; a call Convex rejected for its token runs again once with a fresh one, but an action that already ran is never re-run. Every call lands as the real authenticated user through the public API + its gates.

Local path (--env local): zero config — the CLI follows whichever rdu worktree you run it from. It resolves that deployment's URL from the worktree's .env files and bootstraps a deterministic platform-admin token with the rdu admin key (apiTokens:devSeedToken, DEV_MODE-only), then takes the same token→JWT path. The cwd decides the target deployment, not where the binary was installed: outside a started worktree (no app/.env.local next to app/convex) --env local refuses and says to run pnpm dev there; PLEXUS_LOCAL_INSTALL_ROOT=1 is the explicit opt-in to use the install's own checkout instead. Run plexus-cli --env local config show to see the resolved deployment + authMode (local-bootstrap). Full workflow + gotchas: docs/engineering/plexus-cli.md §"Local dev across rdu worktrees".

Verify it works

Each step proves one layer; the first two need no network and no token:

plexus-cli config show        # resolved env, URLs, tenant, masked token, authMode — no network
plexus-cli functions | head   # offline function catalog
plexus-cli whoami             # token → JWT → identity: confirms auth
plexus-cli tenants            # the tenants your identity reaches (real data)

| Symptom | Cause / fix | |---|---| | plexus-cli: command not found | Not installed — npm i -g @smartkomm/plexus-cli. | | TokenExchangeError / 401 on whoami | Token missing, expired or revoked — mint a new one (Settings → API-Token) and put it into plexus.yml. Do not retry blindly. | | A function call fails on its arguments | plexus-cli describe <module:fn> shows the exact argument names (client vs tenant differ across functions). | | Wrong or empty data | plexus-cli config show — the call may go to another --env than meant. |

Commands

Every command, its options and whether it writes: skill/references/commands.md — generated from the registered commands (pnpm commands; a test fails while it is stale), the same text plexus-cli <command> --help prints. Copy-paste recipes: playbooks/. This table names each group and the rules its --help does not carry.

| Group | What it covers · rules beyond --help | |---|---| | offline | config show, functions, describe, offer validate and every bundle editor (offer form …, offer workflow …, offer manifest …) run without a deployment. | | introspection | functions / describe read the shipped function catalog; call <fn> key=value… is the escape hatch to any public function — see "Generic escape hatch". | | identity | whoami; tenants lists every tenant for a platform admin. | | org | Units and Träger by config key (org tree, org unit …, org legal-entity …); delete plans unless --confirm. | | catalog | Articles, packages, need-based tiers and Word templates by stable key; a DOCX is replaced only when its SHA-256 differs; export → plan is same — set-up-billing-catalog. | | applications | Read applications (applications list, applications counts per list, applications show) and their process store; start and discard test applications (every staff input the offer declares, once each) — start-a-test-application, inspect-process-store. | | work-items | A tenant's tasks: list, show, create a manual one, assign, complete — manage-work-items. | | retention | Applications and persons past their retention period, erased by hand only (--confirm --expect <n>); nothing erases on a schedule — erase-due-applications, erase-due-parties. | | test-actions | The test-mode popover of a test application (scenarios, auto-fill-files, submit-form, …); --param values are JSON like call's — drive-a-test-application. | | tokens | Minting needs _platform.apiTokens.manage or the tenant right administration.apiTokens.manage (every tenant_admin holds it); list and revoke your own always work. Minting FOR someone needs _platform.users.manage and _platform.apiTokens.manage — cli-access-for-tenant-admin. | | users | A school's permission groups and memberships, with the school's token: users groups, users groups delete; a membership is given and taken with tenant-users groups add\|revoke (users set-group / users unset-group are retired spellings; removing is refused while a wip or published workflow's assignment rule names the person — replace them first: „Person ersetzen" in the workflow editor, then publish; --replace-with <configKey> hands their active applications over and never rewrites a rule). Every other user command is the platform's — platform users … below. | | tenant-users | A school's own user administration (Administration → Nutzer), reachable with a school's token (administration.users.manage): list, show, history, deactivate / archive (plan with the handover unless --confirm; --successor takes the open work), reactivate, invite (plan unless --confirm; always mails a one-time sign-in link, --group by key needs administration.permissions.manage too), set-key (the config key rules and config name the person by; plan unless --confirm; invite --key too — set-staff-config-key), groups add\|revoke (by group and unit key, needs administration.permissions.manage; revoke plans unless --confirm; users set-group / users unset-group are retired spellings), send-login-link, send-password-reset, notifications list\|set, signature request. A person is named by address or account id, never by name. Separate from users, the platform's cross-tenant group (platform users archive ends access in every school), because --tenant is filled by default_tenant and cannot tell „this school" from „everywhere" — manage-school-users. | | platform | Everything only a platform operator may do — see "Platform vs school commands": platform users … (every account across tenants: list, show, grant, grant-all, revoke — the --tenant only —, archive everywhere, provision, create, set-password, set-email, set-platform-admin, set-platform-permission, doctor, relink-auth, login-trail; a password comes only through --password-stdin / --password-file, --out <csv> appends the credential row, see "Provisioning admins"), platform workers list\|logs\|reaper-status\|reap, platform notifications list\|show\|retry\|sweeper-status\|self-test (the delivery monitor), platform imports trigger-all\|cleanup-orphaned, platform persons purge, platform signing connection set\|activate\|reseal\|delete and platform signing status, platform generator input-key … — debug-worker-jobs. | | persons · leads | Person search; platform persons purge is the only hard delete of a person (plan unless --confirm) — purge-deleted-person; a tenant's open leads (Interessenten) — list-leads. | | admission | admission runs list; assignment simulate evaluates an offer bundle's assignment rule offline — assignment-rule. | | generator | generator list, generator scope (unit and declared Träger of a template), the platform-wide input registry (generator input-key …, no tenant) and contract packages from one file (generator package plan\|apply --file: unreviewed scenario outputs are written into the file, the next run seals). Authoring one generator revision (source, scenarios, seal) happens in the web workspace — manage-contract-resolver, manage-contract-package. | | imports | trigger / trigger-all print the plan and start nothing unless --confirm (--probe --confirm starts test jobs); the Engage password comes via --engage-password-stdin, --engage-password-file or PLEXUS_ENGAGE_PASSWORD, never as an argument. | | feedback | The feedback board: file, assign, tag, reply, status, deadline, links, GitHub issues — create-and-triage-feedback, work-a-feedback-item, manage-feedback-tags. | | demo | Personalised links to the public demo video page and their statistics (a visit counts once the page was visible 5 s or the visitor interacted; % = how much the best single visit played). Needs _platform.demoLinks.manage — demo-video-links. | | notifications | The school's wording (notifications letters …, notifications chrome …) — see "The words a school sends"; why a sign-in failed: trace-a-login. | | offer | Imperative offer CRUD (archive is the reversible take-down, delete the hard delete, plan unless --confirm) and declarative bundles (export, validate, plan, diff, apply) — see "Offer / admission CRUD". | | offer form | Offline SmartForm authoring; every mutator runs the real publish-gate validators before writing. Every text is ONE { de, en } field: a German text needs its English in the same call (--label-en, …) — the German is never copied into en. A fieldMap to names the tenant's custom-field key directly — author-smartform, edit-smartform. | | offer workflow | Offline workflow-graph authoring. Every node verb names its node with --node <id>; every flag that reads another node's data takes a full ADDRESS (see "Addresses"); node delete is refused while an address elsewhere names the node; the governedDocument node cannot be added by the CLI yet (author it in bundle JSON, gate it through offer validate). Sections below: countersignature, staff inputs and the if node, readiness reads, completeness check, tasks, notifications, contract generator. | | offer upgrade | offer upgrade --dir <bundle> [--semantic <node>.<outcome>=<meaning>]… [--waitlist-policy <node>=<order>:<bool>]… [--payee <node>=<key>]… [--answers <file>] [--write [--out <dir>]] — rewrite a bundle exported in an older format into the current one, offline; dry run by default, refuses what it would have to guess (see "Declarative config-as-code"). | | offer manifest | offer manifest set --sort-order (offline bundle authoring). manifest.json carries no key ({}) — every retired key, selection included (the queue order is the waitlist node's input waitlistOrder), is refused by offer validate with the line to delete. | | signing · stripe | E-signature connection per school (see "E-signature"); Stripe Connect — connect-a-school. | | config · setup · files · branding | Tenant config-as-code — see "Tenant config-as-code". |

Platform vs school commands

Every command declares the right it needs, in ONE table, plexus/commandScope.ts, and --help and the command reference show it in the category (Users (platform operator), Offer (school token)) and as a Right: line:

| Scope | Who runs it | Where it lives | |---|---|---| | platform operator | a token with a _platform.* right; a school's token is refused | platform …, or a group that is the operator's as a whole: feedback, demo, setup, retention, jobs, audit | | school token | a module permission inside one school — the school's own administrator (--tenant <slug>) | every other group: offer, applications, config, branding, files, tenant-users, users groups\|set-group\|unset-group, notifications letters\|chrome, org, catalog, generator (not input-key), stripe, signing list\|start\|show\|void\|resend\|reconcile, signing connection show\|test, … | | own account | any signed-in token | whoami, tenants, tokens …, call (the called function's own gate decides) | | offline | no deployment | functions, describe, offer validate, the bundle editors |

So the path tells a school administrator whether a command is theirs: nothing under platform … is, and no other group mixes the two. Two commands have a platform branch behind a flag: tokens create --for <email> and accounts email-changes --user <address>. commandScope.test.ts holds this: it reads the gate: of every backend function a command calls, and fails when a command calling a platform-only function is not platform, when a command lacks a scope, or when a group mixes platform and school commands.

The retired paths of the commands that moved under platform … (users grant, workers reap, notifications list, imports trigger-all, persons purge, signing connection set, generator input-key create, …) still run, hidden, and name their replacement on stderr.

Writing commands name their environment. Every command declares its effect in ONE table, plexus/writeVerbs.ts (read · write · write only with --confirm · call by its kind · local-write for the offline bundle/config editors), plus whether it runs only on the operator's machine; ONE rule (runWrites) says whether a run writes, and ONE guard (ClientCommand.caller) applies it: a writing run refuses without --env on the command line (a configured default or PLEXUS_ENV is not enough), and a production target needs --allow-prod — decided on the RESOLVED deployment URL, so any environment that points at the URL plexus.yml names for prod is production (plexus/writeTarget.ts, core/config.ts knownProdConvexUrls). The check runs before any network access; a plexus.yml without a prod entry makes writes refuse. Reads are unaffected (--allow-prod on a read is ignored, with a note on stderr). A --confirm command's dry-run counts as a read — its caller lets through only the dry-run calls the command declares (DRY_RUN_CALLS, each with the argument that makes it a dry run) and refuses any other mutation or action before sending it; a write verb's own --dry-run flag (feedback to-issue) does not — it still needs --env. writeVerbs.test.ts fails when a new command is not classified, so none ships unguarded; a command listed as a read that reaches a mutation or action at runtime throws, naming itself (only ACTION_READS names an action a read may call). call --tenant <slug> fills the function's client argument; it is refused for a function without one and when client= names another tenant.

Output and exit codes

A command's result goes to stdout: a table, or — with --json, and for every command that has no table form — indented JSON. Notes, warnings and errors go to stderr, so --json | jq never parses one. A list that stops at its --limit says so on stderr (note: 100 of ≥101 rows shown — use --all for every row.); its --json array is unchanged.

| Exit code | Meaning | |---|---| | 0 | ok | | 1 | the command failed (a server refusal, a network or validation error) | | 2 | refused before anything was sent: a usage error, an invalid or incomplete configuration (unknown key, missing convex_url/site_url/token, --env local outside a worktree), or a guard (write without --env, production without --allow-prod, an undeclared command, a dry run that tried to write) | --env local connects only to THIS machine — its admin-key bootstrap is a mutation even for a read, so the target's host must resolve to loopback or to an address of one of this machine's interfaces (rdu's Traefik host of a worktree does; a remote deployment does not). seed is outside the guard: it runs convex run against the current worktree's own deployment, never through plexus.yml.

seed --if-needed is the local startup path: it pushes current code, initializes an empty database once, joins an active seed, or preserves a completed seed. Failed/incomplete seeds and untracked populated databases require an explicit rebuild. Without --if-needed, seed is destructive. --applicants none and --finance-showcase none select the lean fixtures (--finance-showcase none|cases|full: no finance demo, the finance cases on the demo contracts, or the cases plus the synthetic school year — financeParts in app/shared/seedFinanceShowcase.ts); switching local profiles preserves existing data until an explicit rebuild. --only <cases> drives just those applicant cases and implies --finance-showcase none unless --finance-showcase is given. --add <cases> drives just those cases onto the EXISTING base, without a wipe (seconds instead of a rebuild — the way to get one case for UI work); finance among them adds the finance cases (--finance-showcase cases without the rebuild). It refuses loud with [seed/noBase], [seed/baseStale] (the code fixtures changed since the base was built), [seed/alreadyDriven] (names the application, or the finance cases' demo contract runs) and [seed/notDrivableAlone] (holiday care, template-bundle applications, the staff path, the address change), and prints server time, wall time and the machine load. At most PLEXUS_SEED_SLOTS (default 2) seeds run at once on one machine, across all checkouts: a seed waits for a free slot (an flock under ~/.cache/plexus/seed-slots/, printing who holds them) and keeps it until its run is terminal; --timeout bounds the wait and the seed together, counted from the pushed functions (--if-needed pushes first, bounded by --push-timeout, default 600 s). A failed run is reported at once with its phase and error; five unreadable status polls in a row end the command with the last error. A completed run is held to its shape's budget (app/shared/seedParts.ts) and every drive key to its own; each line over budget goes to stderr with the load average, as a warning. Which shape a task needs and what each costs: docs/engineering/seed-index.md. Recipe: Initialize or rebuild local demo data.

Every command supports --json (raw output; default is a table). Network commands take --env / --tenant. functions / describe / offer validate / config work offline (no deployment).

Command grammar and retired spellings

A command is noun [sub-noun] verb: list, show, create, set, delete, plan / apply / export, or a domain verb (archive, restore, revoke, deactivate, trigger, cancel, …). Every command that deletes, deactivates, revokes, voids, cancels or sweeps all tenants prints its plan by default (exit 0) and writes only with --confirm (on production also --allow-prod). An offer is named by --offer <slug>; a file a command writes is --out <file>, refused when it exists unless --force (a credentials CSV is appended to, never overwritten); a secret comes from stdin or a file, never from an argument.

The old spellings still run — hidden from --help, with a one-line note on stderr — except where marked break:

| Old | New | |---|---| | stammdaten persons [purge] | persons list, platform persons purge | | interessenten list | leads list | | admission laeufe | admission runs list | | offer leistung add\|rm | offer service add\|delete | | offer delete (archived) | offer archive — break: offer delete is now the hard delete; without --confirm it only prints the plan | | offer hard-delete --yes | offer delete --confirm | | offer level remove / rename | offer level archive / set | | offer form … rm, offer role rm, offer workflow … rm\|remove | … delete | | files rm\|remove | files delete (plan unless --confirm) | | signing connection remove | platform signing connection delete | | org unit\|legal-entity update | org unit\|legal-entity set | | feedback deadline | feedback set-deadline | | feedback tags rename\|recolor\|visibility | feedback tags set --name\|--color\|--visibility | | users add | platform users grant | | users remove | platform users revoke (the --tenant; plan unless --confirm) | | users delete, users deactivate | platform users archive (plan unless --confirm) | | users provision-platform-admin | platform users provision --platform-admin | | users provision-platform-user | platform users provision --permission … | | --slug (offer commands) | --offer | | --output | --out | | platform imports trigger-all --execute | platform imports trigger-all --confirm | | imports trigger --dry-run | the default plan; --probe --confirm runs the importer without committing | | imports trigger --confirm <key> | imports trigger --confirm — break: the typed key is gone (on production --allow-prod is the second key); the old form fails as an extra argument | | --password <pw>, --engage-password <pw> | break, refused: --password-stdin / --password-file, --engage-password-stdin / --engage-password-file / PLEXUS_ENGAGE_PASSWORD |

Newly plan-by-default (scripts that ran them must add --confirm): files delete, platform signing connection delete, signing void, offer delete, platform users archive, platform users revoke, platform users grant-all, org unit delete, org legal-entity delete, generator delete-draft, generator package delete-draft, jobs cancel, tokens revoke, demo link revoke, feedback tags delete, imports trigger, platform imports trigger-all.

Provisioning admins

platform users doctor lists every identity that cannot log in, with the repair each one needs, and exits non-zero when it finds any — run it after provisioning.

Two distinct shapes for a tenant-less platform operator:

  • platform users create <email> --platform-admin — mints the identity + the _platform.* row with a generated credential (printed once; --out appends the email,password row to a CSV). It takes no display name, so the account carries none. No verb produces an identity that cannot log in.
  • platform users provision <email> --name "…" --platform-admin — the credentialed, ready-to-log-in full-admin path (creates the Better Auth credential too and grants every _platform.* permission).
  • platform users provision <email> --name "…" --permission _platform.schema.read — the credentialed limited-platform path. It creates the Better Auth credential and grants exactly the repeated --permission values.

Both platform provisioning forms need _platform.users.manage (your own platform-admin token, no Convex admin key) and refuse an email that already has tenant access (the _platform.* XOR tenant-access invariant). platform users provision does the same credentialed onboarding for a tenant user (--tenant <slug> --group tenant_admin).

Step-by-step recipes (exact commands + verification + gotchas): playbooks/users/add-platform-admin.md · playbooks/users/add-platform-user.md · playbooks/users/add-tenant-admin.md. For any common CLI task, see playbooks/.

E-signature (signing command group)

plexus-cli signing {list,start,show,void,resend,reconcile} — the Signaturvorgang (DocuSign or inSign) over the public signing.* functions; gated on finanzen_vertraege.contracts.manage. start goes out through the tenant's ACTIVE connection — there is no provider to pick.

plexus-cli signing connection {show,test}, plexus-cli platform signing connection {set,activate,delete,reseal} (delete plans unless --confirm) and plexus-cli platform signing status (key slots: what is sealed under which key; reseal after a key rotation, both platform-wide under _platform.config.manage — docs/signing/operations.md → "Rotating the key") — the school's own account at a provider (signingConnections.*). show and test need finanzen_vertraege.contracts.manage; set, activate and remove the platform right _platform.config.manage. A secret (DocuSign's Connect HMAC key, inSign's API password) enters only through --secret-stdin or --secret-file and is never printed — show lists its name and fingerprint. Recipes: playbooks/signing/README.md, playbooks/signing/connect-a-school.md; setup + runbook: docs/signing/{README,docusign-setup,insign,operations}.md.

The words a school sends (notifications letters / chrome)

A letter is code: named blocks with the text Plexus wrote. A school may rewrite a block, and what is stored is only that deviation — every block it leaves alone keeps following the platform text, so a later improvement still reaches it.

plexus-cli --tenant test notifications letters list                    # catalog + who deviates
plexus-cli --tenant test notifications letters show applicant.tasksOpen  # the letter's placeholders + each block's text
plexus-cli --env <env> --tenant test notifications letters set  applicant.tasksOpen \
  --slot intro="bei {{schoolName}} warten {{taskCount}} Aufgaben auf Sie."
plexus-cli --env <env> --tenant test notifications letters set  applicant.tasksOpen --slot-file intro=./intro.txt
plexus-cli --env <env> --tenant test notifications letters reset applicant.tasksOpen --slot intro
plexus-cli --env <env> --tenant test notifications letters reset applicant.tasksOpen --all
plexus-cli --tenant test notifications chrome {show,set}               # greeting / sign-off / sender name

Semantics worth knowing before using them:

  • set merges across blocks; a block whose text equals the platform default is dropped rather than stored, so the command's deviating blocks line can be shorter than what you submitted. That is the backend's answer, not a CLI filter — the CLI never resolves a default, checks a placeholder, or merges a letter.
  • reset needs --all to drop a whole letter: naming nothing must not mean everything.
  • A letter with no blocks cannot be reworded. Today that is only the retired applicant.reentry, shown as — (not editable); the sign-in letters (category login) are rewordable, but their link must stand alone.
  • Placeholders are an allowlist per block. An undeclared {{x}} throws with every finding named; nothing is written.
  • These are tenant-scoped (--tenant) and gated on notifications._self.access (read) / notifications.mail.configure (write) — not the platform _platform.notifications.monitor that the delivery log uses.
  • The deviations are config-as-code (notificationLetters, identity templateId:locale:scopeKey; the frame is tenant.notificationChrome), so config export|set|apply promote them across environments.

Recipe: playbooks/notifications/reword-a-letter.md. Model + invariants: docs/engineering/notifications.md.

Addresses (what a node reads, and who produces it)

A node reads another node's data only through a full address in the field that reads it — there are no data edges (wires) any more, no form implied by the node and no bare keys. Control edges (next) only route. The producer must run on every path before the reader.

| Address | Names | |---|---| | $node.<formNode>.admission[.<field>] | the form instance a form node creates, or one field / export / party leaf of it | | $node.<node>.document · .contract · .payment | a document node's newest revision · a resolver contract · a payment | | $task.<task>.<key> | the value the ONE task <task> writes under store key <key> | | $env.applicant.language | a fact about the application |

plexus-cli offer workflow node reads --dir ./bundle --node contract [--json]   # what arrives: address, origin, config path, producer
plexus-cli offer workflow node set-input --dir ./bundle --node contract --input reducedFee --address '$task.reducedFee.reducedFee'
plexus-cli offer workflow node set-reads --dir ./bundle --node contract --from form --field kind.vorname,kind.nachname
plexus-cli offer workflow node add --dir ./bundle --type setFlag ... --flag-key vertrag --target '$node.form.admission'
plexus-cli offer workflow node add --dir ./bundle --type form ... --adopt-form '$node.form.admission'

Address-taking flags: set-input --address (short form --from <formNode> --field <path>), *-reads / *-requires --address (same short form), node add --require / --person / --target / --adopt-form / --contract / --payment / engagePush and review --form, node set --target / --contract / --payment / --person / --form (on a resolver contract, engagePush or review), task --when / --reads / --documents (artifact:<own slot>, field:<address>, document:$node.<node>.document) / --family-request-fields / --family-request-from, and a record field's "suggest":{"kind":"form","address":"$node.…"}. A malformed address or a bare key is refused with the list of addresses visible at the node (visibleAddressesAt, the rule the gate applies too); everything else is the publish gate, run before writing: [address/shape|bareIdentifier|unknown|unknownOwner|unknownKey|ownerNotWriter|taskOwnerTaken|notVisible|pathNotInTarget|typeMismatch|notFormInstance|mixedForms|envCohortBound|missing]. Only errors the edit introduces block it. node delete refuses while any address names the node (addressesNaming) and lists config path + address of each. A bundle exported before addresses is refused ([graph/retiredField]: wires, a binding with path, adoptForm: true) — export it again. Recipe: playbooks/offer/addresses.md; model (visibility, $env vocabulary, the store behind $task): docs/engineering/workflow-engine.md.

Countersignature node („Unterschrift (intern)")

The school countersigns the contract the family signed: a countersign node after a checked resolver contract (production: resolver + responseStrategy: signedUploadChecked). Its one row „Vertrag gegenzeichnen" books the Träger on the revision its contract address names, sends the node's countersigned letter and routes countersigned.

plexus-cli offer workflow node add --dir ./bundle --type countersign \
  --node gegenzeichnung --label "Gegenzeichnung" --label-en "Countersignature" \
  --on-edge contract:signed \
  --assign-team schulverwaltung [--title "Vertrag gegenzeichnen" --title-en "Countersign the contract"] [--due-in-days 5] \
  --contract '$node.contract.contract' \
  --notification countersigned=admission.contractCountersigned

Publish rules: [countersign/assign], [countersign/sourceNotCheckedContract], [countersign/twice], [countersign/dueInDays], [address/missing] (no contract), [notification/missingBinding] (the letter is required). Recipe: playbooks/offer/countersign-node.md.

Staff inputs and the if node

Values staff set when they start an application: graph.staffInputs declares them (v1: boolean, a checkbox in „+ Bewerbung"), an if node reads one in its condition as $env.staffInput.<key> and routes true / false (never parks). Every staff start — cockpit, lead conversion, applications start-test --staff-input <key>=true|false — must set every declared input; a self-service start records false.

plexus-cli offer workflow staff-input set staffApplication --dir ./bundle \
  --label "Mitarbeiterbewerbung" --label-en "Staff application"     # --label-de is an alias of --label
plexus-cli offer workflow staff-input list --dir ./bundle
plexus-cli offer workflow staff-input delete staffApplication --dir ./bundle
plexus-cli offer workflow node add --dir ./bundle --type if \
  --node staffCheck --label "Mitarbeiterbewerbung?" --label-en "Staff application?" \
  --on-edge form:standard --condition '$env.staffInput.staffApplication' \
  --outcomes true:review,false:fee     # a staff child skips the fee
plexus-cli offer workflow node set --dir ./bundle --node staffCheck --condition '<expr>'

Publish rules: [staffInput/*], [if/condition], [if/unroutedOutcome]; a node parameter may bind a staff input, a SmartForm, task or rule cannot read it ([address/staffInputConditionOnly]). Recipe: playbooks/offer/staff-input-if-node.md.

Readiness reads on a workflow node

reads: [Address] on a decision or document-producing governedDocument node names the SmartForm fields ($node.<formNode>.admission.<field>, one form per list) a human reads before the node acts — for an uploaded contract the fields staff copy into it, since an upload binds no field. The readiness gate holds the node's actions while one of them has an open correction or is empty where the form requires it; once the document is issued they are consumed (a correction is refused until it is rescinded).

plexus-cli offer workflow node set-reads    --dir ./bundle --node contract \
  --from form --field kind.surname,kind.geburtsdatum,derivedConstellation   # replaces the list
plexus-cli offer workflow node add-reads    --dir ./bundle --node contract --address '$node.form.admission.kind.homeAddress'
plexus-cli offer workflow node remove-reads --dir ./bundle --node contract --address '$node.form.admission.kind.homeAddress'
plexus-cli offer workflow node set-reads    --dir ./bundle --node contract --clear
plexus-cli offer workflow node list --dir ./bundle      # the `reads` column

Addresses are checked offline against the form their owner node pins, by the same [address/*] / [readiness/*] rules and the same per-form-node field universe publish uses (readinessUniverse): a field the form lacks, a field inside a repeated block, a duplicate, two forms in one list, a form that does not run before the node, or reads on a node the gate does not govern refuses the write. Only errors the edit introduces block it; errors the bundle already carried are printed as warnings. offer validate runs the same rules (it used to skip them offline), and offer plan prints one ~reads:<node> row per node with the addresses added and removed. Recipe: playbooks/offer/declare-node-reads.md.

Completeness check (documents owed before the process ends)

completenessCheck holds the process until the SmartForm fields it requires are there, by a deadline; entered with nothing missing it completes at once.

# --require: ≥ 1, repeatable · one of --deadline-days/--deadline-until ·
# --guest-role and --person: both or neither · the `requested` letter is required
plexus-cli offer workflow node add --dir ./bundle --type completenessCheck \
  --node nachreichen --label "Vollständigkeitsprüfung" --label-en "Completeness check" \
  --require '$node.form.admission.kind.masernschutz,$node.form.admission.kind.geburtsurkunde' \
  --deadline-days 14 \
  --guest-role gasthoerer --person '$node.form.admission.beneficiary' \
  --notification requested=admission.documentsRequested \
  --outcomes rejected:joinAbsage,absage:endAbsage
plexus-cli offer workflow node set-requires|add-requires|remove-requires --dir ./bundle --node nachreichen (--address '<address>,…' | --from form --field <path>,…)
plexus-cli offer workflow node set --dir ./bundle --node nachreichen [--deadline-days n | --deadline-until d] [--guest-role r --person p | --guest-role ""]

--notification <event>=<templateId> works on every node add (it writes the same binding set-notification does); it is needed in the same call whenever the type declares an event required, since node add validates before writing. Nothing is defaulted: no deadline, no person, no letter, no form — requires and the guest role's person name the form they read by address. The node's [completenessCheck/*] rules run offline (field keys against the carried form); the tenant role catalog (unknownRole, nonLeafRole) and the letter catalog only at offer apply --publish. *-requires edits behave like *-reads (only errors the edit introduces block it; removing the last address is refused). Recipe: playbooks/offer/completeness-check.md.

Tasks on a workflow node

node.tasks[] are the composed tasks a node spawns on enter. task add|set|delete edits one of them by --node + --key; set changes only the flags it is given.

plexus-cli offer workflow node task add --dir ./bundle --node reviewEingereicht --key einschaetzung \
  --title "Einschätzung abgeben" [--title-en "…"] --team oeffentlichkeitsarbeit [--kind task|approval] \
  [--due-in-days 3] \
  [--blocks aufnahme:decision | --blocks <siblingKey>] \
  [--result freetext|recommendation|choice|record|pricedItem|reduction|appointment] [--option key=Label[|English]]… \
  [--writes '$task.<thisKey>.<storeKey>'] [--item <catalogKey>] \
  [--result-label "…" [--result-label-en "…"]] \
  [--text-required | --no-text-required] [--chat all|team:<group>|<channelKey>] \
  [--when '$node.<form>.admission.<booleanExport>' --on-condition-return respawn|stayClosed] \
  [--reads '$node.<form>.admission.a,…'] [--documents 'artifact:<slot>,field:<address>,document:<address>'] \
  [--family-request-fields '<address>,…' | --family-request-from '$node.<form>.admission'] \
  [--locks-evidence] [--feedback] [--description "…" [--description-en "…"]] \
  [--optional "Keine Einschätzung abgeben" [--optional-en "No assessment"]]
plexus-cli offer workflow node task set --dir ./bundle --node <n> --key <k> [any flag above] \
  [--clear-blocks] [--clear-result] [--clear-when] [--clear-reads] [--clear-locks-evidence] [--clear-feedback] [--clear-documents] [--clear-description] [--clear-family-request] [--clear-optional]
plexus-cli offer workflow node task delete  --dir ./bundle --node <n> --key <k>

--blocks <templateKey> is a sibling on the same node; <nodeId>:<templateKey> a template on a later node (stored as blocks.node only when it differs from --node). --option replaces its list. --locks-evidence: once the task is decided, its evidence (--reads + the fields behind --when) takes no correction, flag or cohort move until its team reopens the task (applicationTasks.reopen). The CLI refuses malformed input in German (unknown result type, --option on a non-choice result, --text-required on free text, --chat external, …); the graph rules ([workItems/*], [taskResult/*]) are the publish validators, run before every write — only errors the edit introduces block it. delete is refused while another task's blocks (or an optional action) names the task, and lists those dependents. Output: a before/after field diff; --json → {action, node, key, before, after, changes, dropped}. Recipe: playbooks/offer/node-tasks.md.

--optional "<Knopf>" makes a task OPTIONAL: nothing waits for it (publish refuses it together with blocks, gatesNode, locksEvidence, completableIf), its holder may close it without a result with that button, and a decision made while it is open makes it „hinfällig". Every result takes files (no switch): its words and files become one message of the result's chat.

A result with a value (choice, pricedItem, reduction) writes it under the task's own address — --writes '$task.<thisKey>.<storeKey>' (stored as the bare result.writes; another task's address or a path is refused) — and names it with --result-label (--result-label-en; without it the German stands in). --item <catalogKey> (pricedItem) names the billing item it prices. A kind change drops what no longer fits and lists it under dropped.

node optional-action set|remove --dir <bundle> --node <decision> --key <k> authors one entry under „Weiteres" (a spawnTasks binding): --label [--label-en], --tasks a,b (keys of the node's tasks), --trigger <group> (who may press it), --repeatable, --description [--description-en], --clear-trigger, --clear-description, --when <condition> (offered only while that condition over $env holds), --clear-when. set creates it when new (--label + --tasks required); remove is refused while requiresOneOf names the key. Recipe: playbooks/offer/optional-assessment.md; with a condition: playbooks/offer/conditional-optional-action.md.

Notifications on a workflow node

A node type declares at which events it can write to a family and with which typed arguments; the authored node binds a letter to one of them. Neither half can invent the other — a school that says nothing on a rejection simply binds nothing.

plexus-cli offer workflow node set-notification --dir ./bundle \
  --node freigabe --event rejected --template admission.rejected
plexus-cli offer workflow node set-notification --dir ./bundle \
  --node contract --event issued --template admission.documentIssued
  # --literal amount=12000                              # a fixed value
  # --disabled                                          # authored, switched off
plexus-cli offer workflow node set-notification --dir ./bundle \
  --node form --event submitted --template applicant.applicationSubmitted   # the receipt
plexus-cli offer workflow node clear-notification --dir ./bundle --node freigabe --event rejected
plexus-cli offer workflow node list --dir ./bundle      # the `letters` column

The receipt „Ihre Bewerbung ist bei uns eingegangen" is the submitted event of a creating form node (one with --form; an adoptForm node offers no event). Unbound, the submit sends no receipt; offer init scaffolds it bound, node add --type form takes --notification submitted=applicant.applicationSubmitted.

The family's appointment letters are the events of the task that books (--result appointment): <task>:booked, <task>:rescheduled, <task>:cancelled on the task's node. task add|set binds the three standard letters when a task becomes an appointment task and task rm (or switching the result away) removes them, both reported on stderr; set-notification --event termin:rescheduled … --disabled switches one off. Recipe: task-appointment-result.md.

How an argument binds (by itself when exactly one source fits, else a --literal) and why offline validation cannot see the letter catalog is canonical in notifications.md § "When a process writes". For the CLI it means: both commands run the real publish-gate validators before writing, so an event the node type does not declare fails here, while an unknown --template id and an unresolvable argument are caught only at offer apply --publish. Run it.

Wiring a contract generator

Four offline verbs replace the hand edits of a bundle when a contract node gets a generator or package (recipe playbooks/offer/wire-contract-generator.md):

plexus-cli offer workflow node set-generator      --dir <dir> --node contract --package school-with-care
plexus-cli offer workflow node set-cockpit-fields --dir <dir> --node contract --fields shared.deviceOption   # "" removes
plexus-cli offer form field export                --dir <dir> --key kind.geburtsdatum [--type number] [--off]
plexus-cli offer workflow restamp-exports         --dir <dir>
plexus-cli --env <env> offer plan --dir <dir>     # stamps the pin, then the gate
plexus-cli --env <env> offer versions --offer <slug>   # offerVersionId for applications start-test --version
  • set-generator writes the node input contractGenerator: { kind, key } (--generator → generator, --package → package — the same input the Workflow IDE's Parameter column picks) on a governedDocument with production resolver and drops the previous generator's stamp (generatorStamp: inputSchema, parties, schemaDigest, parts); cockpit cells stay. node set-input --input contractGenerator is refused and points here. The current sealed revision is the environment's answer: offer plan stamps the pin (workflow:getResolverPinStamps) before it validates.
  • Unstamped pins in offer validate. Offline, a pin without its stamp reads as a document generator. A workflow finding that holds only under that reading — it disappears when the same pins are read as combined (they produce contracts) — is a warning [resolver/pinNotStamped → offer plan], not an error; one under both readings stays an error.
  • set-cockpit-fields leaves parameters to the cockpit: each listed parameter's input cell becomes { "kind": "cockpit" } (generator or Word-template node), the others lose it; a binding or fixed value the parameter held is replaced in the same write and named under replaced. The gate checks the names against the stamp ([inputs/undeclared]) and refuses a node-type setting such as the reserved Träger ([inputs/notDeferrable]). A party field needs no entry — the cockpit is its only source.
  • Template parameters belong to the template record (catalog documentTemplates[].parameters, templates:setParameters); offer plan|apply stamp them onto every template node (workflow:getTemplateStamps, templateStamp) — a changed record shows as workflow change.
  • formExports is publish's stamp of a form node's exported leaves (exportedFormLeaves). An exported bundle carries a copy; after a form change offer validate warns [formExports/stale], because the address checks read that copy. restamp-exports writes what publish would (refused while a bound form is missing or refused by the gate); field export marks the field and re-stamps in one write — an entered field exports as its own type, a computed one needs --type string|number|boolean|date; docRef, multi-select and repeated-block fields are refused.

Classes from a class list (learning-groups apply)

learning-groups apply --file <classes.csv> --mapping <mapping.json> places each row as unit (by key) → program → the mapping's school year (a run) → learning group, by stable key (<unit>:<year>:<class code>). Without --confirm it prints the plan (create / update / unchanged per class); a re-run creates nothing. The mapping file says which column is code, unit and capacity, which columns name the program (or which existing program a name means), the grade level per Stufe value, and which columns are ignored and why — any other column, unlisted value or duplicate class refuses the file before anything is sent. Gate: the config gate (school administrator with administration.config.manage, or the platform operator). Recipe: Import a school's classes from a class list.

Persons from a person list (persons import)

persons import --file <persons.csv> --mapping <mapping.json> creates, per row, the row's person (roles and fields from the mapping), each related person block (VP1…, VP2…) with the relationship edges to it, and the enrollment into a class that learning-groups apply created. Without --confirm it prints the plan (create / unchanged / changed per person, related persons, edges, enrollments). Identity: sourceKey + the key column for the row's person; a related person is merged across rows only on the mapping's mergeOn fields, otherwise it belongs to its row. A re-run creates nothing; a changed row is reported and not written. Every column read or ignored, every cell converted by the school's field kinds (a choice by element key or German label) — refused otherwise. Gate: stammdaten.parties.write school-wide (plus administration.masterData.manage to enroll); the school's own token suffices. Recipe: Import pupils and their guardians from a person list.

Generic escape hatch

plexus-cli call parties:listForMainPage client=test q="Müller"

Without --kind, call runs only a query: it looks the function up in the offline spec below and refuses a mutation or action by name (a write is always an explicit --kind mutation|action — the default environment may be prod). A function the spec does not know (newer than the snapshot, or a typo) needs --kind.

call also checks the arguments against the spec BEFORE the request: a missing required argument, an unknown one or a wrong type is named with the expected type (missing configKey (string)). A production deployment would answer them only with „Server Error" and a GlitchTip event. A value the spec types as text keeps its text (q=2026 stays "2026"). A function the snapshot does not know (explicit --kind) is called unchecked, with a note on stderr.

A refusal prints as [CODE] <German sentence> on stderr (every command, not only call): the server sends { de, en } and the CLI shows the German, which is what playbooks quote. A stored text in a result (a failure, a reason) prints as its German in tables and as the raw { de, en } under --json.

functions / describe read a committed snapshot (spec.snapshot.json). Regenerate it with the tool's in-repo spec script (run from app/tools/plexus-cli/) after changing a public function's arguments, return validator, kind, or adding/removing one. No backend needed; the cliSpecParity test fails until the committed file matches the code:

pnpm spec        # = tsx scripts/regen-spec.ts (imports app/convex/**, exportArgs/exportReturns of every public function → spec.snapshot.json)

The snapshot drives display and call's query check (call builds its reference directly). A stale snapshot makes call ask for --kind on a new function.

Offer / admission CRUD

Model. An offer's identity = name + unit. The title is the bare program name ("Kosmetik"); the school/location is the pedagogic-unit link (the namespace). Runs (Schuljahre) and levels (Klassenstufen) are settings of the offer. A smartform and a workflow are optional later stages that make the offer applyable — a draft offer needs neither.

Lifecycle: create (name + unit) → add runs / levels / capacity → [smartform → workflow → applyable].

Imperative CRUD

plexus-cli --env <env> offer create --offer kosmetik-bs --title "Kosmetik" --title-en "Cosmetics" --unit <unit key>
plexus-cli offer show   --offer kosmetik-bs              # title, unit, levels, runs, capacity, state
plexus-cli --env <env> offer set    --offer kosmetik-bs --title "Kosmetik" --title-en "Cosmetics" --unit <unit key>
plexus-cli offer list   [--all]                         # --all includes archived (soft-deleted)

plexus-cli --env <env> offer level  add    --offer kosmetik-bs --name "Jahr 1" --name-en "Year 1" --grade 1 [--key jahr-1]
plexus-cli --env <env> offer level  set    --offer kosmetik-bs --level jahr-1 --name "Jahrgang 1" --name-en "Year 1"
plexus-cli offer level  archive|restore --offer kosmetik-bs --level jahr-1
plexus-cli --env <env> offer level  move   --offer kosmetik-bs --level jahr-2 --before jahr-1   # or --after

plexus-cli --env <env> offer run    add    --offer kosmetik-bs --name "Schuljahr 2026/27" --name-en "School year 2026/27" [--key 2026-27] --start 2026-08-01 --capacity 20
plexus-cli offer run    set|archive|restore --offer kosmetik-bs --run 2026-27

plexus-cli --env <env> offer capacity set  --offer kosmetik-bs --run 2026-27 --level jahr-1 --capacity 15|unlimited [--open|--close]

plexus-cli offer archive|restore --offer kosmetik-bs      # soft-delete (archive) / restore
plexus-cli --env <env> offer delete --offer kosmetik-bs [--confirm]   # HARD delete: plan unless --confirm

Archiving is soft — restorable, never orphaning. offer archive, offer run archive, and offer level archive set an archive marker: the row stays and keeps resolving for anything that references it (offering cells, applications), but the item is hidden from active lists and is not applyable in the portal. restore clears the marker. The one hard delete is offer delete — irreversible, it prints the plan unless --confirm, and is refused while any application exists. offer level archive is refused ([stage/hasActiveApplications]) while an active application sits on the level — rename it instead; open leads on it only print a warning.

A level's identity is its key (offer level add --key, or derived from --name when omitted — and it keeps that value after a rename); a run's is its key too (offer run add --key), a unit's its config key. --level, --run and --unit take those keys, never a name. The bundle carries the same keys; see "Stage identity" below.

Names a reader sees are German AND English. Every flag that sets one has an English twin (--title-en, --name-en, --label-en, --program-name-en); creating needs both. Changing only the English keeps the German. Changing only the German follows the one edit rule of every config text (editedLocalizedText): refused when the stored English is a real translation (it would describe the old German); an untranslated English (blank, or a copy of the German) becomes blank — the German is never copied into it. A name is written by a mutation that refuses a blank language, so for a name the CLI refuses that up front: a German rename always needs its English twin (--name-en, --title-en, --label-en).

Declarative config-as-code (bundles, for promotion)

Offers are also versioned bundles on disk — use this for cross-env promotion or a reviewable whole-offer change.

A real tenant's bundle is never committed. Prod is its source of truth: export it from prod into a scratch dir OUTSIDE the repo, edit, plan, apply — recipe: edit-live-tenant-offer. Always pass --env (the default is prod) and --out (the default offers/<tenant>/<slug> is relative to the current directory; a non-empty directory is refused unless --force). Staging gets a real tenant's bundle only by an explicit export --env prod → apply --env staging. Only the test tenant's offers are in git, and those come from the seed (app/config/templates/test/**), not from a bundle dir.

offers/<tenant>/<offerSlug>/
  bundle.yaml      # index — module, tenant, slugs, label, links, program, pinned
  manifest.json    # {} — no key any more (every former key is refused by name)
  smartforms/<slug>.json  # OPTIONAL — { label, ...definition } (absent for a draft offer)
  workflow.json    # OPTIONAL — { graph, progressLayout?, progressWiring? } == saveWorkflowDraft (absent for a draft offer)

A key the format no longer has (manifest capacity/roleInstances/progressLayout/…, graph id/label/dataFlow, wiring layoutId, form id/outcomeIds) is refused by offer validate and apply with the line to delete — never dropped silently.

A bundle exported in an older format (manifest keys, outcomeFnRef, outcomes without a semantic, a start node with its own outcomes, a waitlist without its policy inputs, assignment slots as text, …) is refused by validate / plan / apply as a whole (BUNDLE_NEEDS_UPGRADE) and upgraded once, offline, with offer upgrade:

plexus-cli offer upgrade --dir ./old                     # dry run: every change, every question
plexus-cli offer upgrade --dir ./old \
  --semantic <node>.<outcome>=<granted|rejected|waitlisted|other> \
  --waitlist-policy <node>=<arrival|manual>:<true|false> \
  --payee <node>=<Träger key> \
  --write --out ./new                                      # a full copy; without --out: in place
plexus-cli offer validate --dir ./new

Nothing is guessed: where the old format left a value to a default (an outcome id with no known meaning, a waitlist without a stated policy, an online payment without a payee) the node is refused with the values it may take, and nothing is written while a refusal is open. The same answers fit a JSON file (--answers <file>: { semantics, waitlistPolicy, payee }). A read no slot declares, a task with a fixed person and no team, or a start node with foreign outcomes is a hand edit. Recipe: Upgrade an old bundle.

plexus-cli offer export --tenant test --offer grundschule-havelland --out offers/test/gymnasium
#   the PUBLISHED generation (live head, else the staged candidate); --draft exports the draft.
#   The server assembles the bundle (the same assembly as `setup export`'s offers part).
# edit bundle.yaml (slugs/label/unit/stages/runs) + the JSON blobs
plexus-cli offer validate --dir offers/test/gymnasium   # o