Define API and live update contract
This commit is contained in:
parent
0b2b026283
commit
8c0ec9627e
4 changed files with 954 additions and 0 deletions
71
docs/spec/api/README.md
Normal file
71
docs/spec/api/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Remora API contract semantics
|
||||
|
||||
OpenAPI `3.2.0` describes HTTP operations and JSON Schema Draft 2020-12
|
||||
describes WebSocket frames. The API contract version is `1.0.0`; additive
|
||||
fields require clients to tolerate unknown response fields only after a
|
||||
contract version that permits them, while incompatible changes use a new major
|
||||
path or media type.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Every public mutation requires `Idempotency-Key`. The identity is caller
|
||||
subject, operation, and key. The server canonicalizes the semantic request,
|
||||
including the authorization target, with RFC 8785 and stores its SHA-256
|
||||
fingerprint and complete response atomically with the mutation. The default
|
||||
retention is 24 hours and is never shorter than the advertised
|
||||
`Idempotency-Expires-At`.
|
||||
|
||||
The same identity and fingerprint replays the original status, resource, and
|
||||
body after duplicate or ambiguous delivery. A different fingerprint returns
|
||||
`idempotency_conflict` and cannot affect either request. Naturally idempotent
|
||||
deletion and cancellation semantics still use this request identity.
|
||||
|
||||
Internal claim uses job ID plus `operation_id` because no attempt exists yet.
|
||||
Renew, progress, and outcome use attempt ID, fencing token, worker incarnation,
|
||||
and `operation_id`. A replay has the original effect; a different, stale, or
|
||||
non-authoritative tuple returns `precondition_failed` without mutation.
|
||||
|
||||
## Authorization and errors
|
||||
|
||||
Public operations apply the authorization rules in the architecture. List is
|
||||
filtered before pagination. A missing and an unauthorized resource both return
|
||||
`not_found`; callers cannot probe job existence. Cursors and event replay are
|
||||
bound to the authenticated subject and filter.
|
||||
|
||||
`ApiError` fields are an allowlist. Messages and field diagnostics never carry
|
||||
credential values, bearer material, URL userinfo, raw upstream response bodies,
|
||||
internal stack traces, or unredacted configured endpoints.
|
||||
|
||||
## Jobs, pages, and results
|
||||
|
||||
`Job.phase` uses the exact `JobProtocol` phase vocabulary; this contract does
|
||||
not define phase transitions. `lifecycle`, current authoritative attempt,
|
||||
current matching progress, reusable-checkpoint projection, result, and failure
|
||||
are separate fields. Progress is present only when its attempt ID and fence
|
||||
match the authoritative attempt in the same projection.
|
||||
|
||||
Job pages order by `(created_at DESC, id DESC)`. The first request fixes a
|
||||
membership snapshot, authorization/filter fingerprint, and high-water mark.
|
||||
`next_cursor` is an opaque integrity-protected keyset cursor for that snapshot;
|
||||
new jobs do not enter later pages. Invalid, altered, cross-subject, or
|
||||
cross-filter cursors return the corresponding structured cursor error.
|
||||
|
||||
A result endpoint returns only a visible `CommittedResult`. Tombstoned,
|
||||
unauthorized, absent, and never-committed results are not exposed. The hard
|
||||
delete endpoint returns the durable deletion resource established by the
|
||||
architecture.
|
||||
|
||||
## WebSocket replay
|
||||
|
||||
An HTTP job or list snapshot carries `event_cursor` at its consistency
|
||||
watermark. The client connects to `/v1/events` with that cursor. Server frames
|
||||
are ordered by increasing `sequence` for a connection and carry an opaque next
|
||||
cursor. Delivery is at least once; clients deduplicate event IDs and ignore a
|
||||
job revision no newer than the applied revision.
|
||||
|
||||
Replay applies current authorization before delivery. If a cursor is absent,
|
||||
invalid, expired, authorization-incompatible, or no longer available after
|
||||
coordination loss, the server sends `resync_required`; the client obtains a new
|
||||
HTTP snapshot and reconnects from its cursor. Progress frames for a stale
|
||||
attempt/fence are suppressed before emission and ignored defensively by the
|
||||
client.
|
||||
149
docs/spec/api/events.schema.json
Normal file
149
docs/spec/api/events.schema.json
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://remora.invalid/spec/api/events.schema.json",
|
||||
"title": "Remora WebSocket frame",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/Subscribe"},
|
||||
{"$ref": "#/$defs/JobChanged"},
|
||||
{"$ref": "#/$defs/DeletionChanged"},
|
||||
{"$ref": "#/$defs/ResyncRequired"},
|
||||
{"$ref": "#/$defs/Heartbeat"}
|
||||
],
|
||||
"$defs": {
|
||||
"OpaqueId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$"
|
||||
},
|
||||
"Cursor": {
|
||||
"type": "string",
|
||||
"minLength": 16,
|
||||
"maxLength": 2048
|
||||
},
|
||||
"Subscribe": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "kind"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"kind": {"const": "subscribe"},
|
||||
"cursor": {"oneOf": [{"$ref": "#/$defs/Cursor"}, {"type": "null"}]}
|
||||
}
|
||||
},
|
||||
"Attempt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["attempt_id", "fence"],
|
||||
"properties": {
|
||||
"attempt_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"fence": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"Progress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["attempt_id", "fence", "completed_bytes", "completed_members"],
|
||||
"properties": {
|
||||
"attempt_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"fence": {"type": "integer", "minimum": 1},
|
||||
"completed_bytes": {"type": "integer", "minimum": 0},
|
||||
"total_bytes": {"type": ["integer", "null"], "minimum": 0},
|
||||
"completed_members": {"type": "integer", "minimum": 0},
|
||||
"total_members": {"type": ["integer", "null"], "minimum": 0}
|
||||
}
|
||||
},
|
||||
"ServerBase": {
|
||||
"type": "object",
|
||||
"required": ["schema_version", "kind", "event_id", "sequence", "cursor", "occurred_at"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"kind": {"type": "string"},
|
||||
"event_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"sequence": {"type": "integer", "minimum": 1},
|
||||
"cursor": {"$ref": "#/$defs/Cursor"},
|
||||
"occurred_at": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
},
|
||||
"JobChanged": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/ServerBase"},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "kind", "event_id", "sequence", "cursor", "occurred_at", "job_id", "job_revision", "phase", "lifecycle", "authoritative_attempt", "progress"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"kind": {"const": "job_changed"},
|
||||
"event_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"sequence": {"type": "integer", "minimum": 1},
|
||||
"cursor": {"$ref": "#/$defs/Cursor"},
|
||||
"occurred_at": {"type": "string", "format": "date-time"},
|
||||
"job_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"job_revision": {"type": "integer", "minimum": 1},
|
||||
"phase": {"enum": ["Queued", "Running", "RetryWait", "Cancelling", "Succeeded", "Failed", "Cancelled"]},
|
||||
"lifecycle": {"enum": ["visible", "soft_deleted", "hard_deleting", "hard_deleted"]},
|
||||
"authoritative_attempt": {"oneOf": [{"$ref": "#/$defs/Attempt"}, {"type": "null"}]},
|
||||
"progress": {"oneOf": [{"$ref": "#/$defs/Progress"}, {"type": "null"}]}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"DeletionChanged": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/ServerBase"},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "kind", "event_id", "sequence", "cursor", "occurred_at", "job_id", "operation_id", "state"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"kind": {"const": "deletion_changed"},
|
||||
"event_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"sequence": {"type": "integer", "minimum": 1},
|
||||
"cursor": {"$ref": "#/$defs/Cursor"},
|
||||
"occurred_at": {"type": "string", "format": "date-time"},
|
||||
"job_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"operation_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"state": {"enum": ["queued", "running", "retry_wait", "completed"]}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ResyncRequired": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/ServerBase"},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "kind", "event_id", "sequence", "cursor", "occurred_at", "reason"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"kind": {"const": "resync_required"},
|
||||
"event_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"sequence": {"type": "integer", "minimum": 1},
|
||||
"cursor": {"$ref": "#/$defs/Cursor"},
|
||||
"occurred_at": {"type": "string", "format": "date-time"},
|
||||
"reason": {"enum": ["cursor_absent", "cursor_invalid", "cursor_expired", "authorization_changed", "history_unavailable"]}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Heartbeat": {
|
||||
"allOf": [
|
||||
{"$ref": "#/$defs/ServerBase"},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "kind", "event_id", "sequence", "cursor", "occurred_at"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"kind": {"const": "heartbeat"},
|
||||
"event_id": {"$ref": "#/$defs/OpaqueId"},
|
||||
"sequence": {"type": "integer", "minimum": 1},
|
||||
"cursor": {"$ref": "#/$defs/Cursor"},
|
||||
"occurred_at": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
725
docs/spec/api/openapi.yaml
Normal file
725
docs/spec/api/openapi.yaml
Normal file
|
|
@ -0,0 +1,725 @@
|
|||
openapi: 3.2.0
|
||||
$self: https://remora.invalid/spec/api/openapi.yaml
|
||||
jsonSchemaDialect: https://spec.openapis.org/oas/3.2/dialect/2025-09-17
|
||||
info:
|
||||
title: Remora API
|
||||
version: 1.0.0
|
||||
description: Public job management and service-authenticated worker operations.
|
||||
servers:
|
||||
- url: /api
|
||||
security:
|
||||
- oidc: []
|
||||
paths:
|
||||
/v1/jobs:
|
||||
post:
|
||||
operationId: submitJob
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/JobSubmission'
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted, or the original accepted response for the same key and fingerprint.
|
||||
headers:
|
||||
Idempotency-Expires-At:
|
||||
$ref: '#/components/headers/IdempotencyExpiresAt'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
'409':
|
||||
$ref: '#/components/responses/IdempotencyConflict'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
get:
|
||||
operationId: listJobs
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Cursor'
|
||||
- $ref: '#/components/parameters/Limit'
|
||||
- name: include_deleted
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
'200':
|
||||
description: Authorized keyset page bound to one snapshot and filter.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/JobPage'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/jobs/{job_id}:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/JobId'
|
||||
get:
|
||||
operationId: getJob
|
||||
responses:
|
||||
'200':
|
||||
description: Current authorized job projection.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/jobs/{job_id}/result:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/JobId'
|
||||
get:
|
||||
operationId: getJobResult
|
||||
responses:
|
||||
'200':
|
||||
description: Visible committed manifest-rooted result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResultDescriptor'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/PreconditionFailed'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/jobs/{job_id}/cancel:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/JobId'
|
||||
post:
|
||||
operationId: cancelJob
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
responses:
|
||||
'202':
|
||||
description: Cancellation intent accepted or prior response replayed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/jobs/{job_id}/soft-delete:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/JobId'
|
||||
post:
|
||||
operationId: softDeleteJob
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Job hidden, or prior response replayed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/jobs/{job_id}/restore:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/JobId'
|
||||
post:
|
||||
operationId: restoreJob
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Soft-deleted job restored, or prior response replayed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/jobs/{job_id}/hard-delete:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/JobId'
|
||||
post:
|
||||
operationId: hardDeleteJob
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
responses:
|
||||
'202':
|
||||
description: Durable deletion operation created or replayed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DeletionOperation'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/deletions/{operation_id}:
|
||||
parameters:
|
||||
- name: operation_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
get:
|
||||
operationId: getDeletionOperation
|
||||
responses:
|
||||
'200':
|
||||
description: Current deletion operation.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DeletionOperation'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/v1/events:
|
||||
get:
|
||||
operationId: connectEvents
|
||||
parameters:
|
||||
- name: cursor
|
||||
in: query
|
||||
description: Opaque authorization-bound replay cursor from an HTTP snapshot or prior frame.
|
||||
schema:
|
||||
type: string
|
||||
minLength: 16
|
||||
maxLength: 2048
|
||||
responses:
|
||||
'101':
|
||||
description: WebSocket frames conform to events.schema.json. An absent, invalid, expired, or unauthorized cursor yields resync_required.
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/internal/v1/jobs/{job_id}/claim:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/JobId'
|
||||
post:
|
||||
operationId: claimJob
|
||||
security:
|
||||
- serviceAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClaimRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Allocated authority or replay of the same claim operation.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttemptAuthority'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/internal/v1/attempts/{attempt_id}/renew:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AttemptId'
|
||||
post:
|
||||
operationId: renewAttempt
|
||||
security:
|
||||
- serviceAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttemptMutation'
|
||||
responses:
|
||||
'204':
|
||||
description: Renewed or duplicate operation accepted.
|
||||
'409':
|
||||
$ref: '#/components/responses/PreconditionFailed'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/internal/v1/attempts/{attempt_id}/progress:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AttemptId'
|
||||
post:
|
||||
operationId: reportAttemptProgress
|
||||
security:
|
||||
- serviceAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttemptProgressRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Current progress accepted or duplicate ignored.
|
||||
'409':
|
||||
$ref: '#/components/responses/PreconditionFailed'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
/internal/v1/attempts/{attempt_id}/outcome:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AttemptId'
|
||||
post:
|
||||
operationId: reportAttemptOutcome
|
||||
security:
|
||||
- serviceAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AttemptOutcomeRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Durable outcome projection or replay of the same operation.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Job'
|
||||
'409':
|
||||
$ref: '#/components/responses/PreconditionFailed'
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
components:
|
||||
securitySchemes:
|
||||
oidc:
|
||||
type: openIdConnect
|
||||
openIdConnectUrl: https://identity.invalid/.well-known/openid-configuration
|
||||
serviceAuth:
|
||||
type: mutualTLS
|
||||
parameters:
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
description: Subject- and operation-scoped key retained for at least 24 hours.
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9._~-]{16,128}$'
|
||||
Cursor:
|
||||
name: cursor
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
minLength: 16
|
||||
maxLength: 2048
|
||||
Limit:
|
||||
name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 50
|
||||
JobId:
|
||||
name: job_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
AttemptId:
|
||||
name: attempt_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
headers:
|
||||
IdempotencyExpiresAt:
|
||||
description: Earliest time at which the server may forget this key.
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
responses:
|
||||
Error:
|
||||
description: Structured error with user-safe detail only.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
NotFound:
|
||||
description: Missing or unauthorized resource; the response does not distinguish them.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
IdempotencyConflict:
|
||||
description: The key exists with a different canonical request fingerprint.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
PreconditionFailed:
|
||||
description: Durable authority, fence, phase, or lifecycle precondition failed.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
schemas:
|
||||
OpaqueId:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$'
|
||||
Target:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [store, bucket, prefix]
|
||||
properties:
|
||||
store:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
bucket:
|
||||
type: string
|
||||
pattern: '^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$'
|
||||
prefix:
|
||||
type: string
|
||||
maxLength: 1024
|
||||
HttpSource:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [kind, url]
|
||||
properties:
|
||||
kind:
|
||||
const: http
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 8192
|
||||
credential_ref:
|
||||
type: string
|
||||
maxLength: 512
|
||||
ObjectStoreSource:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [kind, store, bucket, key]
|
||||
properties:
|
||||
kind:
|
||||
const: object_store
|
||||
store:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
bucket:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 63
|
||||
key:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 4096
|
||||
version_id:
|
||||
type: string
|
||||
maxLength: 1024
|
||||
credential_ref:
|
||||
type: string
|
||||
maxLength: 512
|
||||
HuggingFaceSource:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [kind, repository_type, repository_id, revision]
|
||||
properties:
|
||||
kind:
|
||||
const: hugging_face
|
||||
repository_type:
|
||||
type: string
|
||||
enum: [model, dataset, space]
|
||||
repository_id:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'
|
||||
revision:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 256
|
||||
include:
|
||||
type: array
|
||||
maxItems: 1024
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
credential_ref:
|
||||
type: string
|
||||
maxLength: 512
|
||||
Source:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/HttpSource'
|
||||
- $ref: '#/components/schemas/ObjectStoreSource'
|
||||
- $ref: '#/components/schemas/HuggingFaceSource'
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
http: '#/components/schemas/HttpSource'
|
||||
object_store: '#/components/schemas/ObjectStoreSource'
|
||||
hugging_face: '#/components/schemas/HuggingFaceSource'
|
||||
JobSubmission:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [source, target]
|
||||
properties:
|
||||
source:
|
||||
$ref: '#/components/schemas/Source'
|
||||
target:
|
||||
$ref: '#/components/schemas/Target'
|
||||
label:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
JobPhase:
|
||||
type: string
|
||||
enum: [Queued, Running, RetryWait, Cancelling, Succeeded, Failed, Cancelled]
|
||||
JobLifecycle:
|
||||
type: string
|
||||
enum: [visible, soft_deleted, hard_deleting, hard_deleted]
|
||||
AttemptAuthority:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [attempt_id, fence, worker_incarnation]
|
||||
properties:
|
||||
attempt_id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
fence:
|
||||
type: integer
|
||||
minimum: 1
|
||||
worker_incarnation:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
Progress:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [attempt_id, fence, completed_bytes, completed_members, observed_at]
|
||||
properties:
|
||||
attempt_id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
fence:
|
||||
type: integer
|
||||
minimum: 1
|
||||
completed_bytes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total_bytes:
|
||||
type: [integer, 'null']
|
||||
minimum: 0
|
||||
completed_members:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total_members:
|
||||
type: [integer, 'null']
|
||||
minimum: 0
|
||||
observed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
CheckpointProjection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [reusable, validated_bytes, validated_members]
|
||||
properties:
|
||||
reusable:
|
||||
type: boolean
|
||||
validated_bytes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
validated_members:
|
||||
type: integer
|
||||
minimum: 0
|
||||
JobFailure:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [class, retryable, message]
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
enum: [validation, authorization, source_not_found, source_changed, transient_source, transient_target, integrity, capacity, internal, cancelled, lost_attempt]
|
||||
retryable:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
maxLength: 500
|
||||
safe_details:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: [string, integer, boolean, 'null']
|
||||
ResultDescriptor:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [kind, manifest, member_count, total_bytes]
|
||||
properties:
|
||||
kind:
|
||||
const: manifest
|
||||
manifest:
|
||||
$ref: '#/components/schemas/ObjectIdentity'
|
||||
member_count:
|
||||
type: integer
|
||||
minimum: 1
|
||||
total_bytes:
|
||||
type: integer
|
||||
minimum: 0
|
||||
ObjectIdentity:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [store, bucket, key]
|
||||
properties:
|
||||
store:
|
||||
type: string
|
||||
bucket:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
version_id:
|
||||
type: string
|
||||
checksum:
|
||||
type: string
|
||||
Job:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, owner, revision, phase, lifecycle, source, target, authoritative_attempt, progress, checkpoint, result, failure, created_at, updated_at, event_cursor]
|
||||
properties:
|
||||
id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
owner:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
revision:
|
||||
type: integer
|
||||
minimum: 1
|
||||
phase:
|
||||
$ref: '#/components/schemas/JobPhase'
|
||||
lifecycle:
|
||||
$ref: '#/components/schemas/JobLifecycle'
|
||||
source:
|
||||
$ref: '#/components/schemas/Source'
|
||||
target:
|
||||
$ref: '#/components/schemas/Target'
|
||||
authoritative_attempt:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AttemptAuthority'
|
||||
- type: 'null'
|
||||
progress:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Progress'
|
||||
- type: 'null'
|
||||
checkpoint:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CheckpointProjection'
|
||||
- type: 'null'
|
||||
result:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/ResultDescriptor'
|
||||
- type: 'null'
|
||||
failure:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/JobFailure'
|
||||
- type: 'null'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
event_cursor:
|
||||
type: string
|
||||
minLength: 16
|
||||
JobPage:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items, next_cursor, event_cursor]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Job'
|
||||
next_cursor:
|
||||
type: [string, 'null']
|
||||
event_cursor:
|
||||
type: string
|
||||
minLength: 16
|
||||
DeletionOperation:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, job_id, state, created_at, updated_at]
|
||||
properties:
|
||||
id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
job_id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
state:
|
||||
type: string
|
||||
enum: [queued, running, retry_wait, completed]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
ApiError:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [code, message, request_id]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [invalid_request, unauthenticated, forbidden, not_found, idempotency_conflict, precondition_failed, cursor_invalid, cursor_expired, rate_limited, unavailable, internal]
|
||||
message:
|
||||
type: string
|
||||
maxLength: 500
|
||||
request_id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
fields:
|
||||
type: array
|
||||
maxItems: 100
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [pointer, code]
|
||||
properties:
|
||||
pointer:
|
||||
type: string
|
||||
maxLength: 500
|
||||
code:
|
||||
type: string
|
||||
maxLength: 100
|
||||
ClaimRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [operation_id, worker_incarnation]
|
||||
properties:
|
||||
operation_id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
worker_incarnation:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
AttemptMutation:
|
||||
type: object
|
||||
required: [operation_id, fence, worker_incarnation]
|
||||
properties:
|
||||
operation_id:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
fence:
|
||||
type: integer
|
||||
minimum: 1
|
||||
worker_incarnation:
|
||||
$ref: '#/components/schemas/OpaqueId'
|
||||
AttemptProgressRequest:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AttemptMutation'
|
||||
- type: object
|
||||
required: [progress]
|
||||
properties:
|
||||
progress:
|
||||
$ref: '#/components/schemas/Progress'
|
||||
AttemptOutcomeRequest:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AttemptMutation'
|
||||
- type: object
|
||||
required: [outcome]
|
||||
properties:
|
||||
outcome:
|
||||
oneOf:
|
||||
- type: object
|
||||
additionalProperties: false
|
||||
required: [kind, result]
|
||||
properties:
|
||||
kind:
|
||||
const: success
|
||||
result:
|
||||
$ref: '#/components/schemas/ResultDescriptor'
|
||||
- type: object
|
||||
additionalProperties: false
|
||||
required: [kind, failure]
|
||||
properties:
|
||||
kind:
|
||||
const: failure
|
||||
failure:
|
||||
$ref: '#/components/schemas/JobFailure'
|
||||
|
|
@ -150,6 +150,15 @@ flowchart TD
|
|||
- Redirects are bounded to five by default. Every hop repeats URL, scheme/port, DNS, and address validation before dialing. HTTPS-to-HTTP downgrade is denied, and credentials and cookies are removed when the origin changes.
|
||||
- Ambient proxy environment variables are ignored. An outbound proxy is disabled unless configured as a trusted enforcement boundary that provides the same hostname, resolution, address, redirect, and credential controls. Infrastructure egress policy remains defense in depth.
|
||||
|
||||
## API and Live Updates
|
||||
|
||||
- [`api/openapi.yaml`](api/openapi.yaml) is the normative HTTP contract and [`api/events.schema.json`](api/events.schema.json) is the normative WebSocket frame contract. Their compatibility and replay semantics are defined in [`api/README.md`](api/README.md).
|
||||
- Every public mutation uses a caller- and operation-scoped idempotency key. The server atomically retains the canonical request fingerprint and original response for at least the advertised 24-hour default window; changed payload reuse is a conflict.
|
||||
- Public job status projects the `JobProtocol` phase without redefining its transitions and separately exposes deletion lifecycle, current authoritative attempt, matching progress, validated checkpoint, committed result, and structured failure.
|
||||
- Job listing applies current authorization before stable keyset pagination. Cursors are opaque, integrity-protected, and bound to the subject, filters, and membership snapshot.
|
||||
- HTTP snapshots carry a live-event cursor. WebSocket replay is ordered and at least once; invalid, expired, authorization-incompatible, or unavailable history requires a new HTTP snapshot. Stale-attempt progress is suppressed before emission.
|
||||
- Internal claim identifies the job, worker incarnation, and idempotent operation because no attempt exists yet. Renew, progress, and outcome additionally require the opaque attempt ID and fence defined by `JobProtocol`; stale authority has no effect.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
|
|
|||
Loading…
Reference in a new issue