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.
|
- 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.
|
- 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
|
## Requirements
|
||||||
|
|
||||||
### Functional Requirements
|
### Functional Requirements
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue