Ash API Contract Design Notes
These notes define the Ash model/action shape, compatibility boundaries, and retained-controller rationale for the current endpoint inventory. The builder contract slice was rebaselined here, and the approved device-runtime conversion is an additive generated route family.
Usage Rule Consultation
- Read in-repo usage rules from
packages/server/deps/ash/usage-rules.md,packages/server/deps/ash_json_api/usage-rules.md, andpackages/server/deps/ash_phoenix/usage-rules.mdafter fetching server dependencies. - Ash guidance used here: organize around domains/resources, create
domain-specific actions instead of generic CRUD-only behavior, put business
logic inside actions, use action arguments for validated inputs, and prefer
domain code interfaces over direct
Ash.*calls from web modules. - AshJsonApi guidance used here: expose resources through the domain
json_apiblock and resourcejson_apidefinitions; JSON:API route types includeget,index,post,patch, anddeletewith standard JSON:API filter/sort/page semantics. - AshPhoenix guidance is mostly UI/form-oriented for this feature. It reinforces using domain code interfaces and generated helpers rather than bypassing Ash through direct web-module calls.
- The in-repo usage rules are the authoritative guidance for this design.
Ash-Backed Endpoint Model
Builder APIs (existing implementation)
Existing compatibility and generated endpoints:
GET /api/v1/builder-schemasGET /api/v1/builder-schemas/:schema_id/versions/:schema_version/optionsPOST /api/v1/builder-configurations/validate
Implemented model:
- Use the existing non-persisted Ash resource
Nixstasis.SchemaOptions.BuilderContractfor builder option and validation contracts. Preserve the action names and domain fields below. - Use the existing read action
:list_schema_referenceswith no required arguments. Output items preserve the currentSchemaReferencefields:schema_id,schema_version,product_name, andreadable. - Use the existing read action
:options_forwith required argumentsschema_idandschema_version, plus an optionalbuilderargument that defaults toalert. Thebuilderargument is the existing enum stringalertorreport. Successful output includes theSchemaOptionsResponsefieldsschema_id,schema_version,builder,options, andload_time_ms; each option preserveskey,label,value_type,order_index, andselectable. The legacy/api/v1wrapper continues to expose that exact response body, while the generated Ash JSON:API route exposes the same successful payload fields and uses JSON:API error documents for missing or invalid requests. - Add an action
:validate_builder_configurationwith required request fieldsbuilder,schema_id,schema_version, andselections. Each selection preservesslot_idandselected_key. Output preservesValidationResponse:valid,issues, andcleared_slot_ids; each issue preservesissue_code,message,slot_id, andblocking. - Expose these through generated Ash OpenAPI, preferring Ash JSON:API route/action support when it can preserve the current wire shape. If Ash JSON:API cannot preserve the wire shape, use Ash-backed actions behind a thin controller and keep generated schemas as canonical while documenting the controller transport wrapper.
- Preserve the existing
/api/v1response models frombuilder-api.yaml:SchemaReference,SchemaOptionsResponse,ValidationRequest,ValidationResponse, andValidationIssue. Generated/api/jsonroutes may use JSON:API transport envelopes and error documents, but must keep the domain fields and status-code semantics explicit. - Keep authorization/access-denied behavior explicit. The generated
/api/json/builder_contract/*routes run through the JSON API permission pipeline and bearer security, while the/api/v1wrappers retain their compatibility-pipeline behavior. Document and test these as separate contracts;404,422, and generated403semantics must remain explicit.
Implementation note: the first builder slice uses
Nixstasis.SchemaOptions.BuilderContract as a non-persisted generic-action
resource. Existing /api/v1 routes remain the compatibility transport, while
generated Ash JSON:API routes under /api/json/builder_contract/* publish the
Ash-backed action contracts in packages/server/priv/static/openapi.yaml. The
generated routes are Ash generic-action RPC endpoints hosted by the Ash JSON:API
router: successful generic-action responses use raw action payloads, and POST
generic actions use Ash JSON:API's generated 201 success status even when the
action validates rather than creates data. /api/v1 remains the compatibility
surface for clients that require the original wrapper status/body shape.
The builder slice is existing implementation, not a future conversion candidate. Its route-specific authorization, generated/static OpenAPI evidence, wrapper parity, and reader-facing documentation are reconciled alongside the device-runtime actions.
Builder Compatibility Matrix
The generated and compatibility routes intentionally expose different transport contracts while sharing the same Ash/domain behavior:
| Surface | Phoenix authorization boundary | Success contract | Error/status contract |
|---|---|---|---|
GET /api/json/builder_contract/schema_references | JsonApiPermissions report-view policy; bearer security in generated OpenAPI | 200, raw JSON array | 403 JSON:API error when permission is denied |
GET /api/json/builder_contract/schemas/:schema_id/versions/:schema_version/options | JsonApiPermissions report-view policy; bearer security in generated OpenAPI | 200, raw options object | 400 invalid query, 403 permission denied, 404 missing schema |
POST /api/json/builder_contract/builder_configurations/validate | JsonApiPermissions report-view policy; bearer security in generated OpenAPI | 201, raw validation object | 400 invalid JSON:API body, 403 permission denied |
GET /api/v1/builder-schemas | /api/v1 :api pipeline and rate limiter; no JSON:API permission plug | 200, {"data": [...]} | Legacy wrapper behavior |
GET /api/v1/builder-schemas/:schema_id/versions/:schema_version/options | /api/v1 :api pipeline and rate limiter; deployment-edge auth is separate | 200, {"data": ...} | 404 schema_not_found, 422 invalid request |
POST /api/v1/builder-configurations/validate | /api/v1 :api pipeline and rate limiter; deployment-edge auth is separate | 200, raw validation object | 422 invalid_validation_payload |
The generated contract is canonical for Ash action fields and OpenAPI. The
/api/v1 wrappers remain a separate compatibility surface because their JSON
shape, status codes, and Phoenix pipeline differ.
Device Runtime APIs
Candidate endpoints:
GET /api/v1/devicesPOST /api/v1/devices/registerPOST /api/v1/devices/:device_id/heartbeatPOST /api/v1/devices/:device_id/command_resultsGET /api/v1/devices/:device_id/command_payloads/:ref
The device runtime is an Ash-backed implementation group, not a deferred candidate. The contract is split into an Ash-generated surface and an unchanged compatibility surface so the Go client does not need an unversioned migration.
Ash ownership and orchestration boundary
Nixstasis.Devices.Deviceowns the persisted device resource and the existing:readand:registerAsh actions. Registration remains a domain action rather than generic device creation because it validates the public schema, preserves the MAC identity/approval state, and issues a token only after an approved result.- The generated runtime action boundary uses five explicit non-CRUD action names:
:list_runtime_devices,:register_runtime_device,:heartbeat,:acknowledge_command_results, and:fetch_command_payload. The first two adaptDevice.read/Device.registerandDevicesnormalization; the latter three delegate to the existing orchestration contexts. Their Ash action inputs and outputs must be explicit in generated OpenAPI. Nixstasis.Devicesowns registration normalization and token issuance, device lookup/authentication, list filter normalization, pending-command claiming/acknowledgement, and deferred-payload extraction.Nixstasis.Monitoringremains the heartbeat orchestrator. The heartbeat action delegates to it for last-seen updates, sanitized telemetry persistence, best-effort inventory persistence, offline-alert resolution, rule evaluation, and pending-command delivery.Nixstasis.ScriptsandNixstasis.CommandAllowlistsretain command-result side effects.- The web layer owns only transport adaptation: parsing the legacy body/query shape, selecting the compatibility status/error envelope, and delegating to the Ash/domain action. It must not reimplement authentication or workflow side effects in each controller.
Generated transport and security decision
The canonical generated target is an additive Ash JSON:API route family under
/api/json/device_runtime/devices. It is intentionally separate from the
operator CRUD family at /api/json/devices:
| Generated route | Ash/domain boundary | Authentication |
|---|---|---|
GET /api/json/device_runtime/devices | :list_runtime_devices, which adapts Devices.list_devices/1 and returns normalized active-filter metadata. | Operator bearer/device-view permission; this is not a device-runtime credential operation. |
POST /api/json/device_runtime/devices/register | :register_runtime_device, which calls Device.register through public normalization and approved-token issuance. | No application device credential; deployment-edge protection remains separate. |
POST /api/json/device_runtime/devices/{device_id}/heartbeat | :heartbeat, delegating to Monitoring.heartbeat/2. | deviceBearer HTTP bearer security. |
POST /api/json/device_runtime/devices/{device_id}/command_results | :acknowledge_command_results, with Scripts and CommandAllowlists ingestion. | deviceBearer HTTP bearer security. |
GET /api/json/device_runtime/devices/{device_id}/command_payloads/{ref} | :fetch_command_payload, backed by Devices.get_command_payload/2. | deviceBearer HTTP bearer security. |
The generated family uses the Ash JSON:API media type and an explicit OpenAPI
schema for each action. POST action inputs are JSON:API data objects; the
compatibility wrappers continue to accept their current plain JSON objects. The
required generated inputs are mac_address plus schema_definition or legacy
schema for registration; telemetry, connection_status, and optional
command_inventory for heartbeat; and a required results array for command
acknowledgement. List filters are query inputs (product, account_number,
approval_status, connectivity_status, and ipv4_address); payload fetch uses
device_id and ref path inputs. These are generic Ash action routes, not generic
CRUD:
list_runtime_devices returns the logical data collection and normalized
meta.active_filters; register_runtime_device returns the logical registration
fields and optional token; heartbeat returns data.commands plus optional probe
and remote-access fields; acknowledge_command_results returns
data.acknowledged_count; and fetch_command_payload returns the raw payload
fields. Generated action routes explicitly set statuses 200, 201, 200,
202, and 200 respectively, and use JSON:API error documents for auth,
validation, and not-found failures. The current /api/v1 paths remain the
compatibility transport with their exact application/json body/status/error
behavior. Both surfaces call the same Ash/domain boundary; the Go client remains
on /api/v1 until a separately reviewed client migration is approved.
deviceBearer is the route-level generated OpenAPI security scheme:
type: http, scheme: bearer, with the registration-issued device token carried only in Authorization. The existing /api/json pipeline
must dispatch these paths explicitly before its generic JsonApiPermissions
policy: GET list uses the existing operator bearer/device-view check,
POST .../register is an application-level public exception, and the three
runtime actions fetch the device and authenticate the bearer token. Query-string credentials are rejected. The permission
boundary sets the authenticated device with Ash.PlugHelpers.set_actor/2 (and
context when needed) before forwarding to AshJsonApiRouter; the raw token is never
an action argument. It must preserve error precedence: unknown device 404,
missing/invalid bearer token 401, and unapproved device 403. security: [] in generated
OpenAPI documents the registration exception; it does not bypass the Plug. The
/api/v1 controllers retain the same lookup and error precedence during the
transition.
Implementation: the explicit device_runtime dispatch in the existing JSON API
pipeline includes the public registration exception, operator list policy, device
lookup/bearer-token validation, Ash.PlugHelpers.set_actor/2, and HTTP/direct tests
for 404/401/403 precedence. The generated heartbeat orchestration action has a
200 response, heartbeat-rate-limit classification, and OpenAPI/runtime coverage.
The generated command-result acknowledgement and payload-fetch actions preserve
202/200 status and not-found/replay behavior while reusing the same branch for
command-result side effects.
Both generated and compatibility routes use the layered API limiter. Before
device authentication, requests are limited by trusted network origin plus a
finite route bucket and by a global flood ceiling; raw device IDs never allocate
pre-authentication keys. After successful device authentication, heartbeat is 30
requests per 60 seconds per device and other device runtime actions are 120
requests per 60 seconds, with separate keys per action. All five generated paths
have runtime tests and appear in the generated static artifact; the committed
website/docs/reference/openapi/device-api.yaml remains the compatibility reference
for /api/v1 wrappers because its transport contract is distinct. Duplicate
hand-maintained sections are not removed when they document that compatibility
surface.
Current /api/v1 compatibility contract
The following behavior is the baseline that every generated action and wrapper must preserve at the domain/side-effect level. Every route below also passes the pre-authentication origin-and-route limiter (default 1,000 requests per 60 seconds) and global flood ceiling (default 50,000 requests per 60 seconds). Only successfully authenticated device runtime actions consume the separate per-device quotas:
- List —
GET /api/v1/devices: requires verified operator authentication and device-view permission (or the explicit local-development fallback), with the operator's device scope enforced before query filters. Missing authentication returns401; missing permission or malformed scope returns403. The route uses the pre-authentication origin-and-route limit and global flood ceiling, without a device quota.Devices.list_devices/1applies exact product, account, approval, connectivity, andipv4_addressfilters. Connectivity is online whenlast_seen_atis within five minutes and offline when it is older or nil. Success is200with{"data": [...], "meta": {"active_filters": ...}}; filter values are normalized before being echoed. The route has no mutation side effects and returns429when either pre-authentication limit is exceeded. - Registration —
POST /api/v1/devices/register: the current:apipipeline has no device bearer-token requirement and uses only the pre-authentication origin-and-route limit and global flood ceiling, without a device quota.schema_definitionis copied toschema;schemais the legacy alias; public registration requires a schema withproduct; andipv4_addressmay be taken from either the direct field ormetadata.ip_address. Registration is keyed by MAC and preserves operator-owned approval and remote-access settings. Following the enrollment security reconciliation, initial registration requires a client-preparedregistration_tokenpersisted before sending; only its hash is stored. Approved exchanges require that proof (or the current runtime token) and a distinct, pre-savedreplacement_token. Verification, attribute updates, and hash rotation share a transaction. Identical committed retries return the replacement asapi_tokenwithout mutation; superseded proofs cannot exchange again. Pending devices have no runtime API token. Success remains201. Invalid schema or required new credentials are422, invalid proof is403, and exceeding either pre-authentication limit is429. - Heartbeat —
POST /api/v1/devices/:device_id/heartbeat: the controller fetches the device before authenticating theAuthorizationbearer token; approved devices require a secure token match. After successful device authentication, the route is limited to 30 requests per 60 seconds per device, in addition to the pre-authentication limits. A successful200response containsdata.commandsand may contain the server-ownedcommand_inventory_probeand the FRPSremote_access_token. Telemetry is sanitized so top-level and nestedcommand_inventorydo not enter the telemetry event; inventory is persisted separately and malformed inventory is best effort. The heartbeat updates last-seen state, resolves offline alerts, evaluates rules, and claims at most 50 queued pending commands in FIFO order. Failure classes are404device-not-found,401missing/invalid key,403unapproved device,422heartbeat failure, and429rate limit. - Command results —
POST /api/v1/devices/:device_id/command_results: the request requires a list underresults; success is202withdata.acknowledged_count. Scripts and command-policy consumers ingest results before matching pending commands are acknowledged. Unknown or non-matching command IDs are ignored. A repeated ID currently re-reads an already-acked pending command, merges the result again, and increments the acknowledgement count; this is the observed compatibility behavior, not an idempotency guarantee. Missing/invalid keys, unknown devices, and unapproved devices retain401/404/403; a non-list body is400. The current controller has no reachable422processing-error branch for a list input, so the generated action must not invent one or silently change replay behavior; any future hardening requires an explicit versioned contract decision. After successful device authentication, the route has a separate 120-requests-per-60-seconds per-device command-result quota. Exceeding that quota or either pre-authentication limit returns429. - Deferred payload —
GET /api/v1/devices/:device_id/command_payloads/:ref: lookup and bearer-token authentication use the same precedence. After successful device authentication, a separate command-payload quota allows 120 requests per 60 seconds per device, in addition to the pre-authentication limits. Success is200with the raw{content_type, name, data}payload; missing device or payload is404, missing/invalid key is401, and an unapproved device is403. Fetching a payload has no command acknowledgement side effect; rate limiting returns429.
The required baseline is covered by the server controller/domain tests and the
Go transport tests before each conversion group. Source references are the five
routes in packages/server/lib/nixstasis_web/router.ex,
DeviceController, HeartbeatController, DeviceCommandController,
Nixstasis.Devices, Nixstasis.Monitoring,
packages/client/internal/transport/client.go, and the focused tests under
packages/server/test/nixstasis_web/controllers/ plus
packages/client/internal/transport/.
Report Result API
Current route:
GET /api/v1/reports/:id/results
This route is deferred for Ash migration. Reports currently need LiveView preview
behavior, while a future export or external reporting consumer may justify an
Ash-backed action. Do not add CustomReport.results in this feature without an
approved external contract. Keep the current {fields, rows} shape,
not-found/result-unavailable behavior, bespoke OpenAPI, and a baseline controller
test visible for that future decision.
Existing Ash JSON:API Resources
These route groups remain generated Ash resource APIs under /api/json:
devicespending_commandsalertsalert_rulestelemetry_eventscustom_reportssystem_settings
The existing resource groups are not rewritten as part of the device migration, but their inventory, authorization, generated/static OpenAPI, and route-test evidence must remain reconciled. The six script persistence resources remain Ash-owned for the LiveView workbench, but their generic JSON:API routes are intentionally absent. The workbench calls the domain directly, and generic CRUD would bypass validation, command dispatch, and audit boundaries. A future external script contract requires audited domain-specific actions and a new design decision.
Retained Controller Rationale
Caddy TLS Approval
GET /api/v1/check_domain remains controller-owned by default.
Rationale:
- It is an ingress ask workflow called by Caddy, not a resource CRUD contract.
- The response contract is intentionally simple and deployment-coupled.
- Keeping it in
TLSControllermakes the runtime boundary visible in Caddy and deployment docs.
Development Laptop TLS Diagnostics
GET /_nixstasis/laptop/tls_observations and
DELETE /_nixstasis/laptop/tls_observations remain controller-owned.
Rationale:
- They are development-harness diagnostics, not production product APIs.
- Moving them into generated product OpenAPI could imply unsupported production semantics.
E2E API
All /e2e endpoints remain controller-owned for this feature.
Rationale:
- The E2E surface is a gated protocol workflow with enablement checks, protocol-version headers, idempotent run reuse, environment locks, seed execution, result ingestion, log retention, and typed errors.
- The current E2E tests and reporting model may need a separate design review; this feature records the existing contract but does not spend migration effort on generated OpenAPI for it.
Response Schema Rules
- Generated OpenAPI may organize schemas differently from bespoke files, but runtime JSON, status codes, auth failures, and typed errors must remain compatible unless a versioned migration is documented.
- Converted endpoints must have tests for success, validation failure, authorization/authentication failure, and important edge cases before bespoke OpenAPI sections are removed.
- Generated
/api/jsonauthorization and/api/v1compatibility authorization are separate contract surfaces and must be documented/tested separately. - Retained endpoints must keep route-specific rationale in reference docs.
- Deferred endpoints must keep their current contract and an explicit future decision boundary; deferral is not permission to remove existing docs/tests.