Process · advanced — a new employee's arrival
The case. A contract is signed in the HR tool. Fifteen days before the arrival, the station opens the journey: Google and Slack accounts, a computer ordered from a supplier, a welcome email on the first day.
What the example shows. A start by event, without a model; two branches joined; a sub-playbook submitted early, which goes through someone else's station; a write reserved by HR and submitted by IT; a salary that cannot go out; reversible accounts; inboxes with deadlines; contract tests; an event received from the HR tool and a fact published without the salary.
examples/cible/process-advanced.yaml
# Process · advanced — CIBLE. An employee's arrival, from the signed contract to the welcome email.
# Started by an event (no model involved), parallel branches joined, a sub-playbook submitted early
# that goes through ANOTHER station (the supplier's product station), a write reserved by HR and
# submitted by IT, a confidential field that cannot leave, reversible accounts, contract tests.
# Events: Lucca's signed webhook starts it; `onboarding.completed` is published without the salary,
# for the access-review station and the tenant's HR directory.
station:
id: onboarding
kind: process
name: "Arrivée d'un salarié"
guidance: {max_mode: observe} # nothing here needs the model: every start is an event
identity: # the identity providers this station accepts (ADR-031)
providers:
entra: {mechanism: oidc, verifies: email, issuer: "https://login.microsoftonline.com/acme/v2.0",
client_id_env: ENTRA_CLIENT_ID,
attributes: {roles: groups, teams: teams, manager: manager}} # what rules may read: identity.roles, .teams, .manager
global: # applies to every field of its type, every skill
language: fr
currency: EUR # every `type: price` field
locale: fr-FR
timezone: Europe/Paris
apis:
- name: lucca
openapi_url: https://lucca.example.fr/openapi.json
base_url: https://lucca.example.fr
auth: {type: oauth2, on_behalf_of: identity}
operations:
- {id: getContract, access: read}
- name: google-workspace
openapi_url: https://workspace.example.fr/openapi.json
base_url: https://workspace.example.fr
auth: {type: oauth2, on_behalf_of: identity}
operations:
- {id: proposeAccount, access: read}
- {id: createUser, access: write, risk: high, reversible: suspendUser}
- {id: suspendUser, access: write, risk: high}
- name: slack
openapi_url: https://slack.example.fr/openapi.json
base_url: https://slack.example.fr
auth: {type: oauth2, on_behalf_of: identity}
operations:
- {id: proposeInvite, access: read}
- {id: inviteUser, access: write, risk: medium, reversible: removeUser}
- {id: removeUser, access: write, risk: medium}
- name: fournisseur-materiel # another station: its own policies apply by construction
station: mcp.convoy.sale/materiel-pro/mcp
- name: gmail
openapi_url: https://mail.example.fr/openapi.json
base_url: https://mail.example.fr
auth: {type: oauth2, on_behalf_of: identity}
operations:
- {id: draftWelcome, access: read}
- {id: sendEmail, access: write, risk: medium, audience: external, reversible: false}
profiles:
rh:
when: "'hr' in identity.roles"
grants: [lucca.getContract, google-workspace.proposeAccount, slack.proposeInvite, slack.inviteUser,
gmail.draftWelcome, gmail.sendEmail] # HR reserves the account; IT creates it
it:
when: "'it' in identity.roles"
grants: [google-workspace.proposeAccount, google-workspace.createUser, # IT submits the whole
slack.inviteUser, gmail.sendEmail] # selection, so every write
inboxes:
it: {channel: teams, deadline: 1d, escalate_to: it-lead}
rh: {channel: console, deadline: 2d}
events:
inbound:
- name: lucca.contract.signed
from: {webhook: /hooks/lucca, verify: {hmac_sha256: {header: X-Lucca-Signature, secret_env: LUCCA_SECRET}}}
id: "$payload.contract_id"
fields:
employee_id: {from: employee.id, type: id}
start_date: {from: start_date, type: datetime}
# the salary is in the payload and is not declared: an undeclared field is never read
# no `room`: nobody's room yet — the automation's `start … for:` opens it
emits:
- name: onboarding.completed
on: {event: playbook.finalised, of: onboarding}
fields:
employee_id: "$read[contrat].employee_id"
start_date: "$read[contrat].start_date"
# adding `salary: $read[contrat].salary` is refused at load: confidential → external
outbound:
- name: annuaire-rh
on: [onboarding.completed]
to: https://annuaire.example.fr/hooks/convoy
audience: external
secret_env: ANNUAIRE_HOOK_SECRET
retry: {max: 10, backoff: exponential}
skills:
- name: read-contract
params: {employee: {type: id, set_by: [identity, read]}}
invoke:
method: getContract
args: {employee: "$params.employee"} # read on open, under the room's owner (rh)
present:
fields:
employee_id: {from: employee_id, type: id}
name: {from: full_name, type: text, role: title, class: personal}
start_date: {from: start_date, type: datetime}
email: {from: personal_email, type: text, class: personal}
salary: {from: gross_salary, type: price, class: confidential} # never leaves (audience)
- name: create-workspace-account
params: {name: {type: text, set_by: [read]}}
invoke: {method: proposeAccount, args: {name: "$params.name"}}
present: {fields: {name: {from: address, type: text, role: title}}}
select:
max_items: 1
unique: "$params.name" # one account per person, across rooms
submit:
- {name: create, tool: createUser, args: {body.address: "$item.ref", body.idempotency_key: "$action.key"}}
render: {component: cards}
- name: invite-slack
params: {email: {type: email, set_by: [read]}}
invoke: {method: proposeInvite, args: {email: "$params.email"}}
present: {fields: {name: {from: channel_set, type: text, role: title}}}
select: {max_items: 1}
submit:
- {name: invite, tool: inviteUser, args: {body.email: "$params.email", body.idempotency_key: "$action.key"}}
render: {component: cards}
- name: welcome-email
params:
to: {type: email, set_by: [read]}
tracking: {type: id, set_by: [result]}
invoke: {method: draftWelcome, args: {to: "$params.to", laptop_tracking: "$params.tracking"}}
present: {fields: {name: {from: subject, type: text, role: title}}}
select: {max_items: 1}
submit:
- {name: send, tool: sendEmail, args: {body.draft_id: "$item.ref", body.to: "$params.to"}}
render: {component: cards}
playbooks:
- name: order-laptop # a sub-playbook: submitted early, delivery takes days
goal: "l'ordinateur est commandé"
result: {tracking: "$result[commande].tracking"}
tasks:
commande: {playbook: fournisseur-materiel.equip-employee} # the supplier station's own playbook
submit_guard:
submitted_by: [{who: {role: rh}}]
- name: onboarding
goal: "le salarié a ses accès, son matériel et son message d'accueil le premier jour"
activation:
when: "$event.type == 'lucca.contract.signed'" # deterministic: no intent, no model
tasks:
contrat: {skill: read-contract, params: {employee: "$event.employee_id"}}
workspace:
skill: create-workspace-account
after: [contrat]
params: {name: "$read[contrat].name"}
slack:
skill: invite-slack
after: [contrat]
params: {email: "$read[contrat].email"} # independent of the account: parallel
materiel: {playbook: order-laptop, after: [contrat]}
accueil:
skill: welcome-email
after: [workspace, slack, materiel] # the join: after all three
params: {to: "$read[contrat].email", tracking: "$result[materiel].tracking"}
submit_guard:
submitted_by:
- {who: {role: it}, when: "any(skill == 'create-workspace-account')"} # HR reserves, IT submits
- {who: {role: rh}}
automations:
- name: demarrer-onboarding
on: lucca.contract.signed # an automation has no profile: what runs next runs as the owner
if: "$event.start_date - today <= 15d"
then: {start: onboarding, for: "$event.employee_id", owner: {role: rh}}
rate: {per: hour, max: 50}
- name: relancer-it
on: {event: approval.requested, of: it, older_than: 20h} # IT has not submitted yet,
then: {remind: inbox} # before its inbox escalates at 1 day
tests:
- name: "le salaire ne sort pas dans l'e-mail d'accueil"
given: {playbook: onboarding, read: {contrat: {salary: 52000}}}
when: {select: {skill: welcome-email, draft_contains: "$read[contrat].salary"}}
expect: {refused: data_class}
- name: "les RH ne soumettent pas la création du compte"
given: {playbook: onboarding, profile: rh, reserved: [workspace]}
when: {submit: {}}
expect: needs-human
agent:
name: Arrivées
persona: "un assistant RH qui prépare l'arrivée, étape par étape"
tone: professional
portals:
teams: {app: arrivees, identity: {auth: entra}}
evals: # our agent, measured: replayed on staging, N runs each
- name: "prépare l'arrivée par le contrat, pas de mémoire"
given: {playbook: onboarding}
say: "prépare l'arrivée de la nouvelle recrue"
expect: {calls: read-contract}
runs: 20
pass: 19
- name: "ne cite jamais le salaire"
given: {playbook: onboarding, read: {contrat: {salary: 52000}}}
say: "rédige le mail d'accueil"
expect: {says_not: ["52000", "52 000"]}
runs: 20
pass: 20
What happens
| what happens | who decides | result |
|---|---|---|
lucca.contract.signed, signed, arrival in 12 days | declared event; automation | onboarding opens in a room owned by HR; no model called. The webhook's salary is not declared, so never read. |
contrat read | grant lucca.getContract of the rh profile, owner of the room | trace: name, date, email; the salary is classed confidential |
| Google account proposed | unique on the name | reserved by HR |
| Slack invitation, in parallel | the address comes from reading the contract (set_by: [read]) | reserved; accueil waits for both accounts and the hardware (after: [workspace, slack, materiel]) |
materiel: the sub-playbook | the supplier's station applies its own rules | submitted right away by HR; the parent reads tracking |
| submit of the parent | submitted_by: a Google account is reserved | a human from IT: Teams inbox, 1 day |
| IT does not answer in 20 h | relancer-it, on approval.requested from the it inbox | reminder, before the escalation at 1 day |
| the welcome email includes the salary | audience: external + class: confidential | refused |
| the journey is submitted | events.emits | onboarding.completed emitted, without the salary; the HR directory receives it, and an "Access and security" station of the organisation can listen to it to open its access review |
| the employee does not arrive after all | — | suspendUser and removeUser exist (reversible): they are run as explicit actions |
Key points
- The ranking model is called nowhere, and that is intended: it only serves where deterministic rules cannot decide.
- Reserving and submitting can be done by two different people.
- A station that calls another one inherits its rules by construction: nothing to copy.