ADR-027: Callback ingress as a deployable surface¶
Status¶
Proposed
Context¶
Awa currently exposes HTTP-worker callback endpoints through awa-ui. awa serve builds one axum router that contains:
- the operator UI static assets,
- the admin REST API under
/api, - the HTTP-worker callback receiver under
/api/callbacks.
That bundling is convenient for development, but it mixes surfaces with different exposure requirements.
The admin UI and REST API are an operator surface. They should normally be private, authenticated by deployment infrastructure, and reachable only from trusted networks. The callback receiver is different: when using HttpWorker async mode, the remote function must be able to reach it to complete, fail, or heartbeat a parked job. In many deployments that means the callback receiver is public or partner-facing, even when the admin UI is not.
Today users have two imperfect options:
- Run
awa serveand expose only/api/callbacksthrough a reverse proxy. This works, but the process still contains the whole admin/UI surface and permissive UI CORS configuration. - Implement their own callback endpoint around
admin::complete_external,admin::fail_external, andadmin::heartbeat_callback. This gives them control over FastAPI, axum, Django, or another framework, but forces them to reimplement Awa's callback signature contract, payload parsing, error mapping, and future compatibility rules.
ADR-018 deliberately made HTTP workers possible without new storage primitives, and ADR-021 defines the callback semantics. This ADR narrows the HTTP deployment surface so those semantics can be exposed safely without requiring the admin UI.
Decision¶
Make callback ingress a first-class Awa surface, separate from the admin UI.
Surface split¶
Awa's HTTP-facing pieces should be documented and structured as distinct surfaces:
| Surface | Purpose | Expected exposure |
|---|---|---|
| Admin UI / admin REST API | Operator inspection and mutation | Private operator network |
| Callback receiver | Complete, fail, and heartbeat callback waits | Public or partner-facing, signed |
| Worker runtime | Claim and execute jobs | Internal |
| Maintenance runtime | Promote, rescue, prune, and refresh runtime state | Internal |
awa serve remains the admin/UI command. It may continue to mount callback routes for backward compatibility, but the callback receiver should no longer be treated as an incidental part of the UI crate.
Shared callback contract and auth¶
Extract the HTTP callback contract into a shared module or crate that is not owned by awa-ui:
- request and response types for
complete,fail, andheartbeat, - the
X-Awa-Signatureheader name, - BLAKE3 keyed-hash signing and verification,
- 32-byte secret parsing,
- timeout validation and error mapping,
- contract tests shared by every HTTP integration.
The worker-side signer and receiver-side verifier must use the same implementation. The option name hmac_secret may remain for compatibility, but docs and new APIs should describe the algorithm precisely as BLAKE3 keyed hashing.
Callback-only axum router¶
Expose a callback-only axum router, for example:
let router = awa_callbacks::router(pool, CallbackReceiverConfig {
secret: Some(callback_secret),
path_prefix: "/api/callbacks".into(),
..Default::default()
});
The callback-only router should:
- mount only callback routes,
- serve no UI assets,
- expose no admin REST routes,
- avoid permissive CORS by default,
- require writable database access,
- require a callback signing secret by default unless the caller explicitly opts into unsigned callbacks.
This router can be mounted by Awa's own CLI, by an existing axum application, or by users who want a minimal callback receiver binary.
Framework-neutral service layer¶
Provide lower-level callback receiver functions that can be used outside axum:
complete_callback(pool, callback_id, signature, payload),fail_callback(pool, callback_id, signature, error),heartbeat_callback(pool, callback_id, signature, timeout).
The service layer should own validation and signature verification, then call the storage/admin APIs. This lets Rust frameworks share one implementation and gives the Python package a clear contract to mirror for FastAPI, Starlette, Django, or Flask examples.
Python helpers should at minimum expose signature verification and typed request parsing examples. If the Python admin/client API grows a callback receiver helper, it should preserve the same contract and tests.
CLI¶
Add a callback-only command:
The command should accept the normal database/pool options plus callback receiver configuration:
- host and port,
- callback signing secret,
- path prefix,
- optional explicit unsigned mode for private-network deployments.
It should not inherit awa serve's admin routes, static file fallback, or UI CORS behavior.
Callback URL construction¶
HttpWorker should stop hard-coding callback URLs as:
Keep that as the compatibility default, but add configuration for a path prefix or URL template. That allows callback-only servers and user-owned API layers to expose framework-native routes without reverse-proxy rewriting.
Lifecycle hooks¶
Callback ingress resolves callback state. It must not assume a co-located Client runtime or local in-process lifecycle hook registry.
Durable side effects triggered by callback resolution are delivered through the transactional follow-up enqueue mechanism in ADR-029. A worker Client performing the resolution (the in-process worker case) commits the callback transition and the follow-up INSERT in a single transaction via the admin::*_external_in_tx / store::*_external_in_tx helpers — an INSERT failure rolls the transition back, so the external sender can retry. Once the follow-up commits an ordinary worker (anywhere) picks it up.
A callback-only ingress process (this ADR) does not yet own a worker registry, so it cannot dispatch user follow-up specs itself. When the callback-only surface lands, reusing the same _in_tx admin helpers plus a router-side registry hookup will give it the same atomic contract; until then, callback-only ingress runs the transition without follow-up dispatch. The callback receiver does not invoke process-local hooks directly; observation-only hooks (ADR-015) continue to fire only in processes that perform the resolution themselves via the worker Client.
Consequences¶
Positive¶
- The recommended secure deployment becomes obvious: public callback ingress, private admin UI, internal workers and maintenance.
- Users can embed Awa callbacks in FastAPI, axum, or another application without copying the signature algorithm and payload contract from docs.
- Minimal callback deployments no longer carry the embedded frontend or admin REST surface.
HttpWorkerasync mode becomes easier to operate behind real routing schemes instead of relying on the UI path layout.
Negative / Risks¶
- There is one more public surface to version and test.
- The split overlaps with, but does not replace, the broader
awa-api/awa-uiseparation discussed elsewhere. Keeping this narrowly scoped is important. - Tightening callback-only defaults to require signatures is safer but may surprise users who expect the old
awa servebehavior where missing secrets disable verification. Compatibility docs and explicit opt-out flags are required. - Lifecycle hook behavior around externally resolved jobs must be stated precisely so users do not assume the callback receiver runs in the same process as their worker hooks.
Alternatives considered¶
Keep callback routes inside awa serve¶
This is the smallest implementation, and it works when deployments can rely on private networking or a reverse proxy. It keeps the security guidance awkward: the process users need to expose for callbacks also contains admin mutation routes and UI concerns.
Split all API handlers into a new awa-api crate first¶
A broader API/UI split may still be useful, especially if Awa ships an API-only image. It is more work than the callback problem requires. Callback ingress has a distinct security boundary and should be separable even if the admin API remains in awa-ui for now.
Require every application to implement callbacks itself¶
This gives maximum framework flexibility, but makes every integration responsible for reproducing auth, parsing, and compatibility behavior. That is exactly the glue ADR-018 tried to remove for HTTP workers.
Rely on reverse-proxy path filtering¶
Path filtering is still useful defense-in-depth, but it should not be the only way to avoid exposing the admin UI. A callback-only router gives the proxy a smaller upstream to protect.
Relationship to other ADRs¶
- ADR-018 introduced
HttpWorkerand the callback receiver contract this ADR narrows into its own deployable surface. - ADR-021 defines callback completion, sequential waits, and heartbeats. This ADR changes HTTP exposure, not callback semantics.
- ADR-015 defines builder-side lifecycle hooks. Callback ingress does not depend on process-local hook registries; observation hooks stay in the worker process.
- ADR-029 defines transactional follow-up jobs, the runtime-independent mechanism callback ingress uses for durable side effects triggered by a resolution.