Aller au contenu principal
Version: Cible · roadmap

Process · avancé — l'arrivée d'un salarié

Le cas. Un contrat est signé dans l'outil RH. Quinze jours avant l'arrivée, la station ouvre le parcours : comptes Google et Slack, ordinateur commandé chez un fournisseur, e-mail d'accueil le premier jour.

Ce que l'exemple montre. Un démarrage par événement, sans modèle ; deux branches rejointes ; un sous-playbook soumis tôt, qui passe par la station d'un autre ; une écriture réservée par les RH et soumise par l'IT ; un salaire qui ne peut pas sortir ; des comptes réversibles ; des boîtes avec échéance ; des tests de contrat ; un événement reçu de l'outil RH et un fait publié sans le salaire.

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

Ce qui se passe​

ce qui arrivequi déciderésultat
lucca.contract.signed, signé, arrivée dans 12 joursévénement déclaré ; automatisationonboarding s'ouvre dans une room dont les RH sont propriétaires ; aucun modèle appelé. Le salaire du webhook n'est pas déclaré, donc jamais lu.
contrat lugrant lucca.getContract du profil rh, propriétaire de la roomtrace : nom, date, e-mail ; le salaire est classé confidential
compte Google proposéunique sur le nomréservé par les RH
invitation Slack, en parallèlel'adresse vient de la lecture du contrat (set_by: [read])réservée ; accueil attend les deux comptes et le matériel (after: [workspace, slack, materiel])
materiel : le sous-playbookla station du fournisseur applique ses règlessoumis tout de suite par les RH ; le parent lit tracking
submit du parentsubmitted_by : un compte Google est réservéun humain de l'IT : boîte Teams, 1 jour
l'IT ne répond pas en 20 hrelancer-it, sur approval.requested de la boîte itrelance, avant l'escalade à 1 jour
l'e-mail d'accueil reprend le salaireaudience: external + class: confidentialrefusé
le parcours est soumisevents.emitsonboarding.completed émis, sans le salaire ; l'annuaire RH le reçoit, et une station « Accès et sécurité » de l'organisation peut l'écouter pour ouvrir sa revue des droits
le salarié n'arrive finalement pas—suspendUser et removeUser existent (reversible) : on les lance comme actions explicites

À retenir​

  • Le modèle de classement n'est appelé nulle part, et c'est voulu : il ne sert que là où les règles déterministes ne peuvent pas trancher.
  • Réserver et soumettre peuvent être le fait de deux personnes différentes.
  • Une station qui en appelle une autre hérite de ses règles par construction : rien à recopier.