dodil/erp-platform

src/erp_platform/capabilities.yaml

# 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.