openapi: 3.1.0
info:
  title: Astral External API
  version: 0.1.0
  description: >
    Western astrology computation API (Phase 0 spine). All computation
    endpoints are POST; settings and subject resolution are echoed in
    meta on every response.
servers:
  - url: /
    description: The host serving this document
security:
  - bearerKey: []
paths:
  /health:
    get:
      operationId: getHealth
      security: []
      summary: Process liveness
      responses:
        "200":
          description: Process alive (always 200 while serving; never queue-coupled)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [alive] }
                  se_version: { type: string }
                  calc_version: { type: string }
                  panics_total: { type: integer }
  /ready:
    get:
      operationId: getReady
      security: []
      summary: Engine readiness (pooled calculation probe)
      responses:
        "200":
          description: Pool warm and responsive
        "503":
          description: Saturated or cold
  /v1/western/positions:
    post:
      operationId: westernPositions
      summary: Planetary positions for the subject moment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              additionalProperties: false
              properties:
                subject: { $ref: "#/components/schemas/Subject" }
                settings: { $ref: "#/components/schemas/Settings" }
            example:
              subject:
                datetime: "1992-11-22T08:45:00+05:30"
                location: { latitude: 28.6139, longitude: 77.209 }
      responses:
        "200":
          description: Positions with envelope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PositionsEnvelope" }
        "400": { $ref: "#/components/responses/Problem" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "500": { $ref: "#/components/responses/Problem" }
        "503": { $ref: "#/components/responses/Problem" }
  /v1/western/houses:
    post:
      operationId: westernHouses
      summary: House cusps and angles for the subject moment
      description: >
        Unlike /v1/western/positions, subject.location is required here (houses
        cannot compute without coordinates) — an omitted location returns
        subject.location.required (422), and a location with only latitude or
        only longitude returns subject.location.incomplete (422).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              additionalProperties: false
              properties:
                subject: { $ref: "#/components/schemas/Subject" }
                settings: { $ref: "#/components/schemas/Settings" }
            example:
              subject:
                datetime: "1992-11-22T08:45:00+05:30"
                location: { latitude: 28.6139, longitude: 77.209 }
      responses:
        "200":
          description: House cusps + angles with envelope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HousesEnvelope" }
        "400": { $ref: "#/components/responses/Problem" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "500": { $ref: "#/components/responses/Problem" }
        "503": { $ref: "#/components/responses/Problem" }
  /v1/western/aspects:
    post:
      operationId: westernAspects
      summary: Pairwise aspect scan between the requested bodies' positions
      description: >
        subject.location is NOT required (aspect math is purely relative —
        angular separation between longitudes — and never needs a house
        frame); a location, if supplied, is validated and echoed like every
        other endpoint but otherwise ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              additionalProperties: false
              properties:
                subject: { $ref: "#/components/schemas/Subject" }
                settings: { $ref: "#/components/schemas/Settings" }
            example:
              subject:
                datetime: "2000-01-01T12:00:00Z"
      responses:
        "200":
          description: Aspect list + summary with envelope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AspectsEnvelope" }
        "400": { $ref: "#/components/responses/Problem" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "500": { $ref: "#/components/responses/Problem" }
        "503": { $ref: "#/components/responses/Problem" }
  /v1/western/natal:
    post:
      operationId: westernNatal
      summary: Composed birth chart — positions + houses + aspects + distributions
      description: >
        Unlike /v1/western/positions and /v1/western/aspects, subject.location
        is required here (houses cannot compute without coordinates) — an
        omitted location returns subject.location.required (422), and a
        location with only latitude or only longitude returns
        subject.location.incomplete (422). One
        server-side pooled calculation composes already-golden-tested atoms:
        positions with house placement, houses, an aspect scan, and element/
        modality/polarity/hemisphere distributions in a single call
        (credits_charged: 2).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              additionalProperties: false
              properties:
                subject: { $ref: "#/components/schemas/Subject" }
                settings: { $ref: "#/components/schemas/Settings" }
            example:
              subject:
                datetime: "1992-11-22T08:45:00+05:30"
                location: { latitude: 28.6139, longitude: 77.209 }
      responses:
        "200":
          description: Full natal chart with envelope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NatalEnvelope" }
        "400": { $ref: "#/components/responses/Problem" }
        "401": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "500": { $ref: "#/components/responses/Problem" }
        "503": { $ref: "#/components/responses/Problem" }
components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: "API key, e.g. ak_test_..."
  responses:
    Problem:
      description: RFC 9457 problem
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
  schemas:
    Subject:
      type: object
      additionalProperties: false
      required: [datetime]
      properties:
        datetime:
          type: string
          description: ISO 8601 with UTC offset (required).
          example: "1992-11-22T08:45:00+05:30"
        location:
          type: object
          additionalProperties: false
          description: >
            Latitude and longitude must be provided together — a location
            with only one of them is rejected with 422
            subject.location.incomplete. Omit location entirely when not
            needed (positions/aspects).
          properties:
            latitude: { type: number, minimum: -90, maximum: 90, example: 28.6139 }
            longitude: { type: number, minimum: -180, maximum: 180, example: 77.209 }
    Settings:
      type: object
      additionalProperties: false
      properties:
        zodiac: { type: string, enum: [tropical, sidereal], default: tropical, example: tropical }
        ayanamsa:
          type: string
          enum: [lahiri, raman, krishnamurti]
          description: Required iff zodiac=sidereal.
          # Deliberately no `example` here: zodiac's own example is
          # "tropical" (matching its default), and ayanamsa is only valid
          # when zodiac=sidereal — an example value here would combine with
          # zodiac's example into a 422 (settings.ayanamsa.not_applicable)
          # if a client naively merges every property's example together.
        node_type: { type: string, enum: ["true", mean], default: "true", example: "true" }
        bodies:
          type: array
          minItems: 1
          items:
            type: string
            enum: [sun, moon, mercury, venus, mars, jupiter, saturn, uranus,
                   neptune, pluto, mean_node, true_node, chiron,
                   lilith_mean, ceres, pallas, juno, vesta]
          example: [sun, moon, mars]
          description: >
            When provided, must be non-empty — an empty array is rejected
            with 422 settings.bodies.empty. Omit to use the default set (the
            ten planets + node per node_type).
        house_system:
          type: string
          enum: [placidus, koch, whole_sign, equal, equal_mc, aries_equal, campanus,
                 regiomontanus, porphyry, alcabitius, morinus, topocentric, krusinski,
                 vehlow, sripati, meridian, horizon, carter, sunshine, apc, pullen_sd, pullen_sr]
          default: placidus
          example: placidus
          description: >
            22 Swiss-Ephemeris-backed house systems. Nine systems are defined
            at every latitude (whole_sign, equal, equal_mc, aries_equal,
            vehlow, sripati, porphyry, morinus, meridian). Systems outside
            that list return 422 settings.house_system.undefined_at_latitude
            where the engine cannot compute them — in the current engine
            version that is placidus and koch above the polar circles; other
            quadrant systems compute at all latitudes but degrade numerically
            near the poles. The 422 carries the failing latitude and an
            available_systems array listing the 9 all-latitude systems. (A
            23rd candidate, Gauquelin sectors, was evaluated and excluded: it
            emits a 36-sector output, not a 12-cusp house layout, so it isn't
            representable in this enum.)
        lots_formula:
          type: string
          enum: [sect_aware, day_formula_always]
          default: sect_aware
          example: sect_aware
          description: >
            Governs which day/night arithmetic computes the houses endpoint's
            Part of Fortune. sect_aware (default) selects the formula matching
            the Sun's sect (day/night, i.e. above/below the horizon) at the
            subject moment; day_formula_always always applies the day formula
            regardless of sect.
        aspects:
          type: object
          additionalProperties: false
          properties:
            set:
              type: string
              enum: [ptolemaic, ptolemaic_plus_minor]
              default: ptolemaic
              example: ptolemaic
              description: >
                ptolemaic (default): the 5 major aspects — conjunction 0°,
                opposition 180°, trine 120°, square 90°, sextile 60° — at
                their documented default orbs 8/8/7/7/5. ptolemaic_plus_minor
                adds quincunx 150° (orb 3), semisextile 30° (2), semisquare
                45° (2), sesquiquadrate 135° (2), quintile 72° (2), biquintile
                144° (2). (A custom aspect set was evaluated and deferred —
                not in this enum; see docs/western-api-v1-spec.md §1.)
            orbs:
              type: object
              additionalProperties: { type: number }
              example: { conjunction: 8, opposition: 8, trine: 7, square: 7, sextile: 5 }
              description: >
                Optional per-aspect-type orb overrides in decimal degrees,
                keyed by aspect name (conjunction, opposition, trine, square,
                sextile, quincunx, semisextile, semisquare, sesquiquadrate,
                quintile, biquintile). Aspect types outside the resolved set
                are ignored even if an override is supplied. Types within the
                set that aren't overridden use their documented default orb.
                The fully-resolved map (only the types in the resolved set)
                is echoed in meta.settings_resolved.
    Position:
      type: object
      properties:
        body: { type: string }
        longitude: { type: number, description: Decimal degrees }
        sign: { type: string }
        sign_longitude:
          type: object
          properties:
            degrees: { type: integer }
            minutes: { type: integer }
            seconds: { type: integer }
        element: { type: string, enum: [fire, earth, air, water] }
        modality: { type: string, enum: [cardinal, fixed, mutable] }
        speed: { type: number, description: Degrees/day }
        retrograde: { type: boolean }
        declination: { type: number }
        out_of_bounds: { type: boolean, description: "|declination| exceeds the true obliquity of date" }
        house:
          type: integer
          minimum: 1
          maximum: 12
          description: >
            Present when subject.location is provided (a polar-latitude
            quadrant house_system 422s the whole request rather than omitting
            this field per-body — same contract as /v1/western/houses).
    Cusp:
      type: object
      properties:
        house: { type: integer, minimum: 1, maximum: 12 }
        longitude: { type: number, description: Decimal degrees }
        sign: { type: string }
        sign_longitude:
          type: object
          properties:
            degrees: { type: integer }
            minutes: { type: integer }
            seconds: { type: integer }
    AnglePoint:
      type: object
      properties:
        longitude: { type: number, description: Decimal degrees }
        sign: { type: string }
        sign_longitude:
          type: object
          properties:
            degrees: { type: integer }
            minutes: { type: integer }
            seconds: { type: integer }
    PartOfFortune:
      allOf:
        - $ref: "#/components/schemas/AnglePoint"
        - type: object
          properties:
            sect:
              type: string
              enum: [day, night]
              description: >
                Whether the Sun stood above (day) or below (night) the
                horizon at the subject moment (determined from the Sun's
                house-frame position; inside the polar circles during
                midnight sun/polar night this house-frame convention may
                differ from the Sun's visible altitude).
            formula:
              type: string
              enum: [day, night]
              description: >
                Which day/night arithmetic actually computed this value
                (settings.lots_formula). Can diverge from sect when
                lots_formula=day_formula_always.
    HousesData:
      type: object
      description: >
        The house cusps + angles fragment. This IS `data` for the standalone
        houses endpoint, and is nested under natal's `data.houses` key.
      properties:
        house_system: { type: string }
        cusps:
          type: array
          items: { $ref: "#/components/schemas/Cusp" }
        angles:
          type: object
          properties:
            ascendant: { $ref: "#/components/schemas/AnglePoint" }
            mc: { $ref: "#/components/schemas/AnglePoint" }
            descendant: { $ref: "#/components/schemas/AnglePoint" }
            ic: { $ref: "#/components/schemas/AnglePoint" }
            vertex: { $ref: "#/components/schemas/AnglePoint" }
            part_of_fortune: { $ref: "#/components/schemas/PartOfFortune" }
    Meta:
      type: object
      description: Provenance envelope present on every computation response (v1 spec §2.3/P8).
      properties:
        calc_version: { type: string }
        settings_resolved: { $ref: "#/components/schemas/Settings" }
        subject_resolved:
          type: object
          properties:
            utc_datetime: { type: string }
            utc_offset: { type: string }
            coordinates:
              type: [object, "null"]
              properties:
                latitude: { type: number }
                longitude: { type: number }
            time_known: { type: boolean }
        credits_charged: { type: integer }
    HousesEnvelope:
      type: object
      properties:
        data: { $ref: "#/components/schemas/HousesData" }
        meta: { $ref: "#/components/schemas/Meta" }
    PositionsEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            positions:
              type: array
              items: { $ref: "#/components/schemas/Position" }
        meta: { $ref: "#/components/schemas/Meta" }
    Aspect:
      type: object
      properties:
        type:
          type: string
          enum: [conjunction, opposition, trine, square, sextile, quincunx,
                 semisextile, semisquare, sesquiquadrate, quintile, biquintile]
        body_a:
          type: string
          description: >
            The faster-moving of the two bodies (Kerykeion convention),
            stable scan order.
        body_b: { type: string }
        angle: { type: number, description: "Exact aspect-type angle in decimal degrees (e.g. trine = 120.0), unrounded." }
        separation: { type: number, description: "Wrap-aware angular distance between the two longitudes (0-180°), 4dp." }
        orb: { type: number, description: "|separation - angle| — deviation from exact, 4dp." }
        applying: { type: boolean, description: "True iff movement=applying." }
        movement:
          type: string
          enum: [applying, separating, static]
          description: >
            Derived from relative speed: the faster body closing the gap
            toward exact aspect_type angle is applying; moving away is
            separating; |relative speed| < 1e-6 deg/day is static.
    AspectsSummary:
      type: object
      properties:
        total: { type: integer }
        by_type:
          type: object
          additionalProperties: { type: integer }
          description: Aspect-type name to count, only types present in the response.
    Distribution:
      type: object
      properties:
        counts:
          type: object
          additionalProperties: { type: integer }
        percentages:
          type: object
          additionalProperties: { type: number }
          description: count / total * 100, rounded to 1dp.
    Hemispheres:
      type: object
      description: >
        Present iff every returned body has a house (i.e. subject.location
        was provided) AND at least one body is returned; null otherwise.
      properties:
        east_west:
          type: object
          properties:
            east: { type: integer }
            west: { type: integer }
            dominant: { type: string, enum: [east, west, balanced] }
        north_south:
          type: object
          properties:
            north: { type: integer }
            south: { type: integer }
            dominant: { type: string, enum: [north, south, balanced] }
    Distributions:
      type: object
      description: >
        Element/modality/polarity/hemisphere breakdown over the bodies
        present in the response (default 11 or settings.bodies).
      properties:
        elements: { $ref: "#/components/schemas/Distribution" }
        modalities: { $ref: "#/components/schemas/Distribution" }
        polarities: { $ref: "#/components/schemas/Distribution" }
        hemispheres:
          oneOf:
            - $ref: "#/components/schemas/Hemispheres"
            - type: "null"
    AspectsEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            aspects:
              type: array
              items: { $ref: "#/components/schemas/Aspect" }
            aspects_summary: { $ref: "#/components/schemas/AspectsSummary" }
        meta: { $ref: "#/components/schemas/Meta" }
    NatalEnvelope:
      type: object
      properties:
        data:
          type: object
          description: >
            positions/houses keep their own keys; aspects/aspects_summary
            flatten to the top level (same shape as the standalone /aspects
            endpoint); distributions gets its own key.
          properties:
            positions:
              type: array
              items: { $ref: "#/components/schemas/Position" }
            houses: { $ref: "#/components/schemas/HousesData" }
            aspects:
              type: array
              items: { $ref: "#/components/schemas/Aspect" }
            aspects_summary: { $ref: "#/components/schemas/AspectsSummary" }
            distributions: { $ref: "#/components/schemas/Distributions" }
        meta: { $ref: "#/components/schemas/Meta" }
    Problem:
      type: object
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        code: { type: string, description: Stable namespaced error code }
        detail: { type: string }
        request_id: { type: string }
        latitude:
          type: number
          description: Present on settings.house_system.undefined_at_latitude.
        available_systems:
          type: array
          items: { type: string }
          description: House systems that succeed at every latitude (present on the same error).
