Ctrl K
ComunidadGuías…

openapi/dashboard.yaml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
openapi: 3.1.0
info:
  title: Ferryman Dashboard API
  description: >
    The loopback-only web dashboard for observing a channel and (when not
    read-only) reviewing work. Served by `ferry dashboard` on a loopback
    address (default http://127.0.0.1:8788). It is a distinct surface from the
    bridge server API (`openapi.yaml`, port 8787): it authenticates a *human
    operator* by password and returns a short-lived session token, rather than
    the server's project/actor bearer tokens.
  version: 0.3.1
servers:
  - url: http://127.0.0.1:8788
security:
  - DashboardSession: []
components:
  securitySchemes:
    DashboardSession:
      type: apiKey
      in: header
      name: x-ferryman-dashboard-token
      description: >
        Random bearer token returned by `POST /api/auth/login`; idle-expiring
        and revoked on sign-out.
  schemas:
    Credentials:
      type: object
      required: [name, password]
      properties:
        name: {type: string}
        password: {type: string, writeOnly: true, minLength: 8}
    CreatedOperator:
      type: object
      properties:
        name: {type: string}
        public_key: {type: string}
    LoginResponse:
      type: object
      properties:
        token: {type: string, description: Bearer token for the x-ferryman-dashboard-token header}
        name: {type: string}
        public_key: {type: string}
    Review:
      type: object
      required: [accept]
      properties:
        accept: {type: boolean}
        notes: {type: [string, 'null']}
    Error:
      type: object
      required: [message]
      properties:
        message: {type: string}
paths:
  /api/connect:
    get:
      summary: What the Connect an AI app panel needs to write a config (this ferry binary and a machine label; no secrets)
      responses:
        '200': {description: the ferry path and machine label}
        '401': {description: no session}
  /api/auth/create:
    post:
      summary: Mint a password-sealed operator identity and publish its public key to the roster
      requestBody:
        required: true
        content:
          application/json: {schema: {$ref: '#/components/schemas/Credentials'}}
      responses:
        '200': {description: identity created, public key published, content: {application/json: {schema: {$ref: '#/components/schemas/CreatedOperator'}}}}
        '403': {description: dashboard is read-only}
        '409': {description: name already exists or password too short}
        '429': {description: too many creations in the current window}
  /api/auth/login:
    post:
      summary: Unlock an operator identity and start an idle-expiring session
      requestBody:
        required: true
        content:
          application/json: {schema: {$ref: '#/components/schemas/Credentials'}}
      responses:
        '200': {description: session started, content: {application/json: {schema: {$ref: '#/components/schemas/LoginResponse'}}}}
        '401': {description: name or password incorrect}
        '403': {description: dashboard is read-only}
        '429': {description: too many sign-in attempts for this name; wait a minute}
  /api/auth/logout:
    post:
      summary: End the current session immediately
      responses:
        '204': {description: session revoked}
  /api/auth/whoami:
    get:
      summary: Report the session's operator, or 401 when there is no live session
      responses:
        '200': {description: operator name, content: {application/json: {schema: {type: object, properties: {name: {type: string}}}}}}
        '401': {description: no active session}
  /api/auth/recover:
    post:
      summary: Restore an operator from its 24-word recovery phrase
      description: >
        Recovering a name the channel already publishes needs no setup token: the phrase
        must derive that published key, which only its owner can do. Creating a NEW name
        this way still needs the one-time token printed on the console.
      responses:
        '200': {description: session token and fingerprint}
        '400': {description: the phrase is not a valid 24-word BIP-39 phrase}
        '401': {description: an unpublished name, and no setup token or session}
        '409': {description: this machine already holds a different seed}
  /api/auth/status:
    get:
      summary: Whether this machine holds an operator, and which published ones it is missing
      description: >
        `orphaned_operators` names operators the CHANNEL publishes whose sealed key is not
        on this machine. It distinguishes "never set up" from "identity lost", which look
        identical otherwise and call for opposite actions.
      responses:
        '200': {description: any_operators, seed_present, read_only, orphaned_operators}
  /api/identity:
    get:
      summary: The operator's fingerprint, and whether it derives from the machine seed
      responses:
        '200': {description: fingerprint and derivation}
        '401': {description: no active session}
  /api/team/invite:
    post:
      summary: Make an invitation code for a teammate who has not joined yet
      description: >
        Master only. Writes a signed invitation and returns the one-use code and the prompt
        to send. A name may be reserved; without one the person picks any free name when
        they create their identity, and the acceptance carries it. It cannot create their
        identity - that happens on their machine, under their own password.

        Two lengths are separate. `expires_days` is how long the CODE works for joining
        (default 7). The ACCESS it leads to is chosen on every invitation, with no default:
        send exactly one of `access_days` (1 to 3650) or `access_forever: true`. Timed access
        ends that many days after the master's grant is signed unless the master renews it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                agent: {type: string}
                roles: {type: array, items: {type: string}}
                expires_days: {type: integer, minimum: 1}
                access_days: {type: integer, minimum: 1, maximum: 3650}
                access_forever: {type: boolean}
      responses:
        '200': {description: 'the reserved name, the code, and `access_days` (null for no expiry)'}
        '400': {description: not a path-safe identifier, or the length of the access was not chosen}
        '409': {description: already in the channel}
  /api/questions/{id}/answer:
    post:
      summary: The master renews, or lets end, access that is about to run out
      description: >
        Master only. Answers a question of kind `access` (the ones listed as `pending_access`
        by `GET /api/team`) with exactly one of its options, `Renew` or `Let it end`, as the
        master's own signed answer. Nothing else is answered here; every other kind has its
        own page. A renewal extends the access by one more term from the date it would have
        ended; no answer, or `Let it end`, leaves the end where it was.
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [answer]
              properties:
                answer: {type: string}
      responses:
        '200': {description: the recorded answer}
        '400': {description: not an access question, or not one of its options}
        '401': {description: no active session}
        '403': {description: dashboard is read-only, or the signed-in person is not the master}
        '404': {description: no such question}
        '409': {description: already answered}
  /api/master/init:
    post:
      summary: The signed-in operator becomes this project's master
      description: >
        The person, not the machine. Signed with the session's unlocked operator key, so no
        password is asked for again. Flips `grants` to required. Refuses when a master
        already exists; the master transfers the role, nobody seizes it.
      responses:
        '200': {description: the master's name and whether grants are now required}
        '403': {description: dashboard is read-only}
        '409': {description: a master already exists}
  /api/head/revoke:
    post:
      summary: The master clears the head agent of this project
      description: >
        A head agent is whoever the master last named in their own signed words and who
        claimed it (`ferry channel head claim`). Naming someone else replaces it; this
        clears it. Master only.
      responses:
        '200': {description: how many appointments were removed}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/swarm:
    get:
      summary: Which projects the swarm may work
      description: >
        For each project on this machine (the ferry root's, or just the one on screen),
        whether the swarm may work it: the master's signed `swarm = on|off`, who signed
        it and when, the project's master, whether the person signed in may change it
        (`may_set`, true only for that master), and whether it is archived. A project is
        in unless its master signed off.
      responses:
        '200': {description: 'projects: project, on, set_by, set_at, master, may_set, archived'}
        '401': {description: no active session}
    post:
      summary: The master puts a project in or out of the swarm
      description: >
        Writes the master-signed SWARM setting into the project's channel, where every
        machine that syncs it reads it. A project that is out is not cloned by
        `ferry repos sync`, not served by an all-projects worker and not enrolled by
        `team approve --all`. A setting anyone but the master signed is ignored. Master only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project, "on"]
              properties:
                project: {type: string}
                "on": {type: boolean}
      responses:
        '200': {description: 'project, the new state, and whether it changed'}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
        '404': {description: not a project on this machine}
  /api/improve:
    get:
      summary: Self-improve for this project, its last run, and every worker's engines
      description: >
        Whether the project's master has switched the weekly self-improvement loop
        (`ferry improve`) on, who signed that and when, the latest week the loop wrote
        anything for, and each worker's signed engine inventory (tier, how it is paid,
        up, down or out of credit until when; never a credential). Each engine also
        carries its `capabilities` (modalities, strengths, context_k, cost, local) when
        its worker publishes a v2 inventory. `may_set` is true only when the person
        signed in is the master.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'enabled, set_by, set_at, master, may_set, last_run and engines'}
        '401': {description: no active session}
    post:
      summary: The master switches self-improve on or off for this project
      description: >
        Writes the master-signed SELF_IMPROVE setting into the channel, where every
        machine that syncs it reads it. Off by default; a setting anyone but the master
        signed is ignored. Master only.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: {type: boolean}
      responses:
        '200': {description: 'the new state, and whether it changed'}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/improve/all:
    post:
      summary: Self-improve on (or off) for every project the signed-in person masters
      description: >
        "On for all my repos". Writes the master-signed SELF_IMPROVE setting into every
        project in this machine's ferry root whose master is the person signed in (only
        this project on a machine without a root). Projects mastered by someone else are
        reported and left alone. The CLI equivalent is `ferry improve on --all`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: {type: boolean}
      responses:
        '200': {description: 'how many were switched, and per project: switched, already, mastered by, no master or error'}
        '401': {description: no active session}
        '403': {description: dashboard is read-only}
  /api/engine-policy:
    get:
      summary: The engine policy background work runs under, and what auto recommends
      description: >
        Which engines do this project's background work (self-improve planning,
        building and review), in what order, which never, and on which machines: the
        master-signed ENGINE_POLICY, or auto when none is signed (local and free engines
        first, subscriptions never). `effective` is how it falls on the engines the
        fleet published - each role's engines in order, and every blocked engine with
        the reason. `recommended` is what auto would sign, with one reason per choice.
        `may_set` is true only for the signed-in master. `adversary_mode` is what the adversary's findings do here: `off`, `advisory` (the
        default) or `blocking`; `effective.roles.adversary` is who challenges, in order.
        Each role in `effective.roles` carries its `effort`, `width` (orders at once, null
        for no cap) and whether subscriptions are allowed for it; each engine its size
        `class`. `team` is the team preset as it would be signed now, with reasons, and
        `warnings` lists any subscription the policy opens that has no weekly cap.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'project, auto, set_by, set_at, policy, describe, effective, recommended, team, warnings, efforts, self_improve, adversary_mode, the smart router''s routing, thresholds, bias and default_threshold, the kinds of work, and may_set'}
        '401': {description: no active session}
    post:
      summary: The master signs an engine policy, or goes back to auto
      description: >
        Signs `policy` with the session's key into the channel, where every machine
        reads it; `null` goes back to auto. `all` does it in every project in the ferry
        root the person is master of. The CLI equivalent is `ferry engines policy set`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                policy:
                  type: object
                  nullable: true
                  properties:
                    prefer: {type: object, additionalProperties: {type: array, items: {type: string}}}
                    never: {type: array, items: {type: string}}
                    where: {type: array, items: {type: string}}
                    caps_usd: {type: object, additionalProperties: {type: number}}
                    protect_subscriptions: {type: boolean}
                    never_applies_to: {type: string, enum: [background, all]}
                all: {type: boolean}
      responses:
        '200': {description: 'how many changed, and per project: set, already, skipped or error'}
        '400': {description: a role, selector or cap that means nothing}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/engine-policy/accept:
    post:
      summary: The master signs what auto recommends
      description: >
        Signs the recommended engine policy, computed from each project's own fleet.
        `all` does it in every project the person is master of. The CLI equivalent is
        `ferry engines policy accept`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                all: {type: boolean}
      responses:
        '200': {description: 'how many changed, and per project: set, already, skipped or error'}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/engine-policy/choose:
    post:
      summary: The master picks the improvement engine and the review engine
      description: >
        The simple choice. `improve` goes first for planning and building, `review` first
        for review - the first of the two keys every improvement needs before it can go
        live. `recommended` uses what auto recommends for both. The rest of the policy is
        kept. `auto_merge` is `none` or `low-risk`: whether fm merges an improvement on its
        own once it holds both keys, when every changed file is docs, tests or a dependency
        version bump. `adversary_engine` goes first for the adversary role, who challenges
        the builders' work at three moments (before a contract locks, when an order fails
        twice, before an improvement is called done); `adversary` is `off`, `advisory` (the
        default) or `blocking`, what its Block verdicts do. `all` does it in every project
        the person is master of. The CLI equivalent is `ferry engines policy set --improve
        <sel> --review <sel> --auto-merge <mode>`, and for the adversary `ferry engines
        policy set --role adversary --prefer <sel> --adversary <mode>`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                improve: {type: string}
                review: {type: string}
                adversary_engine: {type: string}
                adversary: {type: string, enum: [off, advisory, blocking]}
                recommended: {type: boolean}
                auto_merge: {type: string, enum: [none, low-risk]}
                all: {type: boolean}
      responses:
        '200': {description: 'how many changed, and per project: set, already, skipped or error'}
        '400': {description: nothing picked, an auto_merge that is neither none nor low-risk, or an adversary mode that is not off, advisory or blocking}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/engine-policy/team:
    get:
      summary: What the team preset would sign, with a reason per choice
      description: >
        "Plan on high, build on medium, swarm the cheap work": a large judge plans and
        reviews at high effort, one at a time; mid-size engines build at medium effort,
        three at once; the smallest do chores at low effort, four at once; the adversary
        is a large judge of another model family than the top builder. Built from the
        engines the fleet published. Subscriptions stay out of background work except for
        the roles in `subscription_roles`, and then only an engine with a `weekly_requests`
        cap. Nothing is signed. `subscription_roles` (comma-separated roles; absent keeps
        the policy in force's), `width` (`build=4,chore=2`) and `effort` (`build=high`) try
        other choices. The CLI equivalent is `ferry engines policy team`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
        - {name: subscription_roles, in: query, required: false, schema: {type: string}}
        - {name: width, in: query, required: false, schema: {type: string}}
        - {name: effort, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'policy, describe, reasons, warnings and effective'}
        '400': {description: a role, width or effort that means nothing}
        '401': {description: no active session}
    post:
      summary: The master signs the team preset
      description: >
        Signs the team preset as the project's policy, computed from each project's own
        fleet, with the same optional choices as the GET. `all` does it in every project
        the person is master of. The CLI equivalent is `ferry engines policy team
        --accept`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                subscription_roles: {type: array, items: {type: string, enum: [plan, build, review, chore, adversary]}}
                width: {type: object, additionalProperties: {type: integer, minimum: 1, maximum: 255}}
                effort: {type: object, additionalProperties: {type: string, enum: [low, medium, high]}}
                all: {type: boolean}
      responses:
        '200': {description: 'how many changed, and per project: set, already, skipped or error'}
        '400': {description: a width of 0, or a role or effort that means nothing}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/engine-policy/settings:
    post:
      summary: The master sets effort, width and subscription roles per role
      description: >
        `effort` is how hard a role thinks (`low`, `medium`, `high`); `width` is the most
        orders of the role claimed at once across the fleet, with `null` removing the cap;
        `subscription_roles` lists the roles whose background work may use a subscription,
        and only one with a `weekly_requests` cap (`[]` clears it). The rest of the policy
        is kept. `all` does it in every project the person is master of. The CLI equivalent
        is `ferry engines policy set --effort role=level --width role=n
        --allow-subscriptions-for role,...`.

        The smart router's settings go the same way: `routing` is `smart` (the default) or
        `ordered`; `thresholds` maps a kind of work to the success probability that counts as
        sufficient (`{"docs": 0.7}`, `{}` clears them); `bias` maps an engine selector to a
        tie-break weight (`{"nvidia*": 3}`, `{}` clears them). Each of the two replaces what was signed. They are signed in the
        policy's v2 view only, so a v0.5.17 machine still verifies the file.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                effort: {type: object, additionalProperties: {type: string, enum: [low, medium, high]}}
                width: {type: object, additionalProperties: {type: integer, nullable: true, minimum: 1, maximum: 255}}
                subscription_roles: {type: array, items: {type: string, enum: [plan, build, review, chore, adversary]}}
                routing: {type: string, enum: [smart, ordered]}
                thresholds: {type: object, additionalProperties: {type: number, exclusiveMinimum: 0, maximum: 1}}
                bias: {type: object, additionalProperties: {type: number}}
                all: {type: boolean}
      responses:
        '200': {description: 'how many changed, and per project: set, already, skipped or error'}
        '400': {description: 'nothing to set, a width of 0, a role, effort, kind or routing that means nothing, or a threshold outside 0 to 1'}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/focus:
    get:
      summary: The swarm's focus - which projects it is spending itself on
      description: >
        The master-signed FOCUS record in the home project's channel (`ferryman`, or
        FERRYMAN_FOCUS_HOME): a tier per project - focus, normal, background or paused -
        each with an optional expiry. `projects` lays it over every live project: its tier,
        the entry that set it, `orders` (improvement orders planned this week, for a project
        with self-improve on) and `width` (how many of a role may run at once under the
        tier). `notice` says when this machine is holding on to an earlier record because
        the file went back or vanished. `?suggest=1` also returns `suggestions`: what the
        fleet's own signals say per project, with one reason each; nothing is signed.
        `may_set` is true only for the signed-in master of the home project. The CLI
        equivalent is `ferry focus show` and `ferry focus suggest`.
      parameters:
        - {name: suggest, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'available, home, set_by, set_at, seq, set, notice, expired, projects, tiers, may_set, master and, when asked, suggestions'}
        '401': {description: no active session}
    post:
      summary: The master puts projects in a tier
      description: >
        Signs the entries into the home project's channel with the session's key, where
        every machine reads them; other projects keep their entries. `days` makes the
        entries expire (1 to 366). Only the master signs it. The CLI equivalent is
        `ferry focus set`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [projects, tier]
              properties:
                projects: {type: array, items: {type: string}}
                tier: {type: string, enum: [focus, normal, background, paused]}
                days: {type: integer, minimum: 1, maximum: 366}
      responses:
        '200': {description: 'whether anything changed'}
        '400': {description: no project named, a project that is unknown or archived, a tier that means nothing, or days out of range}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
        '404': {description: the home project's channel is not on this machine}
  /api/focus/clear:
    post:
      summary: The master takes focus entries off
      description: >
        Signs a newer record without the named projects, or without any entry when none
        is named, so every project reads as normal again. The CLI equivalent is
        `ferry focus clear`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                projects: {type: array, items: {type: string}}
      responses:
        '200': {description: 'whether anything changed'}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
        '404': {description: the home project's channel is not on this machine}
  /api/improve/pending:
    get:
      summary: Improvements waiting on a key
      description: >
        Every improvement whose newest result is not yet approved for live: its diff stat,
        what its evidence says, the review engine's verdict when it has given the first key,
        and whether only the master's approval is missing (`ready_for_you`). Each card also
        carries the adversary's finding beside the two keys (`adversary`: its verdict,
        findings and any override, null when it has not read the work or is off) and
        `adversary_blocked`, true while an unresolved Block stands. `may_decide` is true
        only for the signed-in master.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'project, may_decide and waiting'}
        '401': {description: no active session}
  /api/improve/decide:
    post:
      summary: The master's key - approve an improvement for live, or send it back
      description: >
        Signed with the session's key. Approving is refused until the policy's review
        engine has accepted the same revision, on a result whose evidence passes
        verification; sending back needs notes. Nothing merges: an approved improvement is
        "approved, ready to merge", and merging stays the master's act.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order, accept]
              properties:
                order: {type: string}
                accept: {type: boolean}
                notes: {type: string}
      responses:
        '200': {description: 'the order, the revision decided and its state'}
        '401': {description: no active session}
        '403': {description: read-only, not the master, or the review engine has not given its key}
  /api/suggestions:
    get:
      summary: Suggestions outsiders have sent, and the ones waiting for the master
      description: >
        The project's suggestions inbox as this fleet sees it: every suggestion taken in
        from the public inbox the master named (newest first), each with the contributor's
        login, kind, title, pitch and why (their words, to be shown as text), the terms
        version it was sent under and whether those are still the current terms, what
        triage made of it (`verdict`: decision, scores, reason, a draft of what to build),
        its stage (received, clarifying, pending, accepted, building, shipped, declined,
        duplicate, withdrawn, expired) and the order it became. `pending` is "needs me".
        `available` is false for a project that never opened an inbox. `may_decide` is true
        only for the signed-in master. The CLI equivalent is `ferry suggestions pending`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'available and, when it is, project, open, inbox, terms_version, terms_sha256, limits, signed_by, notice, waiting, may_decide, master and cards'}
        '401': {description: no active session}
  /api/suggestions/decide:
    post:
      summary: The master decides a pending suggestion
      description: >
        Accept, decline (with an optional reason shown to the contributor) or ask more
        (with an optional question). Recorded as the master's signed answer to the signed
        question triage asked, the same answer a Telegram button gives; nothing is built
        here. The workers read the answer on their next pass: an accept becomes an ordinary
        signed order tagged `suggestion` for the normal build and review path, and a
        suggestion whose sender has not agreed to changed terms waits for that. A question
        is answered once. The CLI equivalents are `ferry suggestions accept`, `decline` and
        `ask`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [issue, choice]
              properties:
                issue: {type: integer, description: the issue number in the inbox}
                choice: {type: string, enum: [accept, decline, ask]}
                text: {type: string, description: a reason for decline or a question for ask}
      responses:
        '200': {description: 'the issue, the answer recorded and its state'}
        '400': {description: a choice that is not accept, decline or ask}
        '401': {description: no active session}
        '403': {description: read-only, not the master (or a delegate), nothing pending for that issue, or already answered}
        '409': {description: the project has no master}
  /api/delegations:
    get:
      summary: Who may act for the master here, and whether each delegation counts
      description: >
        Every delegation in this project's channel (delegate, principal, scopes, issue
        and expiry times, and its standing: active, expired, revoked or invalid and why),
        the agents on the roster that could be delegated to (a Telegram bridge publishes
        itself with the role `delegate`), and `may_set`, true only for the master.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'project, master, may_set, scopes, delegations and candidates'}
        '401': {description: no active session}
    post:
      summary: The master lets an agent act for them
      description: >
        Signs a delegation with the session's key: the named agent's current key may act
        for the master in the listed scopes (orders, review, improve; all three when none
        are given), until revoked or, with `expires_days`, until it expires. `all` does
        this in every project in the ferry root the person is master of. Everything the
        delegate signs is recorded as "<master> via <delegate>". Master only.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [delegate]
              properties:
                delegate: {type: string}
                scopes: {type: array, items: {type: string, enum: [orders, review, improve]}}
                expires_days: {type: integer}
                all: {type: boolean}
      responses:
        '200': {description: 'how many projects were delegated, and per project the outcome'}
        '401': {description: no active session}
        '403': {description: read-only, not the master, an unknown scope, or no key for the delegate}
  /api/delegations/revoke:
    post:
      summary: The master ends a delegation
      description: >
        Writes a master-signed revocation of the agent's delegation in this project, or
        with `all` in every project that has one. From then on nothing it signs counts
        as the master's, including what it signed before.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [delegate]
              properties:
                delegate: {type: string}
                all: {type: boolean}
      responses:
        '200': {description: 'how many delegations were revoked'}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
  /api/contracts:
    get:
      summary: Every interface contract in the project
      description: >
        Each contract with its status (`proposed`, `locked` or `rejected`), its request and
        response shapes, who proposed and locked it, and the orders on each side with the
        reason a worker is holding any of them. Forged, unsigned and edited-after-lock files
        are not listed. `may_decide` is true only for the signed-in master. Each contract
        carries what the adversary found (`adversary`, one standing finding per moment it
        has read it, empty when the adversary is off) and `lock_refusal`, the reason Lock
        is refused while an unresolved Block stands, or no eligible adversary has read the
        contract at all, in `blocking` mode (null otherwise). `digest` is the contract as it
        reads now and `finding_digest` the adversary's word on it (`none` when no eligible
        adversary has read it): a Lock must send the first, an override both, so what is
        locked or overridden is what this response showed. `adversary_mode` is `off`,
        `advisory` or `blocking`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'the project, its master, may_decide, adversary_mode, and the contracts'}
        '401': {description: no active session}
  /api/contracts/{reference}/lock:
    post:
      summary: The master locks a proposed contract
      description: >
        Freezes `name@version` with a signature of the signed-in master (or a delegate
        holding the improve scope). A locked contract never changes; a change is a new
        version. Also answers the question Telegram asked, so its buttons go away. With the
        adversary on `blocking`, an unresolved Block (or no eligible finding) refuses this
        with 403 until the master overrides or waives it; `override` signs that and locks in
        one step. The body must name what the master looked at: `digest` (the contract's,
        at least 8 characters) and, with `override`, `finding` (the adversary's word, or
        `none`), both from GET /api/contracts. A contract or finding that changed since is
        refused with 403, "the contract changed since you looked".
      parameters:
        - {name: reference, in: path, required: true, schema: {type: string}}
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [digest]
              properties:
                digest: {type: string, description: 'the contract digest from GET /api/contracts'}
                override: {type: boolean}
                finding: {type: string, description: 'with override: the finding_digest from GET /api/contracts, or none'}
                reason: {type: string}
      responses:
        '200': {description: the contract, now locked}
        '400': {description: not a name@version reference, or no digest (or no finding with override)}
        '401': {description: no active session}
        '403': {description: read-only, not the master, the contract or finding changed since you looked, or an unresolved adversary Block in blocking mode}
        '404': {description: no genuine contract by that name}
        '409': {description: already locked or rejected}
  /api/contracts/{reference}/reject:
    post:
      summary: The master rejects a proposed contract
      description: >
        Declines `name@version`. The proposer's way forward is a new version.
      parameters:
        - {name: reference, in: path, required: true, schema: {type: string}}
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: the contract, now rejected}
        '400': {description: not a name@version reference}
        '401': {description: no active session}
        '403': {description: read-only, or not the master}
        '404': {description: no genuine contract by that name}
        '409': {description: already locked or rejected}
  /api/adversary:
    get:
      summary: What the adversary found in this project
      description: >
        The adversary is a separate model that challenges the builders' work at three
        moments: before an interface contract locks, when the same order fails twice, and
        before an improvement is called done. Returns the mode (`off`, `advisory` or
        `blocking`), the engine first in line, and every genuine finding newest first (its
        subject, revision, trigger, verdict, findings, engine, `same_engine` and any
        override) and its `digest`. Forged and unsigned findings are not listed. A finding
        that does not count - from the agent that built the work, from a machine with no
        signed engine inventory, one the policy's `machines` does not allow - is listed
        under `ignored` with its `reason`. `may_override` is true only for the signed-in
        master.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      responses:
        '200': {description: 'project, mode, engine, may_override, findings and ignored'}
  /api/adversary/override:
    post:
      summary: The master goes ahead despite the adversary's Block
      description: >
        Signed with the session's key. For a contract it records the override, after which
        Lock goes through; for an improvement it lets the review engine's key be granted
        and auto-merge proceed. Only a genuine Block can be overridden, and the override is
        for that revision only (the revision under decision when none is given). `finding` is
        required: the finding's `digest` as the page showed it, or `none` to waive a decision
        no eligible adversary has read; if what stands changed since, this is refused. The CLI
        equivalent is `ferry adversary override`.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, trigger, finding]
              properties:
                subject: {type: string, description: 'an order id, or a contract as name@version'}
                trigger: {type: string, enum: [contract-lock, repeat-failure, pre-done]}
                revision: {type: integer}
                reason: {type: string}
                finding: {type: string, description: 'the finding digest the master saw, or none'}
      responses:
        '200': {description: 'subject, revision, trigger, overridden_by and reason'}
        '400': {description: not a trigger, or no finding named}
        '401': {description: no active session}
        '403': {description: read-only, not the master, the finding changed since you looked, or there is no Block to override}
        '404': {description: the subject has no such moment to decide}
  /api/master/claim-all:
    post:
      summary: The signed-in person becomes master of every project with none
      description: >
        The browser half of `ferry root master`. For every project in this machine's ferry
        root whose channel has no master, puts the person's public key on the roster and
        declares them master, signed with the session's key. A project with a master, or
        whose channel knows the name by a different key, is left alone and reported. Does
        not turn on required grants. The team page does the same unasked when the person
        signed in is master of the project on screen.
      responses:
        '200': {description: 'the master, how many were declared, and per project: declared, already, other, key_conflict or error'}
        '401': {description: no active session}
        '403': {description: dashboard is read-only}
        '404': {description: no ferry root on this machine}
  /api/team/{name}/revoke:
    post:
      summary: End a person's access, signed by the master
      description: >
        Writes a master-signed revocation beside their grant and their agents' grants,
        unshares the folder from devices this channel knows as theirs, and burns open
        invitations in their name. Cannot unsync what already synced; the response lists
        secrets sealed to them, which should be rotated.
      parameters:
        - {name: name, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: who was revoked, which devices were unshared, which secrets to rotate}
        '403': {description: only the master may revoke}
        '409': {description: cannot revoke yourself}
  /api/team/{name}/access:
    post:
      summary: Sign a master grant saying what this person may do, and on which projects
      parameters:
        - {name: name, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: the grant}
        '403': {description: only the channel master may grant}
        '404': {description: not in this channel}
        '409': {description: invited but not yet online so there is no key to grant to, or their timed access has already ended}
  /api/release:
    get:
      summary: The release waiting for a person, with the gate's own verdict
      description: >
        `would_authorise` is computed by the same function the signing path calls, so the
        page can never offer to approve something the gate would refuse.
      responses:
        '200': {description: pending request, approval, denial, superseded approval, verdict}
        '401': {description: no active session}
  /api/release/{version}/approve:
    post:
      summary: A person says yes to one exact commit
      parameters:
        - {name: version, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: the signed approval}
        '401': {description: no active session}
        '409': {description: the commit moved, CI is not green, or the request is stale}
  /api/release/{version}/deny:
    post:
      summary: A person says no to one exact commit, with a reason
      description: >
        Never gated on CI or staleness. Those refuse a release; they must not refuse a
        person's refusal.
      parameters:
        - {name: version, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: the signed denial}
        '401': {description: no active session}
        '409': {description: the commit moved under the page}
  /api/conversations:
    get:
      summary: Every conversation topic, with its last line and signature status
      responses:
        '200': {description: topics}
        '401': {description: no active session}
  /api/conversations/{topic}:
    get:
      summary: One conversation, as a transcript
      parameters:
        - {name: topic, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: turns}
        '401': {description: no active session}
  /api/roster:
    get:
      summary: The channel roster, as signed
      responses:
        '200': {description: roster entries}
        '401': {description: no active session}
  /api/fleet:
    get:
      summary: Machines in the fleet and what they are doing
      responses:
        '200': {description: fleet status}
        '401': {description: no active session}
  /api/memory:
    get:
      summary: The shared memory bank
      responses:
        '200': {description: memory}
        '401': {description: no active session}
  /api/memory/suggest:
    post:
      summary: Propose an addition to the shared memory bank
      responses:
        '200': {description: the proposal}
        '401': {description: no active session}
  /api/secrets:
    get:
      summary: Secret names held in the channel. Never their values
      responses:
        '200': {description: names only}
        '401': {description: no active session}
    post:
      summary: Set a secret, sealed and signed by the operator
      responses:
        '200': {description: written}
        '401': {description: no active session}
  /api/secrets/{name}:
    delete:
      summary: Remove a secret from the channel
      parameters:
        - {name: name, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: removed}
        '401': {description: no active session}
  /api/cost/rates:
    get:
      summary: Published per-engine list prices, computed offline
      responses:
        '200': {description: rates}
  /api/cost/plan:
    post:
      summary: Estimate what one prompt would cost on each engine
      responses:
        '200': {description: estimate}
  /api/team:
    get:
      summary: Human teammates and machine agents, returned as separate identity classes
      description: >
        Combines locally available operator identities with remote operator entries from
        the signed channel roster. Agent ownership is deliberately null until a signed
        ownership and access-policy contract exists; clients must not infer authority.

        Each teammate and grant carries `access_ends` (RFC 3339, or null for access that does
        not end) and `access_lapsed` (true once a timed access has run out unrenewed);
        `revoked` stays true only for a revocation the master signed. `pending_access` lists
        the renewal questions waiting for the master (`id`, `text`, `options`, `asked_at`).
      responses:
        '200': {description: current operator, channel master, human teammates, agents, grants, and pending access questions}
        '401': {description: no active session}
  /api/orders:
    post:
      summary: Issue an order, optionally carrying files, signed by the session's operator
      description: >
        Each file is first sent with `PUT /api/orders/{id}/files/{name}` under the order id
        the caller chose; this call then copies them into the channel, records their names,
        sizes and sha256 in the signed payload, and issues the order. Caps and the refusal
        of secret-looking files are the same as for `ferry channel order --file`.
      requestBody:
        required: true
        content:
          application/json: {schema: {type: object}}
      responses:
        '200': {description: the order id and the files it carries}
        '400': {description: a file is refused (too large, secret-looking, unsafe name) or the order is malformed}
        '401': {description: no active session}
        '403': {description: dashboard is read-only}
        '409': {description: the channel refused the order (an id already used, or not allowed to issue it)}
  /api/orders/{id}/files/{name}:
    put:
      summary: Upload one file for an order that is about to be issued
      description: >
        The raw bytes are the request body. The file waits in the operator's upload area
        until `POST /api/orders` names it, and is removed when the order is issued.
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
        - {name: name, in: path, required: true, schema: {type: string}}
      responses:
        '204': {description: stored}
        '400': {description: unsafe name}
        '401': {description: no active session}
        '403': {description: dashboard is read-only}
        '413': {description: over the per-file cap}
  /api/tasks:
    get:
      summary: Summaries of every task, with signature status
      responses:
        '200': {description: task summaries}
  /api/tasks/{id}:
    get:
      summary: Full detail for one task (order, claims, results, reviews)
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: task detail}
        '404': {description: unknown task}
  /api/tasks/{id}/review:
    post:
      summary: Accept a result or send it back with notes, signed by the session's operator
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json: {schema: {$ref: '#/components/schemas/Review'}}
      responses:
        '200': {description: verdict submitted}
        '401': {description: no active session}
        '403': {description: dashboard is read-only}
        '409': {description: no result to review, or result fails the order's contract}
  /api/route/simulate:
    get:
      summary: Where work of a kind and size would go, and why, without running anything
      description: >
        Runs the smart router over the engines the fleet published, under this project's
        engine policy: each engine's chance of getting the work right, its price, whether it
        clears the kind's threshold, and why the others are out. The winner is the cheapest
        sufficient engine, or the likeliest when none is sufficient. The CLI equivalent is
        `ferry route simulate --kind docs --size small --needs vision`. Order cards show the
        `routing` reason a worker recorded for work already done; `GET /api/tasks/{id}` has
        the whole decision per result.
      parameters:
        - {name: project, in: query, required: false, schema: {type: string}}
        - {name: kind, in: query, required: false, schema: {type: string}, description: 'code-change, docs, tests, review, plan, chore, research, translate, transcribe, image, video, audio or other (default code-change)'}
        - {name: size, in: query, required: false, schema: {type: string, enum: [small, medium, large]}, description: default medium}
        - {name: needs, in: query, required: false, schema: {type: string}, description: 'extra modalities, comma separated: vision, audio_in, audio_out, image, video, embed'}
      responses:
        '200': {description: 'the decision (candidates, winner, reason), the lines `ferry route simulate` prints, and the order engines would be tried in'}
        '400': {description: a kind, size or modality that means nothing}
        '401': {description: no active session}
  /api/stats:
    get:
      summary: Per-engine acceptance rate and estimated cost, merged into one table
      responses:
        '200': {description: engine stats}
  /api/ledger:
    get:
      summary: Most recent ledger entries, newest first, with integrity flag
      responses:
        '200': {description: ledger entries}
  /api/learnings:
    get:
      summary: Most recent learning records, newest first
      responses:
        '200': {description: learning records}