Execution
A request can carry a sealed intent: the call your code wants to make, encrypted under a key only you hold. Outis stores the ciphertext and hands it back. It can’t read it, and it can’t change it.
Once the request is authorized, a worker you run finds the intent, claims it, decrypts it, checks it against what the operators approved, runs it with your own credentials and reports back. Outis keeps the claim ledger and fires an event when the report lands. It runs nothing.
The three routes here need a key holding execute. A worker’s key holds read and execute.
The intent
Section titled “The intent”The plaintext is UTF-8 JSON, written once by the proposer and never re-serialized:
{"v":1,"client":"stripe","method":"transfers.create","args":[{"amount":9900,"currency":"usd","destination":"acct_9f2"}]}
client is the name your worker registered a client under, method is a dotted path on it, and args are the positional arguments. The proposer puts "sha256:" + hex(SHA-256(plaintext)) in the request’s params under intent, so the operation hash covers it and the operators approve that exact call.
The key is 32 random bytes, base64. Its id is the first 16 hex characters of SHA-256 over the key bytes, so a worker holding several keys during a rotation picks the right one. The plaintext is sealed with AES-256-GCM under a fresh 12 byte nonce, and the additional data is outis.intent.v1, a zero byte, then the request’s action. An envelope sealed for one action won’t open under another.
The envelope goes on POST /v1/requests as intent, beside params.intent:
| FIELD | WHAT IT IS |
|---|---|
v | The envelope version. |
alg | The one algorithm. |
kid | The first 16 hex characters of SHA-256 over the key, so a worker holding several keys picks the right one. |
nonce | 12 random bytes, base64url. |
ciphertext | The encrypted plaintext with the 16 byte tag appended, base64url. |
execute_within on the same call sets how long after the verdict a worker may still claim it, in seconds. It defaults to seven days and tops out at thirty.
The execution object
Section titled “The execution object”Every request object carries intent, the envelope verbatim or null, and execution:
"execution": {
"state": "claimed",
"claimed_at": <epoch ms>,
"lease_expires_at": <epoch ms>,
"reported_at": null,
"reference": null,
"error": null,
"execute_by": <epoch ms>
}
| FIELD | WHAT IT IS |
|---|---|
state | Where the intent stands, read at the server's clock. A lease that ran out reads `pending` again. |
claimed_at | When the current claim was made. |
lease_expires_at | When the current claim stops being exclusive. |
reported_at | When the claim holder reported. |
reference | What the worker said the run produced, like a transfer id. |
error | What the worker said went wrong. |
execute_by | The last instant a worker may claim the intent, the verdict plus `execute_within`. |
States
Section titled “States”| STATE | MEANS |
|---|---|
| none | No intent, or one that can never run because the request wasn't authorized. |
| pending | Authorized and waiting for a worker to claim it. |
| claimed | A worker holds a live lease on it. |
| succeeded | The claim holder reported that the run worked. |
| failed | The claim holder reported that the run failed. |
execution never moves the request’s own state. An action your organization defined goes from authorized to succeeded when the keys turn, whatever your worker does afterwards; execution.state is where your run stands.
List executable requests
Section titled “List executable requests”GET /v1/requests?executable=true&limit=50
Your organization’s requests that are authorized, carry an intent, haven’t been reported, aren’t under a live claim and are still inside their window. Oldest first, at most 100. A worker polls this when it doesn’t take webhooks, or to catch one that never arrived.
curl "https://api.outis.tech/v1/requests?executable=true" \
-H "Authorization: Bearer $OUTIS_WORKER_KEY"
Returns
Section titled “Returns”{
"server_now": <epoch ms>,
"requests": [ { ...a request object, execution.state pending... } ]
}
| STATUS | WHEN |
|---|---|
| 400 | `executable` isn't `true`, or `limit` is out of range. Kind `invalid`. |
| 401 | No live key. The body names neither the key nor the reason. |
| 403 | The key is live and doesn't hold the execute scope. |
| 429 | Too many refused attempts from this peer. Carries `Retry-After`. |
| 500 | The requests couldn't be read. Kind `internal`. |
Claim a request
Section titled “Claim a request”POST /v1/requests/{id}/claim
Hands the intent to one worker until the lease runs out. However many workers claim at once, on however many Outis instances, exactly one gets a 200. The rest get a 409 with kind already_claimed.
Optional. An empty body takes the default lease.
| FIELD | WHAT IT IS | |
|---|---|---|
lease_seconds | optional | How long the claim is yours alone. Absent is 600. |
curl -X POST "https://api.outis.tech/v1/requests/req-4f2a9c1b8d7e6f50/claim" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OUTIS_WORKER_KEY" \
-d '{"lease_seconds": 300}'
Returns
Section titled “Returns”| FIELD | WHAT IT IS |
|---|---|
server_now | The server's clock when it answered, so a worker can time its lease against it. |
claim_id | Send it back with the report. clm_ and 24 hex characters. |
lease_expires_at | When the lease runs out, UTC epoch milliseconds. |
request | The request object, intent included, as of the claim. |
A lease that runs out without a report is claimable again, and the new claim replaces the old one. That’s how a worker that crashed mid run gets covered. It’s also why the downstream call has to carry the request id as its idempotency key: the second worker may be repeating a call the first one already made.
| STATUS | WHEN |
|---|---|
| 400 | The body is unreadable or `lease_seconds` is out of range. Kind `invalid`. |
| 401 | No live key. The body names neither the key nor the reason. |
| 403 | The key is live and doesn't hold the execute scope. |
| 404 | No such request in your organization. Kind `not_found`. |
| 409 | Nothing to claim: another worker holds a live lease (`already_claimed`), the run was already reported (`already_reported`), the request isn't authorized (`not_authorized`), or it carries no intent (`no_intent`). |
| 410 | The execution window closed: `execute_by` has passed. Kind `execution_window_closed`. Propose it again if it still needs doing. |
| 413 | The body is larger than 4 KiB. Kind `too_large`. |
| 415 | A body was sent and it isn't application/json. Kind `unsupported_media_type`. |
| 429 | Too many refused attempts from this peer. Carries `Retry-After`. |
| 500 | The request couldn't be read or saved. Kind `internal`. Retry the claim. |
Report the run
Section titled “Report the run”POST /v1/requests/{id}/execution
The claim holder says how the run went. Only the current claim can report. A report after the lease ran out still counts if nobody claimed since. The same report twice is a 200 both times and fires one event.
| FIELD | WHAT IT IS | |
|---|---|---|
claim_id | required | The claim the run was made under. |
status | required | How the run went. |
reference | optional | What the run produced, like the downstream object's id. |
error | optional | What went wrong, for the people reading the request. |
curl -X POST "https://api.outis.tech/v1/requests/req-4f2a9c1b8d7e6f50/execution" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OUTIS_WORKER_KEY" \
-d '{"claim_id": "clm_5b0e2f7c91d34a86e1f0c2d3", "status": "succeeded", "reference": "tr_1Q2w3E"}'
Returns
Section titled “Returns”The request object wrapped with server_now, the same as GET /v1/requests/{id}. Outis then posts request.executed or request.execution_failed to the request’s callback URL and your webhook endpoints. See Events and callbacks.
| STATUS | WHEN |
|---|---|
| 400 | The body is unreadable, has no `claim_id`, has a `status` other than `succeeded` or `failed`, or a `reference` or `error` over 512 characters. Kind `invalid`. |
| 401 | No live key. The body names neither the key nor the reason. |
| 403 | The key is live and doesn't hold the execute scope. |
| 404 | No such request in your organization. Kind `not_found`. |
| 409 | Someone claimed after you (`claim_lost`), a different report is already recorded (`already_reported`), or the request carries no intent (`no_intent`). |
| 413 | The body is larger than 4 KiB. Kind `too_large`. |
| 415 | This route reads application/json. Kind `unsupported_media_type`. |
| 429 | Too many refused attempts from this peer. Carries `Retry-After`. |
| 500 | The request couldn't be read or saved. Kind `internal`. Send the same report again. |
Before a worker runs anything
Section titled “Before a worker runs anything”The worker checks, in this order, and reports failed without running anything if one doesn’t hold:
- It holds a key whose id matches the envelope’s
kid. - The envelope decrypts with the additional data built from the request’s
action. - SHA-256 of the plaintext matches
params.intent. operation_hashmatches the operation hash it computes fromactionandparams.outcomeisauthorized.- The intent’s client is one the worker registered, and the method is one it allows.
Outis can’t forge a call that passes step 3, because it doesn’t hold your key. A proposer can write any intent it likes, and people still approve its exact digest before anything runs.
Error kinds
Section titled “Error kinds”| KIND | MEANS |
|---|---|
| invalid | The body or a query parameter is malformed. A 400. |
| not_found | No such request in your organization. A 404. |
| no_intent | The request carries no intent. A 409. |
| not_authorized | The request isn't authorized, or its authorization no longer stands. A 409. |
| already_claimed | Another worker holds a live lease. A 409; try again after it runs out. |
| already_reported | The run was already reported. A 409. |
| claim_lost | Someone claimed after you, so your report isn't the current one. A 409. |
| execution_window_closed | `execute_by` has passed. A 410. |
| too_large | The body is over 4 KiB. A 413. |
| unsupported_media_type | The body isn't application/json. A 415. |
| internal | Something failed on the server's side. A 500; retry. |
A key that’s absent, revoked or lacks execute gets a 401 or 403 from the key check, with error alone.