solti-api 0.0.4

HTTP/JSON and gRPC API layer for the Solti task supervisor SDK.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
# Task API Contract

The current Task API identity is `v1`.

This document describes the public Task API v1 wire contract.
It also describes the behavior implemented by `SupervisorApiAdapter`.

Task API v1 has two transport bindings:

| Transport | Protocol identity                              |
|-----------|------------------------------------------------|
| HTTP      | API root `/apis/solti.io/v1`                   |
| gRPC      | Package `solti.task.v1`, service `TaskService` |

The transports expose the same operations.
They do not use the same wire representation.

| Contract                   | Source                                             |
|----------------------------|----------------------------------------------------|
| HTTP shapes and operations | OpenAPI produced from the mounted `HttpApi` routes |
| gRPC shapes and operations | `proto/solti/task/v1/api.proto` and `types.proto`  |
| Behavior                   | This document                                      |

HTTP uses the CRD JSON representation owned by `solti-model`.
gRPC uses versioned protobuf messages.
`HttpApi::build` generates a standalone OpenAPI document.
`HttpApi::mount` adds the same routes to an application-owned document.
The application finalizes the combined router once.

## Version selection

One `solti-api` build exposes one Task API major version.
The crate exports its identity through `API_VERSION`, `HTTP_API_ROOT`, `GRPC_API_PACKAGE`, and `GRPC_API_SERVICE`.

The agent passes `API_VERSION` to `AgentEndpoint`.
Discovery advertises that number through `SyncRequest.api_version`.

The control plane selects a Task API adapter by `endpoint_type` and `api_version`.
Each supported major has its own HTTP routes, gRPC package, and wire models.
One agent server does not host multiple Task API majors.

## Boundary

`solti-api` owns public transport behavior.
It does not store or execute tasks.

The HTTP task subtree owns its handler state, authentication, limits, metrics, and fallbacks.
Application routes keep their own state and perimeter.

```text
HTTP CRD JSON ── parse and validate ──┐
                                 ApiHandler
                                      ├── custom backend
                                      └── SupervisorApiAdapter ──► solti-core
gRPC v1 DTO ── convert and validate ──┘
```

Every operation delegates to one `ApiHandler` method.

| Operation | HTTP                                      | gRPC             |
|-----------|-------------------------------------------|------------------|
| Create    | `POST /apis/solti.io/v1/tasks`            | `CreateTask`     |
| Apply     | `PUT /apis/solti.io/v1/tasks/{name}`      | `ApplyTask`      |
| Get       | `GET /apis/solti.io/v1/tasks/{name}`      | `GetTask`        |
| List      | `GET /apis/solti.io/v1/tasks`             | `ListTasks`      |
| Watch     | `GET /apis/solti.io/v1/tasks?watch=true`  | `WatchTasks`     |
| Runs      | `GET /apis/solti.io/v1/tasks/{name}/runs` | `ListTaskRuns`   |
| Delete    | `DELETE /apis/solti.io/v1/tasks/{name}`   | `DeleteTask`     |
| Logs      | `GET /apis/solti.io/v1/tasks/{name}/logs` | `StreamTaskLogs` |

## Public workloads

The public API accepts these built-in workloads:

- `solti.io/v1`, kind `Subprocess`;
- `solti.io/v1`, kind `Wasm`;
- `solti.io/v1`, kind `Container`.

It also accepts application-provided workload GVKs.
Their API group must not be `solti.io`.
Their `spec` must be a JSON object.

Built-in workload specs reject unknown fields.
Extension workload fields are owned by the application.

The built-in `solti.io/v1` `Embedded` workload is SDK-only.
HTTP and gRPC reject it as input.
`SupervisorApiAdapter` also hides it from reads, watches, history, deletion, and output.

## Resource shapes

Create and apply accept a `TaskManifest`.
It contains caller-owned desired state.

```json
{
  "apiVersion": "solti.io/v1",
  "kind": "Task",
  "metadata": {
    "name": "daily-report",
    "labels": {
      "app": "reports"
    }
  },
  "spec": {
    "slot": "reports",
    "workload": {
      "apiVersion": "solti.io/v1",
      "kind": "Subprocess",
      "spec": {
        "mode": {
          "command": {
            "command": "report-generator",
            "args": ["--daily"]
          }
        },
        "failOnNonZero": true
      }
    },
    "timeout": 30000,
    "restart": {
      "type": "onFailure"
    },
    "backoff": {
      "jitter": "full",
      "firstMs": 1000,
      "maxMs": 30000,
      "factor": 2.0
    },
    "admission": "dropIfRunning",
    "maxRetries": 3
  }
}
```

A stored `Task` adds server-owned metadata and observed status.

| Field                        | Owner  | Meaning                                     |
|------------------------------|--------|---------------------------------------------|
| `metadata.name`              | Caller | Stable resource address                     |
| `metadata.labels`            | Caller | Selector metadata                           |
| `metadata.annotations`       | Caller | Free-form metadata                          |
| `metadata.uid`               | Server | Resource-incarnation identity               |
| `metadata.resourceVersion`   | Server | Opaque store version                        |
| `metadata.generation`        | Server | Desired-spec generation                     |
| `metadata.creationTimestamp` | Server | RFC 3339 creation time                      |
| `spec`                       | Caller | Desired execution state                     |
| `status`                     | Server | Observed reconciliation and execution state |

`metadata.name` uses the Kubernetes DNS-1123 subdomain format.
It is immutable because it is the resource address.

`metadata.uid` changes after deletion and recreation.
Clients must treat `metadata.resourceVersion` as opaque.

## Create

HTTP:

```text
POST /apis/solti.io/v1/tasks
Content-Type: application/json

TaskManifest
```

A successful HTTP create returns `201 Created` and the committed `Task`.

gRPC:

```text
/solti.task.v1.TaskService/CreateTask

CreateTaskRequest { manifest }
    ──► CreateTaskResponse { task }
```

Create requires a new retained name.
An existing name returns HTTP `409` or gRPC `AlreadyExists`.

With `SupervisorApiAdapter`, success means desired state is committed.
It does not mean that a runtime has started.

## Apply

HTTP:

```text
PUT /apis/solti.io/v1/tasks/{name}
Content-Type: application/json

TaskManifest
```

`{name}` must equal `metadata.name`.
A successful HTTP apply returns `200 OK` and the committed `Task`.

gRPC:

```text
/solti.task.v1.TaskService/ApplyTask

ApplyTaskRequest { manifest, preconditions }
    ──► ApplyTaskResponse { task }
```

Apply without preconditions is an upsert.
It creates a missing resource.
It updates an existing resource.

Apply and delete accept two optional preconditions:

| Field             | Check                                     |
|-------------------|-------------------------------------------|
| `uid`             | Current resource incarnation must match   |
| `resourceVersion` | Current opaque store version must match   |

HTTP carries them as query parameters.
gRPC carries them in `WritePreconditions`.

Any precondition requires an existing public resource.
A missing resource returns `404` or gRPC `NotFound`.
A mismatch returns `409` or gRPC `Aborted`.

HTTP conflicts contain `Status.details.causes`.
gRPC conflicts contain encoded `WriteConflictDetails` status details.

## Desired-state commit

`SupervisorApiAdapter` uses this flow:

```text
create or apply
      ├── validate manifest and preconditions
      ├── commit desired resource
      ├── return committed Task
      └── reconcile generation asynchronously
                    ├── accepted ──► Reconciled=True
                    └── rejected ──► Reconciled=False
```

A spec change increments `metadata.generation`.
It resets execution status to pending.
The `Reconciled` condition becomes `Unknown` for that generation.

A metadata-only change preserves the generation.
It does not rebuild the runtime.

An identical apply is normally a no-op.
When `Reconciled=False`, one identical apply schedules a manual retry.
The condition becomes `Unknown` without incrementing the generation.

Reconciliation is latest-wins by generation.
A stale reconciliation cannot bind or replace the current runtime.
The API does not provide a staged-rollout or availability guarantee.

## Status

`Task.status` contains execution state and controller conditions.

| Field                | Meaning                                       |
|----------------------|-----------------------------------------------|
| `observedGeneration` | Latest generation processed by the controller |
| `phase`              | Current execution phase                       |
| `attempt`            | Attempt within `observedGeneration`           |
| `exitCode`           | Process exit code, when available             |
| `error`              | Execution diagnostic, when available          |
| `conditions`         | Extensible controller conditions              |

The current condition set always contains one `Reconciled` condition.

| Status    | Meaning                                         |
|-----------|-------------------------------------------------|
| `Unknown` | Reconciliation is scheduled or still unresolved |
| `True`    | Runtime accepted the referenced generation      |
| `False`   | Runtime rejected the referenced generation      |

The condition contains its own `observedGeneration`.
It also contains `reason`, `message`, and `lastTransitionTime`.

Execution phases are:

```text
pending
running
succeeded
failed
timeout
canceled
exhausted
```

## Get

HTTP:

```text
GET /apis/solti.io/v1/tasks/{name}
```

The response is one `Task`.
An unknown or hidden name returns `404`.

gRPC `GetTask` returns `GetTaskResponse.task`.
An unknown or hidden name returns `NotFound`.

## List

HTTP:

```text
GET /apis/solti.io/v1/tasks
```

The response is a Kubernetes-shaped `TaskList`.

```json
{
  "apiVersion": "solti.io/v1",
  "kind": "TaskList",
  "metadata": {
    "resourceVersion": "opaque-version",
    "continue": "opaque-token",
    "remainingItemCount": 12
  },
  "items": []
}
```

`continue` and `remainingItemCount` are absent on the final page.

HTTP list parameters are:

| Parameter       | Meaning                                               |
|-----------------|-------------------------------------------------------|
| `slot`          | Match one slot                                        |
| `phase`         | Match any supplied phase; may be repeated             |
| `labelSelector` | Match a Kubernetes-style label selector               |
| `limit`         | Page size; omitted or `0` means `100`; maximum `1000` |
| `continue`      | Resume the snapshot identified by a previous page     |

Filters are combined with AND.
Repeated phases are combined with OR.
Unknown query parameters are rejected.
Singleton parameters cannot be repeated.

gRPC `ListTasksRequest` carries the same filters.
Its `phases` field is repeated.

With `SupervisorApiAdapter`, list results are ordered by task name.
Pagination uses a snapshot captured for the first page.
Every page in one chain has the same collection `resourceVersion`.

The continuation token is opaque.
Clients must return it unchanged.
Filters must remain unchanged for the full continuation chain.

An invalid continuation returns `400` or gRPC `InvalidArgument`.
An unavailable snapshot returns `410 Gone` or gRPC `OutOfRange`.

## Watch

HTTP selects watch mode on the list route:

```text
GET /apis/solti.io/v1/tasks?watch=true&resourceVersion={opaque-version}
Accept: application/json
```

`watch` accepts `true`, `false`, `1`, or `0`.
`limit` and `continue` are rejected in watch mode.
`resourceVersion` is accepted only in watch mode.

The response is a sequence of JSON documents.
Each document ends with a newline.

```json
{"type":"ADDED","object":{"apiVersion":"solti.io/v1","kind":"Task"}}
{"type":"MODIFIED","object":{"apiVersion":"solti.io/v1","kind":"Task"}}
{"type":"DELETED","object":{"apiVersion":"solti.io/v1","kind":"Task"}}
```

Watch event types are:

| Type       | Meaning                                            |
|------------|----------------------------------------------------|
| `ADDED`    | A task entered the selected collection             |
| `MODIFIED` | A selected task changed                            |
| `DELETED`  | A task left the collection or was deleted          |
| `ERROR`    | The stream ended with an API error                 |

An absent `resourceVersion` or `"0"` emits the current snapshot first.
Snapshot tasks are emitted as `ADDED` in task-name order.
The stream then emits live changes.

A specific version replays later retained changes.
It then continues with live changes.

The initial request returns `410 Gone` when its position is no longer retained.
An error after streaming starts becomes one final `ERROR` document.
The HTTP stream closes after that document.

gRPC `WatchTasks` is a server stream.
An initial error is a normal gRPC status.
A later error terminates the stream with that status.

Clients resume from the latest processed `object.metadata.resourceVersion`.

## Run history

HTTP:

```text
GET /apis/solti.io/v1/tasks/{name}/runs
```

The response shape is:

```json
{
  "runs": [
    {
      "workload": {
        "apiVersion": "solti.io/v1",
        "kind": "Subprocess"
      },
      "generation": 1,
      "attempt": 1,
      "phase": "succeeded",
      "startedAt": "2026-07-29T10:00:00Z",
      "finishedAt": "2026-07-29T10:00:01Z",
      "exitCode": 0
    }
  ]
}
```

Runs are ordered by generation and attempt.
An active run has phase `running` and no terminal fields.
A finished run has a terminal phase and `finishedAt`.

gRPC uses `ListTaskRunsRequest` and `ListTaskRunsResponse`.
Its timestamps are Unix milliseconds.

## Delete

HTTP:

```text
DELETE /apis/solti.io/v1/tasks/{name}?uid={uid}&resourceVersion={opaque-version}
```

Preconditions are optional.
A successful delete stops the task and purges its history.
HTTP returns `204 No Content`.

gRPC uses `DeleteTaskRequest`.
It returns an empty `DeleteTaskResponse`.

## Live output

HTTP:

```text
GET /apis/solti.io/v1/tasks/{name}/logs
Accept: text/event-stream
```

The response uses Server-Sent Events.
The current event names are:

- `chunk`;
- `run-started`;
- `run-finished`;
- `lagged`.

Example frames:

```text
event: run-started
data: {"type":"runStarted","generation":2,"attempt":1,"startedAt":1712750400000}

event: chunk
data: {"type":"chunk","generation":2,"attempt":1,"stream":"stdout","seq":0,"ts":1712750400123,"line":"aGVsbG8="}

event: run-finished
data: {"type":"runFinished","generation":2,"attempt":1,"exitCode":0,"finishedAt":1712750400456}

event: lagged
data: {"type":"lagged","skipped":42}
```

`line` contains standard padded base64.
It preserves non-UTF-8 output.

The stream is live-only.
It has no persistence or replay.
A slow subscriber can miss events.
`lagged.skipped` reports how many events were missed.

Run markers are best-effort observations.
They are not ordering barriers for chunks.
Clients group chunks by generation, attempt, and stream.
They order those chunks by `seq`.

With `SupervisorApiAdapter`, a subscription is pinned to the generation visible when it opens.
It can span later attempts of that generation.
Events from another generation are filtered out.

The HTTP transport sends periodic SSE keep-alive comments.

gRPC `StreamTaskLogs` is a server stream.
It carries the same four event variants in a protobuf `oneof`.
Protobuf carries `line` as raw bytes.

## HTTP encoding

HTTP uses JSON field names from `solti-model`.
It does not use protobuf JSON encoding.

| Value                              | HTTP encoding              |
|------------------------------------|----------------------------|
| Resource and run timestamps        | RFC 3339 string            |
| Live-output timestamps             | Unix milliseconds          |
| Output bytes                       | Standard padded base64     |
| `resourceVersion` and `continue`   | Opaque strings             |
| Empty optional maps and lists      | Usually omitted            |

Create and apply require `Content-Type: application/json`.
Missing or unsupported media types return `415`.

Every HTTP request body is limited to 4 MiB.
An oversized body returns `413`.

## gRPC encoding

The protobuf package is `solti.task.v1`.
The complete service method prefix is:

```text
/solti.task.v1.TaskService/
```

Resource and live-output timestamps are Unix milliseconds.
Output chunks contain raw protobuf bytes.
Extension workload specs contain one UTF-8 JSON object in `RawExtension.raw`.

Encoded and decoded messages are limited to 4 MiB.

## Authentication

Authentication is disabled unless the application configures a token.

HTTP uses:

```text
Authorization: Bearer <token>
```

gRPC uses the same value in `authorization` metadata.
The `Bearer` scheme comparison is case-insensitive.

Missing, malformed, or rejected credentials return HTTP `401`.
gRPC returns `Unauthenticated`.

TLS is configured separately.
The application hosting the HTTP router owns HTTP TLS.
The optional `grpc-tls` feature converts `solti-tls` server settings for tonic.

## Errors

Both transports use the same error categories.

| Category                     | HTTP                         | gRPC                |
|------------------------------|------------------------------|---------------------|
| Invalid request              | `400 Bad Request`            | `InvalidArgument`   |
| Missing or invalid token     | `401 Unauthorized`           | `Unauthenticated`   |
| Existing create target       | `409 Conflict`               | `AlreadyExists`     |
| Failed write precondition    | `409 Conflict`               | `Aborted`           |
| Unknown resource or route    | `404 Not Found`              | `NotFound`          |
| Unsupported method           | `405 Method Not Allowed`     | `Unimplemented`     |
| Unsupported media type       | `415 Unsupported Media Type` | `InvalidArgument`   |
| Oversized request            | `413 Payload Too Large`      | `ResourceExhausted` |
| Unavailable resource version | `410 Gone`                   | `OutOfRange`        |
| Service shutting down        | `503 Service Unavailable`    | `Unavailable`       |
| Internal failure             | `500 Internal Server Error`  | `Internal`          |

HTTP errors use a Kubernetes-style `Status` resource.

```json
{
  "apiVersion": "v1",
  "kind": "Status",
  "metadata": {},
  "status": "Failure",
  "message": "task not found",
  "reason": "NotFound",
  "code": 404
}
```

Write conflicts also contain:

```json
{
  "details": {
    "name": "daily-report",
    "group": "solti.io",
    "kind": "Task",
    "causes": [
      {
        "reason": "ResourceVersionMismatch",
        "field": "resourceVersion",
        "message": "expected `10`, current `11`"
      }
    ]
  }
}
```

Internal diagnostics are logged by the server.
Clients receive the fixed message `internal server error`.

## Version identity

The Task resource API version is `solti.io/v1`.
The HTTP API group path is `/apis/solti.io/v1`.
The protobuf package is `solti.task.v1`.

The OpenAPI document version and the Task API version are separate values.
`HttpApi::build` returns a standalone OpenAPI 3.1 document.
`HttpApi::mount` contributes Task API paths and schemas to the application document.
The standalone document uses `info.version` for Task API v1.
The mounted document keeps the application `info.version`.
The `x-solti-task-api-version` extension identifies the mounted Task API.