{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://golfsga.com/sga-ois/schema/0.1/update.schema.json",
  "title": "SGA-OIS firmware relay messages",
  "description": "The engine fetching a firmware image on a device's behalf, for hardware that cannot reach the internet itself: a BLE-only accessory with no screen, or a LAN device on a network with no outbound access. Both otherwise become a generation of gear that never gets patched. The engine's own connection is tried first and a paired member's phone only as a fallback -- the phone is somebody's data plan. Limits and destination rules are in 13 section 3.1.",
  "oneOf": [
    {
      "$ref": "#/$defs/updateFetch"
    },
    {
      "$ref": "#/$defs/updateBegin"
    },
    {
      "$ref": "#/$defs/updateData"
    },
    {
      "$ref": "#/$defs/updateEnd"
    },
    {
      "$ref": "#/$defs/updateRefused"
    }
  ],
  "$defs": {
    "updateFetch": {
      "type": "object",
      "required": [
        "type",
        "url"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "const": "update.fetch",
          "description": "Message type discriminator. Always update.fetch."
        },
        "request_id": {
          "type": "string",
          "maxLength": 64,
          "description": "Correlates every message in one transfer. The device chooses it; the engine echoes it on BEGIN, DATA, END and REFUSED, so several transfers can be in flight at once."
        },
        "url": {
          "type": "string",
          "maxLength": 2048,
          "description": "MUST be https and MUST NOT resolve to a private or reserved address. There is no allowlist of vendor hosts: destination is constrained by ADDRESS, checked at connect time against the address actually being dialed so DNS rebinding cannot slip past a resolve-then-connect gap."
        },
        "max_bytes": {
          "type": "integer",
          "minimum": 0,
          "description": "What this device can actually accept. Believed when smaller than the engine would allow -- sending more than a device's flash can hold spends the bay's shared budget on bytes that end in the bin."
        },
        "chunk_bytes": {
          "type": "integer",
          "minimum": 256,
          "description": "A smaller chunk than the default, for a device with little RAM to decode into."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "description": "A device asking the engine to fetch a file on its behalf. Hardware on an isolated bay network usually has no route to the internet; the engine has one, so it relays the bytes rather than the vendor shipping a second updater."
    },
    "updateBegin": {
      "type": "object",
      "required": [
        "type",
        "bytes",
        "chunk_bytes"
      ],
      "additionalProperties": false,
      "description": "Sent only once the first bytes are in hand, so a device does not erase flash for a transfer that is refused a moment later.",
      "properties": {
        "type": {
          "const": "update.begin",
          "description": "Message type discriminator. Always update.begin."
        },
        "request_id": {
          "type": "string",
          "maxLength": 64,
          "description": "Echoes the request_id of the update.fetch that opened this transfer."
        },
        "bytes": {
          "type": "integer",
          "description": "The vendor's declared length, or -1 where it did not say."
        },
        "chunk_bytes": {
          "type": "integer",
          "minimum": 256,
          "description": "Size of each update.data payload before base64 encoding, so a device can size its receive buffer before the first chunk arrives."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "updateData": {
      "type": "object",
      "required": [
        "type",
        "seq",
        "data"
      ],
      "additionalProperties": false,
      "description": "One chunk. Base64 in ordinary JSON at a 33% cost on the wire, deliberately: every device already has a JSON parser for the rest of the protocol and streaming base64 into flash is a few lines on an MCU, whereas a binary side-channel would need every vendor to implement a second framing mode correctly.",
      "properties": {
        "type": {
          "const": "update.data",
          "description": "Message type discriminator. Always update.data."
        },
        "request_id": {
          "type": "string",
          "maxLength": 64,
          "description": "Echoes the request_id of the transfer this chunk belongs to."
        },
        "seq": {
          "type": "integer",
          "minimum": 0,
          "description": "Sequential from zero. A device writing flash in order needs these in order."
        },
        "data": {
          "type": "string",
          "contentEncoding": "base64",
          "description": "One chunk of the file, base64 encoded. Chunks arrive in order and are concatenated after decoding. The encoding exists because this transport frames JSON, not arbitrary bytes."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "updateEnd": {
      "type": "object",
      "required": [
        "type",
        "bytes",
        "complete"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "const": "update.end",
          "description": "Message type discriminator. Always update.end."
        },
        "request_id": {
          "type": "string",
          "maxLength": 64,
          "description": "Echoes the request_id of the transfer being closed."
        },
        "bytes": {
          "type": "integer",
          "minimum": 0,
          "description": "Total decoded bytes relayed. A device MUST compare this against what it actually assembled before installing anything."
        },
        "chunks": {
          "type": "integer",
          "minimum": 0,
          "description": "Number of update.data messages sent, so a device can detect a chunk it never received rather than installing a file with a hole in it."
        },
        "complete": {
          "type": "boolean",
          "description": "False if the transfer was cut short. A device MUST discard a partial file rather than attempt to install it."
        },
        "sha256": {
          "type": "string",
          "description": "Covers what the engine RELAYED. A check on the TRANSFER, not a warrant on the IMAGE. The engine does not verify firmware and cannot -- the format and the signing are the vendor's own. The vendor signs; the DEVICE verifies before flashing. A device treating this as a substitute for checking the vendor's signature would be trusting SGA for something SGA never checked."
        },
        "error": {
          "type": "string",
          "maxLength": 200,
          "description": "A vendor's failure passed through rather than swallowed. A 404 means the vendor moved the file and this device's updater has a stale URL, which is the device's problem to solve and it can only solve it if it is told."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "description": "The transfer finished. Sent whether or not it succeeded -- check complete before trusting anything that was received."
    },
    "updateRefused": {
      "type": "object",
      "required": [
        "type",
        "reason"
      ],
      "additionalProperties": false,
      "description": "Coded so a device can act. A device that cannot tell why it failed retries forever.",
      "properties": {
        "type": {
          "const": "update.refused",
          "description": "Message type discriminator. Always update.refused."
        },
        "request_id": {
          "type": "string",
          "maxLength": 64,
          "description": "Echoes the request_id of the refused transfer."
        },
        "reason": {
          "enum": [
            "scheme_not_https",
            "private_address_refused",
            "file_too_large",
            "device_budget_exhausted",
            "bay_budget_exhausted",
            "too_soon"
          ],
          "description": "Why the fetch was refused, coded so a device can remediate without parsing prose. scheme_not_https: the URL was not HTTPS. private_address_refused: the host resolved to a private or reserved address; this is checked at dial time rather than at parse time, which is what defeats DNS rebinding. file_too_large: over the per-file ceiling. device_budget_exhausted and bay_budget_exhausted: the rolling 24-hour byte budget for this device, or for the bay as a whole, is spent. too_soon: the device asked again before its rate limit elapsed."
        },
        "detail": {
          "type": "string",
          "maxLength": 200,
          "description": "Human-readable elaboration for a log or a support call. Never parse it -- reason is the machine-readable half, and detail is free to change wording at any time."
        },
        "retry_after_ms": {
          "type": "integer",
          "minimum": 0,
          "description": "Computed from when the oldest spend actually ages out of the rolling window, rather than guessed."
        },
        "remaining_bytes": {
          "type": "integer",
          "minimum": 0,
          "description": "What is left of the tighter of the two budgets, so a device can decide whether a smaller delta is worth asking for."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    }
  }
}
