openapi: 3.1.0
info:
  title: Revolution Road CMMS API
  version: 1.1.0
  description: Organization-scoped maintenance API for assets, work orders, store-to-district supplies, explainable risk, and ERP/accounting exchange.
  contact:
    name: Revolution Road CMMS developer support
    url: /contact
servers:
  - url: https://your-forge-host.example.com/api/v1
    description: Replace with your Revolution Road CMMS host
security:
  - bearerApiKey: []
tags:
  - name: Assets
  - name: Work orders
  - name: Data exchange
  - name: Predictive maintenance
  - name: Supplies
  - name: Team structure
paths:
  /assets:
    get:
      tags: [Assets]
      operationId: listAssets
      summary: List assets
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/AssetStatus' } }
        - { name: search, in: query, schema: { type: string, maxLength: 80 } }
      responses:
        '200': { description: Asset collection, content: { application/json: { schema: { $ref: '#/components/schemas/AssetCollection' } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Assets]
      operationId: createAsset
      summary: Create an asset
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/AssetInput' } } }
      responses:
        '201': { description: Asset created, content: { application/json: { schema: { type: object, required: [data], properties: { data: { $ref: '#/components/schemas/Asset' } } } } } }
        '422': { $ref: '#/components/responses/ValidationError' }
  /assets/{id}:
    parameters:
      - { $ref: '#/components/parameters/ResourceId' }
    get:
      tags: [Assets]
      operationId: getAsset
      summary: Get an asset
      responses:
        '200': { description: Asset, content: { application/json: { schema: { type: object, properties: { data: { $ref: '#/components/schemas/Asset' } } } } } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Assets]
      operationId: updateAsset
      summary: Update an asset
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/AssetInput' } } }
      responses:
        '200': { description: Updated asset }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      tags: [Assets]
      operationId: deleteAsset
      summary: Delete an unreferenced asset
      responses:
        '200': { description: Deletion acknowledgement }
        '409': { description: Asset is referenced, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } } }
  /analytics/assets/{id}/risk:
    parameters:
      - { $ref: '#/components/parameters/ResourceId' }
    get:
      tags: [Predictive maintenance]
      operationId: getAssetRisk
      summary: Get the latest explainable asset-risk result
      description: Returns a durable advisory result. It never changes a PM schedule or creates work.
      responses:
        '200':
          description: Latest risk result, evidence confidence, explanations, and human-review suggestion
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: object
                    required: [asset_id, risk_score, risk_band, confidence, explanations, suggested_pm_adjustment]
                    properties:
                      asset_id: { type: string, format: uuid }
                      risk_score: { type: number, minimum: 0, maximum: 1 }
                      risk_band: { type: string, enum: [low, moderate, high, critical] }
                      confidence: { type: number, minimum: 0, maximum: 1 }
                      estimated_days_to_failure: { type: [number, 'null'], minimum: 0 }
                      explanations: { type: array, items: { type: object } }
                      suggested_pm_adjustment: { type: object }
                  meta:
                    type: object
                    properties:
                      advisory_only: { type: boolean, const: true }
                      schedule_changed: { type: boolean, const: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /work-orders:
    get:
      tags: [Work orders]
      operationId: listWorkOrders
      summary: List work orders
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/WorkOrderStatus' } }
        - { name: asset_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200': { description: Work-order collection }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Work orders]
      operationId: createWorkOrder
      summary: Create a work order
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/WorkOrderInput' } } }
      responses:
        '201': { description: Work order created }
        '422': { $ref: '#/components/responses/ValidationError' }
  /work-orders/{id}:
    parameters:
      - { $ref: '#/components/parameters/ResourceId' }
    get:
      tags: [Work orders]
      operationId: getWorkOrder
      summary: Get a work order
      responses:
        '200': { description: Work order }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Work orders]
      operationId: updateWorkOrder
      summary: Update or advance a work order
      description: Status transitions are forward-only. Closing requires any requested approval to be approved.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/WorkOrderUpdate' } } }
      responses:
        '200': { description: Updated work order }
        '409': { description: Lifecycle or approval conflict, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } } }
    delete:
      tags: [Work orders]
      operationId: deleteWorkOrder
      summary: Delete an unreferenced work order
      responses:
        '200': { description: Deletion acknowledgement }
  /exports/assets.csv:
    get:
      tags: [Data exchange]
      operationId: exportAssetsCsv
      summary: Export up to 10,000 assets as CSV
      responses:
        '200': { description: CSV file, content: { text/csv: { schema: { type: string } } } }
  /exports/work-orders.csv:
    get:
      tags: [Data exchange]
      operationId: exportWorkOrdersCsv
      summary: Export up to 10,000 work orders as CSV
      responses:
        '200': { description: CSV file, content: { text/csv: { schema: { type: string } } } }
  /imports/assets:
    post:
      tags: [Data exchange]
      operationId: importAssetsCsv
      summary: Validate and upsert up to 1,000 assets by asset_tag
      requestBody:
        required: true
        content: { text/csv: { schema: { type: string } } }
      responses:
        '200': { description: Import summary }
        '422': { $ref: '#/components/responses/ValidationError' }
  /webhooks:
    get:
      tags: [Data exchange]
      operationId: listWebhooks
      summary: List signed webhook subscriptions
      responses:
        '200': { description: Webhook subscriptions }
    post:
      tags: [Data exchange]
      operationId: createWebhook
      summary: Create a webhook and return its signing secret once
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, endpoint_url, events]
              properties:
                name: { type: string, minLength: 2, maxLength: 80 }
                endpoint_url: { type: string, format: uri, pattern: '^https://' }
                events: { type: array, minItems: 1, maxItems: 50, items: { type: string, pattern: '^cmms\\.' } }
      responses:
        '201': { description: Webhook and one-time signing secret }
        '422': { $ref: '#/components/responses/ValidationError' }
  /webhooks/{id}:
    parameters:
      - { $ref: '#/components/parameters/ResourceId' }
    patch:
      tags: [Data exchange]
      operationId: updateWebhook
      summary: Update or pause a webhook
      responses:
        '200': { description: Updated webhook }
    post:
      tags: [Data exchange]
      operationId: rotateWebhookSecret
      summary: Rotate the webhook signing secret
      responses:
        '200': { description: Updated webhook and one-time signing secret }
    delete:
      tags: [Data exchange]
      operationId: deleteWebhook
      summary: Delete a webhook subscription
      responses:
        '200': { description: Deletion acknowledgement }
  /supplies:
    get:
      tags: [Supplies]
      operationId: listSupplyRequests
      summary: List scoped supply requests
      parameters:
        - { name: store_id, in: query, schema: { type: string, format: uuid } }
        - { name: district_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/SupplyStatus' } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } }
      responses:
        '200': { description: Supply request collection }
    post:
      tags: [Supplies]
      operationId: createSupplyRequest
      summary: Create a store supply request
      description: District and requester are derived by the server.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/SupplyRequestInput' } } }
      responses:
        '201': { description: Supply request created }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /supplies/{id}:
    parameters:
      - { $ref: '#/components/parameters/ResourceId' }
    get:
      tags: [Supplies]
      operationId: getSupplyRequest
      summary: Get a supply request and timeline
      responses:
        '200': { description: Supply request }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Supplies]
      operationId: updateSupplyRequest
      summary: Update supply-list notes
      description: Item quantities can only be increased by adding supplies, reduced with the quantity-removal action, delivered, or deleted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [notes]
              properties:
                notes: { type: [string, 'null'], maxLength: 2000 }
      responses:
        '200': { description: Supply request updated }
        '409': { description: Request is no longer editable }
  /supplies/{id}/mark_partial:
    post:
      tags: [Supplies]
      operationId: markSupplyPartial
      summary: Add delivered quantities to request items
      parameters: [{ $ref: '#/components/parameters/ResourceId' }]
      responses:
        '200': { description: Partial delivery recorded }
        '409': { description: Request is already final }
  /supplies/{id}/mark_delivered:
    post:
      tags: [Supplies]
      operationId: markSupplyDelivered
      summary: Complete every remaining item quantity
      parameters: [{ $ref: '#/components/parameters/ResourceId' }]
      responses:
        '200': { description: Delivery completed }
        '409': { description: Request is already delivered or cancelled }
  /supplies/{id}/cancel:
    post:
      tags: [Supplies]
      operationId: cancelSupplyRequest
      summary: Cancel an open supply request
      parameters: [{ $ref: '#/components/parameters/ResourceId' }]
      responses:
        '200': { description: Request cancelled }
        '409': { description: Request is already final }
  /supplies/export:
    get:
      tags: [Supplies, Data exchange]
      operationId: exportSupplyRequests
      summary: Export supply requests and item quantities as CSV
      responses:
        '200': { description: UTF-8 CSV, content: { text/csv: { schema: { type: string } } } }
  /team/markets:
    get:
      tags: [Team structure]
      operationId: listMarkets
      summary: List organization markets
      responses:
        '200': { description: Market collection }
    post:
      tags: [Team structure]
      operationId: createMarket
      summary: Create a market
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/MarketInput' } } }
      responses:
        '201': { description: Market created }
        '409': { description: A market with that name already exists }
  /team/markets/{marketId}/districts:
    parameters:
      - { name: marketId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Team structure]
      operationId: listMarketDistricts
      summary: List districts in a market
      responses:
        '200': { description: District collection }
    post:
      tags: [Team structure]
      operationId: createMarketDistrict
      summary: Create a district under a market
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/DistrictInput' } } }
      responses:
        '201': { description: District created }
        '404': { $ref: '#/components/responses/NotFound' }
  /team/districts/{districtId}/stores:
    parameters:
      - { name: districtId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Team structure]
      operationId: listDistrictStores
      summary: List stores in a district
      responses:
        '200': { description: Store collection }
    post:
      tags: [Team structure]
      operationId: createDistrictStore
      summary: Create a store under a district
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/StoreInput' } } }
      responses:
        '201': { description: Store created }
        '404': { $ref: '#/components/responses/NotFound' }
  /team/stores/{storeId}:
    parameters:
      - { name: storeId, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Team structure]
      operationId: getStore
      summary: Get a store visible to the caller
      responses:
        '200': { description: Store record }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Team structure]
      operationId: updateStore
      summary: Update a store as an Admin
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/StoreInput' } } }
      responses:
        '200': { description: Store updated }
        '403': { description: Admin role required }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Team structure]
      operationId: deleteStore
      summary: Delete an unused store as an Admin
      responses:
        '204': { description: Store deleted }
        '403': { description: Admin role required }
        '409': { description: Store has assignments or maintenance history }
  /team/positions:
    get:
      tags: [Team structure]
      operationId: listPositions
      summary: List scoped positions
      responses:
        '200': { description: Position collection }
    post:
      tags: [Team structure]
      operationId: createPosition
      summary: Create a custom scoped position
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/PositionInput' } } }
      responses:
        '201': { description: Position created }
  /team/members:
    get:
      tags: [Team structure]
      operationId: listTeamAssignments
      summary: List scoped position assignments
      parameters:
        - { name: market_id, in: query, schema: { type: string, format: uuid } }
        - { name: district_id, in: query, schema: { type: string, format: uuid } }
        - { name: store_id, in: query, schema: { type: string, format: uuid } }
        - { name: position_key, in: query, schema: { type: string } }
        - { name: active, in: query, schema: { type: boolean } }
      responses:
        '200': { description: Assignment collection }
    post:
      tags: [Team structure]
      operationId: createTeamAssignment
      summary: Assign an active member to a position and matching scope
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/TeamAssignmentInput' } } }
      responses:
        '201': { description: Assignment created, optionally with overlap warnings }
        '400': { description: Position and selected scope do not match }
        '409': { description: The active assignment already exists }
  /team/members/{id}/deactivate:
    parameters:
      - { $ref: '#/components/parameters/ResourceId' }
    post:
      tags: [Team structure]
      operationId: deactivateTeamAssignment
      summary: Deactivate an assignment while retaining its history
      responses:
        '200': { description: Assignment deactivated }
        '404': { $ref: '#/components/responses/NotFound' }
components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: forge_live
      description: One-time API key created in the Revolution Road developer center.
  parameters:
    ResourceId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Unauthorized:
      description: API key missing, invalid, expired, or revoked
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    RateLimited:
      description: Per-key minute limit exceeded
      headers:
        Retry-After: { schema: { type: integer } }
        X-RateLimit-Limit: { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
        X-RateLimit-Reset: { schema: { type: string, format: date-time } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    ValidationError:
      description: Input validation failed
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    NotFound:
      description: Resource not found in the API key organization
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
  schemas:
    AssetStatus: { type: string, enum: [active, inactive, retired] }
    WorkOrderStatus: { type: string, enum: [open, in_progress, closed] }
    SupplyStatus: { type: string, enum: [requested, partially_delivered, delivered, cancelled] }
    TeamScopeType: { type: string, enum: [market, district, store] }
    Priority: { type: string, enum: [low, medium, high, critical] }
    Asset:
      type: object
      required: [id, asset_tag, name, status, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        asset_tag: { type: string }
        name: { type: string }
        location: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/AssetStatus' }
        metadata: { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    AssetInput:
      type: object
      required: [name]
      properties:
        asset_tag: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$' }
        name: { type: string, minLength: 2, maxLength: 120 }
        location: { type: [string, 'null'], maxLength: 160 }
        status: { $ref: '#/components/schemas/AssetStatus' }
        metadata: { type: object, additionalProperties: true }
    WorkOrderInput:
      type: object
      required: [title]
      properties:
        asset_id: { type: [string, 'null'], format: uuid }
        title: { type: string, minLength: 4, maxLength: 160 }
        description: { type: [string, 'null'], maxLength: 4000 }
        priority: { $ref: '#/components/schemas/Priority' }
        due_date: { type: [string, 'null'], format: date }
    SupplyRequestInput:
      type: object
      required: [store_id, items]
      properties:
        store_id: { type: string, format: uuid }
        notes: { type: [string, 'null'], maxLength: 2000 }
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            required: [description, quantity]
            properties:
              sku: { type: [string, 'null'], maxLength: 80 }
              description: { type: string, minLength: 1, maxLength: 240 }
              quantity: { type: integer, minimum: 1, maximum: 100000 }
              unit: { type: [string, 'null'], maxLength: 40 }
    MarketInput:
      type: object
      required: [name, timezone]
      properties:
        name: { type: string, minLength: 2, maxLength: 120 }
        legal_name: { type: [string, 'null'], maxLength: 160 }
        timezone: { type: string, minLength: 3, maxLength: 64 }
        contact: { type: object, additionalProperties: true }
    DistrictInput:
      type: object
      required: [code, name]
      properties:
        code: { type: string, minLength: 1, maxLength: 32 }
        name: { type: string, minLength: 2, maxLength: 120 }
        timezone: { type: string, minLength: 3, maxLength: 64 }
        contact: { type: object, additionalProperties: true }
    StoreInput:
      type: object
      required: [code, name]
      properties:
        code: { type: string, minLength: 1, maxLength: 32 }
        name: { type: string, minLength: 2, maxLength: 120 }
        location: { type: [string, 'null'], maxLength: 240 }
        timezone: { type: string, minLength: 3, maxLength: 64 }
    PositionInput:
      type: object
      required: [key, title, scope_type]
      properties:
        key: { type: string, pattern: '^[a-z][a-z0-9_]{1,63}$' }
        title: { type: string, minLength: 2, maxLength: 120 }
        description: { type: [string, 'null'], maxLength: 500 }
        scope_type: { $ref: '#/components/schemas/TeamScopeType' }
    TeamAssignmentInput:
      type: object
      required: [user_id, position_key]
      description: Exactly one scope ID must be supplied, and it must match the position scope.
      properties:
        user_id: { type: string, format: uuid }
        position_key: { type: string }
        market_id: { type: [string, 'null'], format: uuid }
        district_id: { type: [string, 'null'], format: uuid }
        store_id: { type: [string, 'null'], format: uuid }
        notes: { type: [string, 'null'], maxLength: 1200 }
    WorkOrderUpdate:
      allOf:
        - { $ref: '#/components/schemas/WorkOrderInput' }
        - type: object
          properties:
            status: { $ref: '#/components/schemas/WorkOrderStatus' }
            assigned_to: { type: [string, 'null'], format: uuid }
    AssetCollection:
      type: object
      required: [data, meta]
      properties:
        data: { type: array, items: { $ref: '#/components/schemas/Asset' } }
        meta:
          type: object
          properties: { count: { type: integer }, limit: { type: integer } }
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { type: string }
            message: { type: string }
            request_id: { type: string, format: uuid }
            details: {}
