# capabilities.yaml — what a brick may ask another brick to DO in the outside world, by name, not by vendor.
#
# STATUS: draft 0.6 (2026-10-25) — 0.6 adds identity.revoke_app_user (hr offboarding; moved from brick-hr's
# capabilities.local.yaml under the same name and shape). 0.5 (2026-10-18) adds the AI-step capabilities ai.suggest / ai.draft_message / ai.flag_risk (the
# erp-roadmap M0 "AI-step contract", below the contracts entries). 0.4 (2026-10-17) adds identity.invite_app_user (hr HR-25) and contracts.draft / send / status /
# fetch_executed (contracts CT-50; the entries the contracts spec proposed). The contracts.* capabilities are PROVIDED BY THE
# CONTRACTS BRICK (`contract:capability:invoke`), not by a vendor connector; for contracts.draft the idempotency_key MUST equal
# '<source_ref>|<type_code>' (contract_id is derived from it, so a retried step drafts one contract). Two type forms are new
# in 0.4: `{a: t, …}` (one object) and `map of any` (a name → JSON scalar map); enum values may be UPPER_CASE codes, and one
# upper-case code alone is a literal. contracts.fetch_executed returns the file as base64 in the RESULT (a RESULT is JSON) —
# the proposal's "bytes with the sha256 in a header" — never an object URL. 0.3 makes the no-provider path first-class (rule 8). 0.2 — 0.1 plus the gws builder's review: terminal-only replay, claim leases,
# async Drive transfers, password delivery, the permission namespace, tool names. Written so a provider brick (google-workspace first) can be built in
# parallel with its first consumer (hr). The platform pieces that enforce it — CapabilitySeam and the
# erp-compose check — come next; until then this file is the contract both sides build against.
#
# WHY CAPABILITIES. hr must be able to "provision the starter's account" without knowing Google. hr declares
# what it REQUIRES; a provider brick declares what it PROVIDES; compose.yaml maps one to the other:
#
# capabilities:
# identity.provision_user: google-workspace
#
# Microsoft 365 or Slack later is a new provider brick and one line here — hr does not change. A capability
# with NO provider in a composition is legal: the consumer turns those steps into manual tasks ("create the
# account by hand"), which is exactly what a customer without the tool should get.
#
# THE RULES EVERY CAPABILITY CALL FOLLOWS (a provider that breaks one does not satisfy the contract):
#
# 1. idempotency_key (required). The consumer derives it from the work it is doing (hr: "<run_id>|<step_no>").
# The provider CLAIMS it with a plain INSERT on a table whose primary key is the key (as brick-activity
# claims emails); an upsert here would let a retried tick act twice.
# Only a TERMINAL result is stored and replayed: `done`, and `failed` with retryable=false. A call with the
# same key after one returns that result and acts no further. A NON-terminal outcome (`failed` retryable,
# `connection_required`) RELEASES the claim, so the consumer's later retry acts — otherwise a stored
# transient failure would be replayed forever. Every attempt is still in the action log.
# A claim carries a LEASE (~120s). A call that finds an unexpired claim with no result answers
# `in_progress` (try later). One that finds an EXPIRED claim — the pod died between claim and result —
# re-attempts; so every action must also be idempotent AT THE VENDOR, derived from the key (look up before
# create; "already a member" is done; ids derived from the key where the vendor allows it).
# 2. dry_run (boolean, default false). true = validate, resolve and return the exact plan of what WOULD be
# done (the vendor request in words and payload), touching nothing outside and claiming nothing.
# 3. connection. A provider acts on a customer's tool only through a CONNECTION the customer set up (for
# Google: a service account with domain-wide delegation on their Workspace). With no usable connection the
# provider answers status "connection_required" — never a 500, never a silent success — and the consumer
# turns the step into a manual task naming what is missing. dry_run works without a connection.
# 4. The answer is always the RESULT shape below, HTTP 200, for an outcome the consumer can act on (done,
# planned, in_progress, connection_required, failed). HTTP errors are for calls the consumer got wrong: 401/403
# identity, 404 not found, 409 the key was used with DIFFERENT inputs, 422 invalid input.
# 5. Every attempt is written to the provider's action log: capability, key, inputs (secrets redacted),
# result, vendor reference, actor, time. The customer can read it (hr story 13).
# 6. destructive capabilities are marked so; a consumer puts an approval step before them, and the MCP tool
# carries destructive_hint=true.
# 7. A transient vendor failure (timeout, 429, 5xx) is retried by the provider with backoff inside one call;
# if it still fails the result is "failed" with retryable=true and the error in words. The CONSUMER decides
# when to try again (hr: after N failures the step becomes a task for IT, story 14).
# 8. NO PROVIDER IS A FIRST-CLASS PATH, not a fallback. When a composition maps no provider to a capability
# (or the provider answers connection_required), the consumer makes the step a MANUAL TASK that carries the
# capability name and the EXACT input it would have sent — the same payload, validated the same way — plus
# the plan in words ("Create the Google account anna@co.example in OU /Staff/London"). The person does it by
# hand and marks it done, optionally recording the provider_ref (the account id they created). Wiring a
# provider later makes the SAME step automatic: no playbook or checklist change, no new step kind. A first
# demo with no connectors shows exactly this path.
#
# ROUTE: a provider serves each capability it provides at POST /capabilities/<capability name>
# with body {idempotency_key, dry_run, input: {...}} → RESULT.
# PERMISSION: the provider requires <provider>:capability:invoke on the caller, <provider> being the brick's
# short name (google-workspace → `gws:capability:invoke`). The composition knows which brick provides
# each capability, so the consumer's CapabilitySeam sends the right one.
# MCP: the provider's tool for a capability is its name with dots as underscores (identity_provision_user);
# the route keeps the dots.
#
# ENFORCED BY erp_platform.capabilities (erp-platform 0.2.0): a provider declares @BRICK.capability(name) and
# the platform implements rules 1, 2, 4, 5 and 7's result shape; a consumer calls CapabilitySeam(name) and gets
# rule 8's ManualTask when nothing is wired. This file ships inside the package (src/erp_platform/) so an
# installed brick reads the same contract; the repo-root capabilities.yaml is a symlink to it. A capability
# marked `read: true` is answered without a claim (rule 1 does not apply to a read).
version: "0.6"
result: # the answer to every capability call
status: "done | planned | in_progress | connection_required | failed"
retryable: "boolean — failed only: may the same call succeed later?"
provider: "the brick that acted, e.g. google-workspace"
provider_ref: "the vendor's id for what was made or changed (user id, event id, folder id) — null if none"
output: "capability-specific fields, below"
plan: "dry_run only: list of {action, target, payload} in words, what WOULD happen"
message: "one sentence a person can act on — the vendor's error in words when failed"
idempotent_replay: "true when this is the stored result of an earlier call with the same key"
capabilities:
identity.provision_user:
summary: Create the person's work account, or confirm it exists.
input: {given_name: str, family_name: str, primary_email: str, recovery_email: "str|null", org_unit: "str|null",
initial_password_delivery: "recovery_email | none"}
output: {account_id: str, primary_email: str, created: bool}
destructive: false
notes: >-
An account already existing for primary_email is not an error — created=false, its id returned.
recovery_email delivery: the provider sets a random password that must be changed at first sign-in and sends
it from the connection's notifications_sender mailbox. The password never appears in the output, the plan
or the action log.
identity.suspend_user:
summary: Stop the account signing in, keeping its data.
input: {primary_email: str, reason: str}
output: {account_id: str, suspended: bool}
destructive: true
identity.delete_user:
summary: Delete the account and free its licence. Irreversible after the vendor's grace period.
input: {primary_email: str}
output: {account_id: str, deleted: bool}
destructive: true
groups.add_member:
summary: Add the person to a group (team, distribution list).
input: {group: str, member_email: str, role: "member | manager | owner"}
output: {group_id: str, added: bool}
destructive: false
groups.remove_member:
summary: Remove the person from a group.
input: {group: str, member_email: str}
output: {group_id: str, removed: bool}
destructive: true
chat.add_to_space:
summary: Add the person to a chat space or channel.
input: {space: str, member_email: str}
output: {space_id: str, added: bool}
destructive: false
chat.remove_from_space:
summary: Remove the person from a chat space or channel.
input: {space: str, member_email: str}
output: {space_id: str, removed: bool}
destructive: true
drive.create_folder:
summary: Create a folder and share it.
input: {name: str, parent: "str|null", share_with: "list of {email, role: reader|writer|owner}"}
output: {folder_id: str, url: str}
destructive: false
drive.transfer_ownership:
summary: Move everything the person owns to someone else (usually their manager).
input: {from_email: str, to_email: str}
output: {transfer_ref: str, state: "requested | completed", transferred_items: "int|null"}
destructive: true
notes: >-
Asynchronous at the vendor (Google's Data Transfer API): `done` means the transfer was ACCEPTED. A consumer
that must wait — hr before identity.delete_user — polls drive.transfer_status. delete_user does no transfer
of its own: the consumer sequences transfer → wait → delete explicitly.
drive.transfer_status:
summary: Whether a requested ownership transfer has completed.
input: {transfer_ref: str}
output: {transfer_ref: str, state: "requested | in_progress | completed | failed", transferred_items: "int|null"}
destructive: false
read: true # machine-readable form of the note: no claim, nothing stored, an MCP read-only hint
notes: A read — no idempotency claim; idempotency_key may be any value.
mail.send:
summary: Send an email from a mailbox the connection may send as.
input: {from_email: str, to: "list of str", cc: "list of str", subject: str, body_text: str, body_html: "str|null"}
output: {message_id: str}
destructive: false
notes: Sending is not undoable — a consumer sends only approved content (hr drafts are approved first).
mail.set_autoreply:
summary: Turn on an out-of-office reply on the person's mailbox.
input: {mailbox: str, subject: str, body_text: str, start: "iso datetime", end: "iso datetime|null"}
output: {enabled: bool}
destructive: false
calendar.create_event:
summary: Create a calendar event and invite people.
input: {organizer: str, title: str, start: "iso datetime", end: "iso datetime", timezone: str,
attendees: "list of str", description: "str|null", location: "str|null"}
output: {event_id: str, url: str}
destructive: false
identity.invite_app_user:
summary: Invite a person to sign in to the composition's user pool, or confirm they already can.
input: {email: str, pool: str, given_name: "str|null", family_name: "str|null", app_roles: "list of str"}
output: {sub: str, email: str, created: bool}
destructive: false
notes: >-
pool is '<org>/<pool>' (DODIL_USER_POOL). A user already in the pool for email is not an error — created=false,
their sub returned. The provider sends the pool's own invitation (a sign-in link) to email; no password or link
appears in the output, the plan or the action log. hr derives the key from the person (invite:<person_id>), so
inviting twice reuses the same user. Provided by a future dodil-appid provider brick; with none, a manual task
(rule 8): an admin creates the pool user with this exact input and records the sub on completion.
identity.revoke_app_user:
summary: Stop a person signing in to the composition's user pool — disable the pool user and revoke every session.
input: {pool: str, email: str, sub: "str|null", reason: str}
output: {disabled: bool, sessions_revoked: int}
destructive: false
notes: >-
pool is '<org>/<pool>' (DODIL_USER_POOL); the user is the person's pool user (sub when the consumer stored it at
the invite, else found by email). Disabling is reversible (a rehire re-invites), so no approval is needed beyond
the consumer's own (hr: the offboarding's access-removal approval the step waits on). A user that is not in the
pool is not an error: disabled=false, sessions_revoked=0. Provided by a dodil-appid provider brick; with none, a
manual task (rule 8): an admin disables the user and revokes its sessions (dodil appid user ban /
revoke-sessions) with this exact input and completes the step.
contracts.draft:
summary: Draft a contract of a type for a counterparty from the approved template, filled from the values given.
input: {type_code: str, counterparty_bp_id: int, context_ref: "str|null", source_ref: str, variables: "map of any",
signers: "list of {side: US|THEM, full_name: str, email: str, title: str|null, authority_basis: str|null, signing_order: int}|null"}
output: {contract_id: str, state: "DRAFT | IN_APPROVAL", missing: "list of str",
deviations: "list of {what: str, level: str, needs: str|null}", approval_required: bool}
destructive: false
notes: >-
Provided by the contracts brick. idempotency_key MUST equal '<source_ref>|<type_code>' (contract_id is derived from
it); the same key with different inputs is 409. Missing required variables answer failed retryable=false with
missing[] in the output and NO partial contract. The caller supplies values, never clause text. A counterparty
without a role the type allows is 422. deviations[].needs is the approver group.
contracts.send:
summary: Freeze the current draft as a new version and send it for signature.
input: {contract_id: str}
output: {contract_id: str, state: OUT_FOR_SIGNATURE, version: int, sha256: str,
deliveries: "list of {signer_no: int, channel: provider|manual}"}
destructive: false
notes: >-
Provided by the contracts brick. Not undoable once delivered (recall revokes the links, it does not unsend mail).
Refused (failed, retryable=false, message in words) when missing[] is non-empty, an approval is open, the type is a
deed or esign_allowed=false, both sides are not named, or a localisation pack check fails. dry_run returns the
version that would be frozen and the deliveries. Signing links come from the platform's public-link door.
contracts.status:
summary: Where a contract stands.
input: {contract_id: str}
output: {contract_id: str, state: str, version: "int|null",
signers: "list of {signer_no: int, side: US|THEM, status: str, seen: bool}",
executed: "{object_key: str, sha256: str, provenance: str}|null",
key_dates: "list of {kind: str, due_on: str, done: bool}",
principal_statement_issued_at: "iso datetime|null", warnings: "list of str"}
destructive: false
read: true
notes: >-
A read, like drive.transfer_status — no idempotency claim. `executed` is returned only to the ORIGINATOR (the caller
whose contracts.draft created it, matched by the source_ref namespace); for anyone else it is null, and contract
bodies are never returned. due_on is an ISO date.
contracts.fetch_executed:
summary: Stream the executed copy of a contract the caller created.
input: {contract_id: str}
output: {contract_id: str, filename: str, content_type: str, sha256: str, size: int, content_base64: str}
destructive: false
read: true
notes: >-
A read. Allowed ONLY to the originator (source_ref namespace = the caller's brick); any other caller gets 404. The
bytes travel base64-encoded in the RESULT with their sha256 beside them — never an object URL. This is what lets hr
file the signed contract on the person without a broad contract:employment:read.
# ── AI steps (0.5, erp-roadmap M0 "AI-step contract") ──────────────────────────────────────────────────────────
# An AI step is a PROVIDER of one of these three capabilities; the platform calls no model. Three, because every
# ai_steps entry in the module specs so far is one of three shapes: propose values for a record (dates and owners
# for a plan, a category, a match), draft words a person sends (a reminder, a reply), or warn about something at
# risk (a shortfall, a late payment) — more names would be the same contract under different words.
#
# THE MODE is the consumer's, per step, part of the signed BPMN: off | suggest (default) | auto-with-review | auto.
# The provider only PROPOSES and never applies; what happens to its output is the consumer's mode decision:
# off the step is not called; the fallback is the process (a person does it).
# suggest the output becomes a ManualTask for a person to accept, edit or reject (the person decides).
# auto-with-review the consumer applies it and a person reviews it afterwards (a ManualTask to confirm/undo).
# auto applied, only for a step the brick marks safe_for_auto; never destructive or money-moving.
# NO PROVIDER (or a confidence below the step's floor, or a model that is down) is rule 8: the ManualTask with
# the exact input — the step's fallback, written in the spec — so an AI step always degrades to a person.
# AUDIT (rule 5, plus): the provider's action log records model, inputs, output and confidence; the consumer
# records the approver (who accepted the ManualTask, or reviewed the auto-with-review result) beside it.
# `step` is the spec's ai_steps key; `subject_ref` the record it is about ("<table>:<key>"); `context` the inputs
# the spec lists for the step, as JSON scalars (the consumer resolves ids to values: the provider reads nothing).
ai.suggest:
summary: Propose values for one record (dates, owners, a category, a match) for a person or the step's mode to apply.
input: {step: str, subject_ref: str, fields: "list of str", context: "map of any", instructions: "str|null"}
output: {suggestion: "map of any", rationale: str, confidence: int, model: str, sources: "list of str"}
destructive: false
notes: >-
confidence is 0..100. suggestion holds only the names in fields. The provider never writes the record: the
consumer applies the suggestion per the step's mode. idempotency_key is '<step>|<subject_ref>|<version>' so a
retried step asks once per version of the record.
ai.draft_message:
summary: Draft a message a person edits and sends (a reminder, a reply, a cover note); never sent by the provider.
input: {step: str, subject_ref: str, purpose: str, audience: str, context: "map of any", language: "str|null"}
output: {subject: "str|null", body: str, confidence: int, model: str, sources: "list of str"}
destructive: false
notes: >-
A draft only: sending is a separate step (mail.send, or a person), so a message to a customer is never auto
unless the consumer marks it safe. confidence is 0..100.
ai.flag_risk:
summary: Warn about something at risk (a shortfall, a late payment, an anomaly) while it can still be acted on.
input: {step: str, subject_ref: str, horizon: "str|null", context: "map of any"}
output: {flagged: bool, risks: "list of {ref: str, why: str, confidence: int}", confidence: int, model: str}
destructive: false
notes: >-
It only warns, so it is the one shape a brick may mark safe_for_auto. risks[].ref names the record at risk.
confidence is 0..100.