openapi: 3.1.0
info:
  title: Finance Portal Control API
  version: 1.6.0
  description: >-
    Same-origin control and read API for monthly cloud close. Production access
    is expected to be protected by Cloud Run IAM or IAP. Real business mutations
    remain disabled pending v7 execution contracts. Alert acknowledgement uses
    a separate server-bound operator credential. Observation reads use local projections.
servers:
  - url: /api/v1
paths:
  /tasks:
    get:
      operationId: listOperationTasks
      responses:
        '200': { description: 'Task catalog with scopes, collection checkpoint and optional latest_run. CRM load exposes crm_tables with an independent latest_run for each Finance prerequisite table; missing tables stay unknown. Its aggregate latest_run is only the latest single-table audit. Shared CRM extraction is diagnostic latest_job_run, never finance-subset latest_run. Scope is read-only observation, not an execution allowlist or full dbt closure. Checkpoints older than three minutes are unknown; can_run remains false until execution contracts are bound.' }
        '503': { description: Task projection unavailable; retain stale UI data and retry the read }
  /dependencies:
    get:
      operationId: listOperationDependencies
      responses:
        '200': { description: 'Design edges in items remain separate from observed_runs (latest 50 CRM table-load records with current manifest evidence). Identity/table/time consistency is not an immutable loader-generation receipt or target/business acceptance.' }
        '503': { description: Runtime evidence projection unavailable; preserve previously displayed results as stale }
  /runs:
    get:
      operationId: listProviderRuns
      parameters:
        - { in: query, name: task, schema: { type: string } }
        - { in: query, name: provider, schema: { type: string } }
        - { in: query, name: status, schema: { type: string, enum: [pending, succeeded, failed, running, cancelled, unknown] } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { in: query, name: cursor, schema: { type: string }, description: Opaque keyset cursor returned as next_cursor; use with the same filters }
      responses:
        '200':
          description: Independent provider runs, without fabricated close identities
          content:
            application/json:
              schema:
                type: object
                required: [items, next_cursor, environment]
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/ProviderRun' } }
                  next_cursor: { type: string }
                  environment: { type: string }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /runs/{id}:
    get:
      operationId: getProviderRun
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Run with raw and normalized execution state, independent verification and asset references
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProviderRun' }
        '404': { $ref: '#/components/responses/NotFound' }
  /schedules:
    get:
      operationId: listObservedSchedules
      responses:
        '200': { description: Bounded latest schedule projections (max 500), owner state, timezone, next time and coverage }
  /triggers:
    get:
      operationId: listObservedTriggers
      responses:
        '200': { description: 'Latest 500 trigger/response evidence records ordered by event time, not 500 deduced attempts. Optional evidence_kind=scheduler_log, phase=started|finished, scheduled_at, raw_status and http_status distinguish log evidence from latest-attempt snapshots. dispatch_started is not execution success. run_id stays absent without an explicit correlation; coverage remains partial.' }
  /collection-status:
    get:
      operationId: listCollectionCheckpoints
      responses:
        '200': { description: Provider checkpoints, current scan-window lower bound, last collection time, errors and coverage gaps. Internal provider page tokens are not exposed. }
  /alerts:
    get:
      operationId: listOperationAlerts
      responses:
        '200': { description: Latest 500 alert projections with acknowledgement actor, recovery, notification status and can_acknowledge capability }
  /alerts/{id}/acknowledge:
    post:
      operationId: acknowledgeOperationAlert
      security: [{ alertOperatorBearer: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200': { description: Acknowledgement committed; task execution state unchanged }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /tasks/{key}/actions:
    post:
      operationId: requestOperationTaskAction
      parameters:
        - { in: path, name: key, required: true, schema: { type: string } }
      responses:
        '409': { description: Execution contract unbound; exact task scope and missing contract returned, no provider submission }
        '404': { $ref: '#/components/responses/NotFound' }
  /runtime:
    get:
      operationId: getRuntime
      summary: Read runtime capabilities and safety gates
      responses:
        '200':
          description: Runtime capabilities
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RuntimeInfo' }
  /close-observations/current:
    get:
      operationId: observeCurrentClose
      summary: Read a background-collected close observation
      parameters:
        - in: query
          name: bill_month
          schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' }
          description: Defaults to the previous Asia/Shanghai calendar month.
        - in: query
          name: mode
          schema: { type: string, enum: [predicted, actual], default: predicted }
      responses:
        '200':
          description: Live provider evidence synthesized into the close DAG
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CloseObservation' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /closes:
    get:
      operationId: listCloses
      summary: List persisted local close runs
      responses:
        '200':
          description: Close list
    post:
      operationId: createClose
      summary: Create one close identity
      security: [{ mutationBearer: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bill_month, mode]
              properties:
                bill_month: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' }
                mode: { type: string, enum: [predicted, actual] }
      responses:
        '201': { description: Close created }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
  /closes/{close_id}:
    get:
      operationId: getClose
      parameters:
        - $ref: '#/components/parameters/CloseID'
      responses:
        '200': { description: Close state }
        '404': { $ref: '#/components/responses/NotFound' }
  /closes/{close_id}/attempts:
    get:
      operationId: listCloseAttempts
      parameters:
        - $ref: '#/components/parameters/CloseID'
      responses:
        '200': { description: Immutable attempts for the close }
        '404': { $ref: '#/components/responses/NotFound' }
  /assets:
    get:
      operationId: listAssets
      summary: List close-step freshness projections
      responses:
        '200':
          description: Asset freshness list
  /closes/{close_id}/steps/{step_key}/actions:
    post:
      operationId: executeCloseStepAction
      summary: Submit one exact-scope step action
      description: Retries require a new immutable attempt. Observe mode always returns 403.
      security: [{ mutationBearer: [] }]
      parameters:
        - $ref: '#/components/parameters/CloseID'
        - in: path
          name: step_key
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action, reason]
              properties:
                action: { type: string, enum: [run, retry, verify, reconcile] }
                reason: { type: string, minLength: 4, maxLength: 500 }
      responses:
        '202': { description: Immutable attempt accepted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
components:
  securitySchemes:
    alertOperatorBearer:
      type: http
      scheme: bearer
      description: Separate alert credential bound to an operator identity on the server; cannot dispatch provider tasks
    mutationBearer:
      type: http
      scheme: bearer
  parameters:
    CloseID:
      in: path
      name: close_id
      required: true
      schema: { type: string }
  responses:
    Unauthorized:
      description: Authentication failed
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
    Forbidden:
      description: Runtime write gate denied the action
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
    NotFound:
      description: Resource not found
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
    Conflict:
      description: Current state conflicts with the requested action
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
    Unprocessable:
      description: Scope or request validation failed
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
    Unavailable:
      description: Live observation is not configured or cannot be assembled
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
  schemas:
    ProviderRun:
      type: object
      required: [id, provider, environment, task, external_id, status, raw_status, verification, started_at, event_at, observed_at, message]
      properties:
        id: { type: string }
        provider: { type: string }
        environment: { type: string }
        task: { type: string }
        external_id: { type: string }
        status: { type: string, enum: [pending, succeeded, failed, running, cancelled, unknown] }
        raw_status: { type: string }
        verification: { type: string, enum: [not_checked, passed, failed] }
        started_at: { type: string, format: date-time, description: Source audit timestamp where actual execution start is unavailable; see message }
        finished_at: { type: string, format: date-time }
        event_at: { type: string, format: date-time }
        observed_at: { type: string, format: date-time }
        bill_month: { type: string }
        input_refs: { type: array, items: { type: string } }
        output_refs: { type: array, items: { type: string } }
        time_basis: { type: string, description: 'audit_recorded_at or created_at means started_at is an audit/creation timestamp, not proven execution start.' }
        manifest:
          type: object
          description: Current object evidence only; never changes the run verification to passed
          properties:
            uri: { type: string }
            generation: { type: string }
            sha256: { type: string }
            run_id: { type: string }
            source_table: { type: string }
            status: { type: string, enum: [consistent, current_pointer_only, unavailable, unbound, mismatch] }
            message: { type: string }
            observed_at: { type: string, format: date-time }
        message: { type: string }
    RuntimeInfo:
      type: object
      required: [runtime_mode, live_observation_enabled, write_actions_enabled, default_bill_month]
      properties:
        runtime_mode: { type: string, enum: [demo, observe, direct] }
        live_observation_enabled: { type: boolean }
        write_actions_enabled: { type: boolean }
        default_bill_month: { type: string }
        old_cloud_run_dependency: { type: boolean, const: false }
        source_sha: { type: string }
        migrated_steps: { type: array, items: { type: string } }
    CloseObservation:
      type: object
      required: [observed_at, close, assets, dependencies, next_gate, write_actions_enabled, write_gate, credential_gaps]
      properties:
        observed_at: { type: string, format: date-time }
        close: { type: object, additionalProperties: true }
        assets: { type: array, items: { type: object, additionalProperties: true } }
        dependencies:
          type: array
          items: { $ref: '#/components/schemas/DependencyObservation' }
        next_gate: { type: string }
        write_actions_enabled: { type: boolean, const: false }
        write_gate: { type: string }
        credential_gaps: { type: array, items: { type: string } }
    DependencyObservation:
      type: object
      required: [key, label, status, message, credential_configured]
      properties:
        key: { type: string }
        label: { type: string }
        status: { type: string, enum: [healthy, degraded, unavailable] }
        message: { type: string }
        observed_at: { type: string, format: date-time }
        credential_configured: { type: boolean }
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { type: string }
            message: { type: string }
            request_id: { type: string }
            details: {}
