SSOLVOTIX / AGENT ENTRYPOINT
DOCUMENT_STATUS=READY
---
document: solvotix-agent-guide
document_version: "1.1"
language: en
audience: autonomous-coding-agent
read_mode: sequential
api_base_url: https://backend.solvotix.org
openapi_url: https://backend.solvotix.org/v3/api-docs
swagger_url: https://backend.solvotix.org/swagger-ui/index.html
manifest_url: https://solvotix.net/ai-first/agent-manifest.json
authentication: solvotix-api-user-token
tenant_header: Tenant
status: live
---

# SOLVOTIX AGENT EXECUTION GUIDE

## 0. EXECUTION DIRECTIVE

You are integrating software with the Solvotix physical-device platform.

Follow these instructions in order.

1. Read this document completely before changing code.
2. Retrieve the live OpenAPI document before selecting endpoints or generating types.
3. Treat OpenAPI as authoritative for paths, methods, parameters, schemas, and documented responses.
4. Use this document as authoritative for sequencing, physical semantics, verification, risk, and approval rules.
5. Do not invent endpoints, fields, device capabilities, success states, or retry behavior.
6. Begin with authentication and read-only discovery.
7. Do not execute a physical operation until the tenant, device ID, device type, and real-world purpose are known.
8. An HTTP success response can mean accepted or queued. It does not prove physical completion.
9. Verify physical operations through queue state, events, and current device state when available.
10. Never expose tokens, passwords, refresh tokens, Wi-Fi credentials, access codes, private keys, or service-account files.
11. Never automatically retry a non-idempotent physical command.
12. Stop and report uncertainty when this guide, OpenAPI, inventory, and observed state disagree.

## 1. MACHINE RESOURCES

```yaml
html_guide: https://solvotix.net/ai-first/agent-guide
raw_markdown: https://solvotix.net/ai-first/agent-guide.md
structured_manifest: https://solvotix.net/ai-first/agent-manifest.json
openapi_json: https://backend.solvotix.org/v3/api-docs
swagger_ui: https://backend.solvotix.org/swagger-ui/index.html
production_api: https://backend.solvotix.org
```

Preferred read order:

1. `agent-manifest.json`
2. `agent-guide.md`
3. live OpenAPI JSON
4. project-local conventions and existing generated clients

## 2. REQUIRED INPUTS

```text
SOLVOTIX_API_BASE_URL=https://backend.solvotix.org
SOLVOTIX_API_TOKEN=<sat_ token copied from the Solvotix portal>
SOLVOTIX_TENANT_ID=<tenant selected when the API user was created>
```

If the API token or its tenant ID is unavailable, stop and ask the system owner to create an API user in the Solvotix portal. Do not request human login credentials, create placeholder tokens, or embed a token in source code.

### 2.1 Restricted role

The `Restricted` role can be assigned to authenticated human users and tenant-bound API users. It is
a shared backend role, not an OAuth scope. A Restricted identity has broad application access,
including operations otherwise available to an unrestricted tenant user, with these enforced
exceptions:

- Access-code values are masked in JSON responses. This includes booking and room codes, lock-user
  codes, smart-lock slots and master codes, cleaning views, automated-message data, and event data,
  text, and reasons. A value such as `1234` is returned as `1**4`. MIFARE UIDs and credential
  identifiers are masked the same way, so `DEADBEEF` is returned as `D******F`.
- Raw gateway message payloads are removed from command responses.
- Gateway queue/message read, refresh, package-download, and delete operations return `403
  Forbidden`. This includes tenant, device, and individual-gateway queue routes.

The `Receptionist` role is unchanged and may receive booking codes. Do not infer Restricted behavior
from Receptionist behavior. A Restricted user may submit an access-code value for an authorized
operation, but must not expect the unmasked value to be echoed in the response. A `403` from a
gateway queue route must not be bypassed or treated as an empty queue; use non-queue state and event
verification that does not expose a code. Do not automatically retry the rejected request.

## 3. SYSTEM MODEL

```text
agent/application
  -> Solvotix REST API
  -> authenticated tenant context
  -> persistent command queue
  -> selected gateway
  -> physical device
  -> queue result / event / device state
```

```yaml
entities:
  api_user: tenant-bound Solvotix machine identity
  tenant: isolated organization, site, or installation
  gateway: connection between Solvotix Cloud and local devices
  sensor: generic API model for a connected node or device
  message_frame: hardware command accepted or queued by the backend
  event: structured hardware or system activity record
  queue: command delivery state between backend, gateway, and device
```

## 4. OPENAPI ACQUISITION

Retrieve the current specification:

```bash
curl --fail --silent --show-error \
  https://backend.solvotix.org/v3/api-docs \
  --output solvotix-openapi.json
```

Validate all of the following:

```yaml
required_top_level_fields:
  - openapi
  - info
  - servers
  - paths
  - components
required_component_fields:
  - schemas
required_security_scheme:
  name: bearerAuth
  type: http
  scheme: bearer
  bearer_format: JWT
```

OpenAPI processing algorithm:

```text
1. Validate the document structure.
2. Select https://backend.solvotix.org as the production server.
3. Index operations by tag, operationId, method, and path.
4. Resolve every local $ref.
5. Read request parameters and requestBody schemas.
6. Read every documented response schema and status.
7. Generate types using the project's existing generator when one exists.
8. Place authentication, tenant selection, safety, and retry behavior in a wrapper.
9. Never edit generated client files directly.
10. Record the OpenAPI info.version, retrieval time, and content hash.
```

Optional client generation:

```bash
npx @openapitools/openapi-generator-cli generate \
  -i https://backend.solvotix.org/v3/api-docs \
  -g typescript-fetch \
  -o generated/solvotix

openapi-generator-cli generate \
  -i https://backend.solvotix.org/v3/api-docs \
  -g python \
  -o generated/solvotix
```

Conflict rule:

```yaml
if_written_example_conflicts_with_openapi:
  action: stop
  report:
    - operation
    - written_value
    - openapi_value
    - proposed_resolution
  forbidden: guessing
```

## 5. CREATE THE SOLVOTIX SYSTEM AND API USER

API users are machine identities for integrations, scripts, and external systems. An API user belongs to exactly one tenant, uses a long-lived bearer token, does not require an interactive user account at runtime, can expire, can have the same roles as a human user, and can be revoked or rotated.

Human setup sequence:

1. Open `https://portal.solvotix.org/login`.
2. Press **Register here** and create the Solvotix system owner account.
3. Sign in and open `https://portal.solvotix.org/home/settings#system-users`.
4. Select the target tenant/system.
5. Create an API user with a descriptive integration name, the minimum appropriate role, and an expiration date when appropriate.
6. Copy the displayed `sat_...` token immediately.
7. Store the token in a secrets manager or protected environment variable.
8. Record the tenant ID selected during creation.

The plaintext token is displayed only when the API user is created or rotated. It cannot be retrieved later.

```yaml
api_user:
  identity_type: machine
  belongs_to_tenants: exactly-one
  token_prefix: sat_
  token_lifetime: long-lived
  expiration: optional
  revocable: true
  rotatable: true
  roles: [Janitor, Cleaning, Receptionist, Restricted, User]
  empty_roles: unrestricted
  role_changes_require_token_rotation: false
  runtime_interactive_account_required: false
  may_manage_api_users: false
```

## 6. STORE THE TOKEN

```text
SOLVOTIX_API_TOKEN=sat_REPLACE_WITH_COPIED_TOKEN
SOLVOTIX_TENANT_ID=REPLACE_WITH_BOUND_TENANT_ID
```

Mandatory token rules:

```yaml
token_storage:
  permitted:
    - secrets manager
    - protected server environment variable
  forbidden:
    - frontend or browser bundle
    - Git repository
    - URL or query parameter
    - application log
    - analytics event
    - error report
on_exposure: rotate immediately in the Solvotix portal
```

Do not build an API-token integration as browser-only code. The token grants broad access inside its bound tenant and must remain on a trusted server.

## 7. API-USER AUTHENTICATION

Every API request uses the copied token in the standard bearer header:

```http
GET /api/sensor
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Accept: application/json
```

```bash
curl "https://backend.solvotix.org/api/sensor" \
  -H "Authorization: Bearer $SOLVOTIX_API_TOKEN" \
  -H "Tenant: $SOLVOTIX_TENANT_ID" \
  -H "Accept: application/json"
```

The `sat_` prefix tells the backend to authenticate a Solvotix API user.

```yaml
authorization_header: Authorization
authorization_format: Bearer sat_<TOKEN>
tenant_header: Tenant
tenant_header_recommended: true
tenant_derived_from_token_when_omitted: true
tenant_mismatch_result: 401 Unauthorized
token_refresh_flow: none
token_rotation: human owner action in portal
```

The token is bound to the tenant selected during creation. Never substitute another tenant ID. Although the backend can derive tenant context from the token, include the `Tenant` header for consistency with generated clients and existing API calls.

## 8. VERIFY AUTHENTICATION

Use a read-only endpoint from the live OpenAPI specification. Start with device inventory:

```http
GET /api/sensor
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```

Interpret failures:

```yaml
401:
  possible_causes:
    - token missing
    - token malformed
    - token expired
    - token revoked
    - Tenant header does not match token tenant
  action: stop and ask system owner to verify or rotate the API user
403:
  possible_causes:
    - API user attempted API-user credential management
    - operation is not permitted for this identity
  action: stop; do not attempt privilege escalation
```

## 9. API-USER LIFECYCLE BOUNDARY

The integration cannot create, list, rotate, or revoke API users. Those operations require an authenticated regular portal user with tenant membership.

The API-user implementation does not provide OAuth-style scopes. It supports the same roles as
human users. Roles are stored on the API-user record and evaluated on every authenticated request,
so a role change applies to the next request without rotating the token. Existing API users with a
missing or empty role list retain unrestricted access for backward compatibility. Unknown role
names are rejected rather than ignored. Create API users only for trusted integrations, select the
minimum appropriate role, and isolate each integration with its own token so it can be revoked or
rotated independently.

```yaml
credential_management:
  portal: https://portal.solvotix.org/home/settings#system-users
  performed_by: human tenant member
  create: POST /api/api-users
  list: GET /api/api-users
  rotate: POST /api/api-users/{id}/rotate
  update_roles: PUT /api/api-users/{id}/roles
  revoke: DELETE /api/api-users/{id}
  api_user_calling_management_endpoint: 403 Forbidden
rotation_effect:
  old_token: invalid-immediately
  new_token_visibility: one-time
  revoked_user_reenabled: true
  expiration_preserved: true
access:
  tenant_boundary: enforced
  per_token_scopes_supported: false
  shared_human_api_user_roles: true
  role_changes_effective: next-request-without-token-rotation
  trust_requirement: trusted-integration-only
```

When a runtime request returns `401`, do not attempt an interactive sign-in. Stop and request API-user verification or rotation from the system owner.

## 10. READ-ONLY DISCOVERY

Execute in this order:

```yaml
steps:
  - method: GET
    path: /api/gateways
    purpose: list tenant gateways
  - method: GET
    path: /api/sensor
    purpose: list tenant devices
  - method: GET
    path: /api/gateways/{gatewayId}/sensors
    purpose: map devices to gateways
  - method: GET
    path: /api/gateways/{gatewayId}/metering/latest
    purpose: inspect gateway metering
  - method: GET
    path: /api/sensor/{deviceId}/pairings
    purpose: inspect device relationships
```

Build this inventory:

```yaml
device_inventory_fields:
  - id
  - name
  - type
  - online_state
  - gateway_ids
  - configuration
  - paired_device_ids
  - supported_operations_from_openapi
  - known_real_world_purpose
```

Do not map a generic `sensor` to an operation until its device type and compatible endpoint are established.

## 11. CLAIMING DEVICES

```text
GET  /api/sensor/{deviceId}/taken
POST /api/gateways/{gatewayId}/{urlEncodedName}
POST /api/sensor/{deviceId}/claim/{urlEncodedName}
```

```yaml
operation_class: ownership-changing
approval_required: true
preconditions:
  - target tenant confirmed
  - physical identifier confirmed
  - current ownership checked
automatic_retry: false
```

## 12. SMART LOCK RECIPES

Direct-delivery packages are also available without placing a command in the gateway queue:

```text
GET /api/smartlocks/{lockId}/packages/pulse-open
GET /api/smartlocks/{lockId}/packages/open
GET /api/smartlocks/{lockId}/packages/lock
```

Each response contains a newly generated `messageId`, `action`, `subAction`, `packageBase64`,
and `packageHex`. The two package encodings represent identical bytes. HTTP 200 means only that
the package was generated; it does not mean the command was queued, delivered, acknowledged, or
physically completed. Confirm the target and operation before direct delivery, never retry after
an ambiguous delivery result, and verify the lock's acknowledgement or resulting state.

### 12.1 Pulse-open

```yaml
operation: pulse_open_smart_lock
risk: physical
approval_required: true
idempotent: false
method: POST
path: /api/smartlocks/{lockId}/pulse-open
body: null
preconditions:
  - lockId belongs to selected tenant
  - device type supports smart-lock operations
  - door purpose is known
success_meaning: command accepted or queued
physical_completion_confirmed: false
verification:
  - inspect device queue
  - inspect sensor events
  - re-read device state when available
automatic_retry: forbidden
```

Prefer `pulse-open` for ordinary access. Persistent operations require stronger confirmation:

```text
POST /api/smartlocks/{lockId}/open
POST /api/smartlocks/{lockId}/close
```

### 12.2 Add access codes

```http
POST /api/smartlocks/{lockId}/codes/add
Content-Type: application/json
```

```json
{ "codes": ["1234", "98765"] }
```

```yaml
risk: security-sensitive
approval_required: true
code_constraint: 4-7 digits per current backend documentation
log_codes: forbidden
automatic_retry: forbidden
verification:
  - GET /api/smartlocks/{lockId}/codes/slots
  - inspect queue and related events
```

Runner serializes slot allocation and slot moves per lock. Concurrent code-management requests for
the same lock are processed one at a time, so they cannot allocate the same free slot within the
supported single-runner-process deployment. This concurrency guarantee does not make the operation
idempotent and does not confirm delivery to the physical lock.

Runner performs automatic recovery every five minutes for stored access-code slots that remain in
`adding to lock` state without an upload timestamp. Routine missing-command recovery waits at least
one minute after the slot was last queued. Recovery preserves slot numbers already assigned to
uploaded codes, repairs duplicate or invalid pending slot numbers, and recreates the affected
add-code queue commands after a repair. Recovery also recreates missing remove-code commands for
slots in `removing from lock` state while preserving their slot numbers and original upload
timestamps. It does not enqueue another command when the same code and operation are already present
in the tenant's outbound queue. This recovery is limited to commands that the backend previously
accepted; it does not authorize new access codes
and does not prove that recovery reached the physical lock. Verify the slot, queue, and related
delivery event after recovery.

Remove selected codes with `POST /api/smartlocks/{lockId}/codes/remove`.

Delete all codes with `DELETE /api/smartlocks/{lockId}/codes`. This is destructive and requires explicit confirmation.

### 12.2.1 Add a MIFARE credential to a lock user

A lock user is a logical access profile that holds credentials and the lock sensors those
credentials apply to. Numeric codes are set through the `codes` array on the lock user itself.
MIFARE card credentials are added one at a time through a dedicated route, in the same way a code is
added: the credential is stored on the lock user and then queued for every assigned lock sensor.

```http
POST /api/lock-users/{lockUserId}/mifare-credentials
Content-Type: application/json
```

```json
{ "mifareUid": "DEADBEEF", "code": "1234" }
```

`mifareUid` is the card UID as hex, with or without spaces, colons, or dashes. It must be 4, 7, or
10 bytes, that is 8, 14, or 20 hex characters, and is stored uppercase. `code` is optional. When it
is present the card and that PIN together form one two-factor credential, and the PIN must be 4-7
digits with the same length as the access codes already stored on the lock. When `code` is omitted
the card alone opens the lock.

```yaml
risk: security-sensitive
approval_required: true
uid_constraint: 4, 7, or 10 bytes of hex
code_constraint: optional; 4-7 digits, same length as the lock's existing codes
supported_device_types: [lock_8015, lock_s42]
log_credentials: forbidden
idempotency: re-posting a UID already on the lock user replaces its PIN; it does not create a second entry
automatic_retry: forbidden
verification:
  - GET /api/lock-users/{lockUserId} and read mifareCredentialsNotUploadedToAllSensors
  - GET /api/smartlocks/{lockId}/codes/slots
  - GET /api/events for action 1028 with data.credentialType mifare or mifare_pin
```

Only smart locks with the extended credential store accept MIFARE credentials. Assigned sensors of
any other device type are skipped without error, so a `200` response does not mean every assigned
sensor received the credential; confirm per lock through the slot list. Credentials and numeric
codes share the same slot space on the lock.

The response is the updated lock user. `mifareCredentialsNotUploadedToAllSensors` counts assigned
lock/credential pairs the lock has not confirmed yet, the same way `codesNotUploadedToAllSensors`
does for numeric codes. A `200` means the credential was stored and queued, not that the physical
lock has it.

Remove one credential with `DELETE /api/lock-users/{lockUserId}/mifare-credentials/{mifareUid}`.
This queues removal from every assigned lock on which no other lock user still grants the same card.
Removing a UID the lock user does not have returns the unchanged lock user.

Deleting a lock user, or removing a sensor or credential through `PUT /api/lock-users/{id}`, queues
the same removals. `DELETE /api/smartlocks/{lockId}/codes` erases MIFARE credentials along with
numeric codes.

Automatic recovery covers MIFARE credentials on the same five-minute cycle and with the same
guarantees as numeric codes: it recreates queued commands the backend previously accepted, and does
not authorize new credentials or prove delivery to the physical lock.

### 12.2.2 Manage phone wallet cards

Wallet cards are tenant-owned credentials. Creating the certificate alone does not authorize a
lock, but assigning it to a lock user or room queues a physical credential-store change. Neither
creation nor assignment proves that the card was installed on a phone or uploaded to a lock. Use bearer authentication and
the required `Tenant` header; tenant identity is taken from authenticated request context and is not
accepted in the path, query, or request body.

```http
POST /api/wallet-certificates
Content-Type: application/json

{
  "platform": "APPLE",
  "name": "Main entrance",
  "companyName": "Your company",
  "description": "Mobile access card",
  "active": true
}
```

`platform` is `APPLE` or `ANDROID`, and `name` is required. Blank `companyName` becomes
`Your company`. Blank `logoUrl` uses the published Solvotix artwork at
`https://solvotix.net/images/solvotix-wide-logo.png`. Text fields are trimmed and HTML/control text
is removed. Optional `validFrom` and `validUntil` values are instants; when both are present,
`validUntil` must be later. The generated read-only `credentialId` is the same 32-character value
embedded in Apple NFC/QR and Android Smart Tap/QR data and uploaded to assigned compatible locks.
The platform cannot be changed after creation. `tenantId`, signing keys, passwords, and service-account credentials
cannot be set through this API.

The CRUD routes are:

- `GET /api/wallet-certificates`
- `GET /api/wallet-certificates/{id}`
- `POST /api/wallet-certificates`
- `PUT /api/wallet-certificates/{id}`
- `DELETE /api/wallet-certificates/{id}`

Generate the phone-install artifact with:

```http
POST /api/wallet-certificates/{id}/package
```

For Apple, a successful response has `Content-Type: application/vnd.apple.pkpass` and contains a
freshly signed `.pkpass`. For Android, the response is JSON with a short-lived `saveUrl` and
`expiresAt`; open `saveUrl` on the phone. The endpoint never returns the Apple `.p12`, its password,
or Google private-key material. It returns `409` for inactive or expired records and `503` when
server signing configuration or logo retrieval is unavailable.

Assign an existing certificate to a lock user:

```http
POST /api/lock-users/{lockUserId}/wallet-certificates
Content-Type: application/json

{ "walletCertificateId": "wallet-certificate-id" }
```

The wallet certificate ID is stored in `LockUser.walletCertificateIds`; its credential is queued to
every compatible sensor in `LockUser.sensorIds`. Remove it with
`DELETE /api/lock-users/{lockUserId}/wallet-certificates/{walletCertificateId}`.

Assign the same certificate directly to every compatible lock in a room:

```http
POST /api/bookings/rooms/{roomId}/wallet-certificates
Content-Type: application/json

{ "walletCertificateId": "wallet-certificate-id" }
```

The ID is stored in the room's dedicated wallet-assignment record and the credential is queued to
each sensor in `Rooms.sensorIds`. Read assignments with
`GET /api/bookings/rooms/{roomId}/wallet-certificates`. Remove one with
`DELETE /api/bookings/rooms/{roomId}/wallet-certificates/{walletCertificateId}`. Assignment is
set-like and safe to repeat. When a room or lock user's sensors change, credentials are added to new
sensors and removed from old sensors. Removal from a sensor occurs only when no other room or lock
user still grants the same certificate there. Deleting the certificate removes all assignments and
queues removal from affected locks. Only `lock_8015` and `lock_s42` accept the extended wallet
credential; other assigned sensor types are skipped. A five-minute reconciliation pass recreates a
missing wallet slot/queue operation for a persisted assignment; this recovery does not prove delivery.

```yaml
risk: security-sensitive-digital-card-issuance
approval_required:
  create_update_delete: true
  generate_install_package: true
physical_operation:
  certificate_crud_and_package_generation: false
  room_or_lock_user_assignment: true
idempotency:
  get_list: safe
  package_generation: safe-to-repeat-but-each-artifact-is-fresh
  create: not-idempotent
  update: idempotent-for-the-same-complete-body
  delete: verify-before-retry-after-an-ambiguous-response
automatic_retry:
  get_list: allowed
  mutations: forbidden
  package_generation: allowed-on-503-with-bounded-backoff
  assignment: forbidden; verify stored assignment and lock slot first
verification:
  - GET /api/wallet-certificates/{id} verifies stored metadata only
  - a 200 package response proves generation only, not phone installation
  - verify installation in the phone wallet UI
  - GET /api/lock-users/{id} or GET /api/bookings/rooms/{roomId}/wallet-certificates verifies the stored assignment
  - GET /api/smartlocks/{lockId}/codes/slots verifies slot upload state
  - GET /api/events action 1028/1029 with credentialType apple_wallet or android_wallet verifies delivery processing
  - assignment response means stored and queued, not uploaded to the physical lock
```

### 12.3 Configure a lock

```http
PUT /api/smartlocks/{lockId}/configuration
Content-Type: application/json
```

```json
{
  "soundLevel": "low",
  "systemCode": "123456",
  "setTime": true,
  "lightEnabled": false,
  "openSeconds": 6
}
```

`soundLevel` accepts `off`, `low`, `medium`, or `high`; omitted or unknown stored values default to
`low`. The legacy `soundEnabled` boolean remains accepted for older clients (`false` maps to `off`,
`true` maps to `low`). `systemCode` must contain exactly six decimal digits. Treat it like an access
credential: never log, echo, or expose it unnecessarily. On node types 7, 9, and 10, enter the
physical system menu with `#<systemCode>#` and exit it with `*`. Inside the menu, `1` unlocks, `2`
locks, and `3` erases all local access codes and clears the saved advertising profile.
Option `4` restarts the device.
The lock LED blinks continuously while the menu remains active.
Option `5` cycles node 10 sound through `off`, `low`, `medium`, and `high`. This
is a runtime test setting confirmed with an LED blink; the backend-configured
sound level is restored by the next boot/configuration delivery.

Action-27/sub-action-0 payload format version 3 contains 14 meaningful bytes followed by zero
padding to 201 bytes. Offsets 0-3 contain the unsigned Unix timestamp in little-endian order;
offset 4 is version `3`; offset 5 is sound level (`0=off`, `1=low`, `2=medium`, `3=high`); offsets
6-11 are the six ASCII system-code digits or six zero bytes; offset 12 is update-time (`0=false`,
`1=true`); and offset 13 is NFC (`0=off`, `1=on`). When a device boots and requests a time anchor,
the backend responds with the current Unix time, tenant-wide `soundLevel`, `systemCode`, and
`nfcEnabled` settings, and update-time set to `true`.

Configure the tenant-wide values with the existing tenant update operation:

```http
PUT /api/tenants
Authorization: Bearer <token>
Tenant: <TENANT_ID>
Content-Type: application/json
```

```json
{
  "id": "<TENANT_ID>",
  "name": "Example property",
  "timezone": "Europe/Copenhagen",
  "soundLevel": "low",
  "systemCode": "123456",
  "nfcEnabled": false
}
```

The tenant update replaces the persisted tenant document, so first read the tenant with
`GET /api/tenants`, preserve fields that are not being changed, and then submit the complete tenant
object. The tenant ID is part of that resource body; it is not an endpoint argument and must match
a tenant available to the authenticated user. `soundLevel`
accepts `off`, `low`, `medium`, or `high`. At boot-message encoding time, a missing or unrecognized
value resolves to `low`. `systemCode` must contain exactly six decimal digits. At encoding time, a
missing or invalid value becomes six zero bytes and disables the physical system menu. Never log
the system code or expose it unnecessarily. `nfcEnabled` is tenant-wide and controls the NFC reader
on node type 10 (`false=off`, `true=on`); missing values default to `false`. Device-specific NFC
configuration is not currently supported. The successful response is the saved tenant object.

After a successful tenant save, the backend compares the effective wire values for `soundLevel` and
`systemCode`, and `nfcEnabled` with the previously stored tenant. It queues an action-27 message for
every tenant device only when any effective value changed. Changes to unrelated tenant fields do not queue
device messages. Normalization is applied before comparison: missing or unknown sound values are
equivalent to `low`, missing or invalid system codes are equivalent to six zero bytes, and missing
NFC values are equivalent to `false`.

Each queued message contains the newly saved sound, system-menu, and NFC settings but sets the update-time
flag to `false`, so the device applies configuration without replacing its current time anchor. A
new configuration save replaces any already-pending action-27 message for the same device, ensuring
the newest settings win. Delivery is asynchronous: the successful tenant response means the
settings were saved and, when applicable, messages were queued; it does not prove that every
physical device applied them.

Use `GET /api/tenants` to verify the saved tenant settings. Updating tenant settings is reversible
but security-sensitive because the system code controls a physical device menu; require approval
and do not retry after an ambiguous response until the current tenant value has been read back.
Confirm the target tenant before changing the configuration and verify subsequent queue delivery
and physical sound or system-menu behavior.

```yaml
risk: configuration-changing
approval_required: true
idempotent: false
automatic_retry: forbidden-after-ambiguous-response
verification:
  - GET /api/tenants and confirm the tenant-wide soundLevel
  - GET /api/tenants and confirm the tenant-wide nfcEnabled value
  - inspect the device queue
  - confirm delivery or acknowledgement
  - test keypad sound on the physical lock
  - confirm the node 10 NFC reader state locally when nfcEnabled changes
  - confirm system-menu entry and exit locally when the system code changes
```

## 13. RELAY RECIPES

```text
POST /api/relay/{relayId}/open
POST /api/relay/{relayId}/open-until-closed
POST /api/relay/{relayId}/close
POST /api/relay/{relayId}/pulse/ms/{milliseconds}
POST /api/relay/{relayId}/pulse/seconds/{seconds}
POST /api/relay/{relayId}/pulse/minutes/{minutes}
POST /api/relay/{relayId}/pulse-open
```

```yaml
risk: physical-or-operationally-dangerous
approval_required: true
idempotent: false
mandatory_preconditions:
  - relay real-world purpose known
  - safe duration known
  - target device and tenant confirmed
warning: relay may operate a door, heater, motor, appliance, or alarm interface
automatic_retry: forbidden
```

Generate a package for direct delivery to a relay (for example, by a mobile app over BLE):

```http
POST /api/relay/{relayId}/package
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
```

Persistent open and close:

```json
{ "operation": "open" }
```

```json
{ "operation": "close" }
```

Timed pulse (unit must be `milliseconds`, `seconds`, or `minutes`):

```json
{
  "operation": "pulse",
  "value": 5,
  "unit": "seconds"
}
```

Consumption-limited open accepts an energy allowance in kilowatt-hours:

```json
{
  "operation": "consumption",
  "kwh": 1.5
}
```

The backend converts `kwh` to CF pulses using `relay.metering.cf-pulses-per-kwh` (deployment
default: 2,175,856 pulses/kWh), rounds to the nearest whole pulse using half-up rounding, and
rejects results outside the unsigned 32-bit range. This default is derived from the relay's
BL0937B reference circuit: a 1 mOhm shunt, six 200 kOhm high-side divider resistors, a 510 Ohm
low-side resistor, and the nominal 1.1 V reference. For example, `1.5` kWh produces 3,263,784
pulses. The response exposes this as `consumptionPulseCount`; the encoded action-40 data is that
count as four little-endian bytes. `kwh: 0` produces zero pulses, which cancels an active countdown
and closes the relay. A deployment-specific calibrated value may override the nominal default.

A successful response returns the selected protocol `action` and `subAction`, a generated
`messageId`, and the complete node-core package as both `packageBase64` and `packageHex`.
Decode exactly one representation and deliver those bytes unchanged. The endpoint only creates
the package: it does not queue, deliver, or execute it, and HTTP 200 does not confirm physical
completion.

```yaml
risk: physical-or-operationally-dangerous
approval_required: true
mandatory_preconditions:
  - relay belongs to the authenticated tenant
  - relay real-world purpose is known
  - target device, operation, and bounds are explicitly confirmed
  - a direct transport to the intended relay is available
idempotent: false
automatic_retry_generation: allowed only before any delivery attempt
automatic_retry_delivery: forbidden
verification:
  - verify transport-level delivery
  - verify device acknowledgement or current relay state
  - do not expect a gateway queue entry or backend event from package generation
errors:
  - 400 for a non-relay sensor, unknown operation, missing pulse value/unit, missing or non-finite kwh, invalid calibration, or an out-of-range calculated pulse count
  - 404 when the sensor does not exist in the tenant
```

Set default pulse duration:

```http
PUT /api/relay/{relayId}/default-milliseconds
Content-Type: application/json
```

```json
{ "openMilliseconds": 150 }
```

### 13.1 Scheduled lock automation

Lock automation runs open, close, and pulse-open on a recurring schedule across locks, lock
controllers, and relays. A task names one trigger, one schedule, and one or more devices.

```text
GET    /api/automation/locks/triggers/config
GET    /api/automation/locks/tasks
POST   /api/automation/locks/tasks
PUT    /api/automation/locks/tasks/{taskId}
DELETE /api/automation/locks/tasks/{taskId}
```

All five operations require a superuser token; a non-superuser receives 403. The tenant must also
have the **Lock Automation** module enabled under **Settings → Modules** (`automation_lock` in
`/api/modules/settings`). Task management stays available while the module is off, but no task
executes. Disabling the module is the correct way to suspend all scheduled physical operations
without deleting configuration.

Supported triggers and their effect per device family:

| Trigger | Locks and lock controllers | Relays |
|---|---|---|
| `open` | unlocks and stays open | closes the circuit and stays closed until a `close` runs |
| `close` | locks | opens the circuit |
| `pulse_open` | short open pulse, closes by itself | pulse for the relay's configured `relay_open_ms` |

Create a task that unlocks the main entrance every weekday at 08:00:

```http
POST /api/automation/locks/tasks HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
```

```json
{
  "name": "Unlock main entrance",
  "comment": "Opens the front door for the morning shift",
  "trigger": "open",
  "deviceIds": ["A1B2C3D4E5F60708"],
  "schedule": {
    "frequency": "WEEKLY",
    "interval": 1,
    "byWeekdays": ["MON", "TUE", "WED", "THU", "FRI"],
    "timeOfDay": "08:00",
    "startDate": "2026-09-01",
    "timeZone": "Europe/Oslo"
  }
}
```

A task carries exactly one trigger. Locking again at 16:00 is a second task with
`"trigger": "close"` and `"timeOfDay": "16:00"`.

Schedule fields:

- `frequency` — `ONCE`, `DAILY`, `WEEKLY`, or `MONTHLY`.
- `interval` — repeat every N days, weeks, or months, counted from `startDate`. Defaults to 1.
- `byWeekdays` — required for `WEEKLY`. Weekday names such as `MON` or `MONDAY`.
- `byMonthDays` — required for `MONTHLY`. Days 1-31; a day that does not exist in a month is
  skipped for that month.
- `timeOfDay` — required, 24-hour `HH:mm`.
- `startDate` — required, `YYYY-MM-DD`. Recurrence intervals are counted from this date.
- `endDate` — optional, `YYYY-MM-DD`. Omit for an open-ended schedule.
- `timeZone` — optional IANA identifier, defaulting to the tenant time zone.

Schedules are evaluated in local time, so a task stays at its local clock time across daylight
saving transitions.

Execution semantics an agent must account for:

- The scheduler polls once a minute, so a trigger fires within roughly a minute of its local time.
  Treat the scheduled time as approximate.
- An occurrence executes at most once. `lastFiredOccurrence` on the task holds the local date-time
  of the most recent executed occurrence, and `lastFiredAt` the server time it ran.
- Occurrences missed while the backend was not running are skipped, not replayed, once they fall
  outside the catch-up window (`lock.automation.catch-up-window-minutes`, deployment default 10
  minutes). A task that did not fire is expected behavior after an outage, not an error to retry.
- Each device is dispatched independently. One unreachable device does not stop the others.
- Deleting a task stops future occurrences but does not recall commands already queued to a gateway.

Every executed and failed dispatch is written to the event log per device, so delivery is verified
through `/api/events` in the same way as a manual lock or relay command. A queued command does not
prove physical completion.

`PUT` replaces the name, comment, trigger, devices, schedule, and enabled state, so send the whole
task rather than a partial one. `userId` and `createdDate` are preserved, and the already-fired
marker is kept so an occurrence that already ran does not run again after an edit. Set `enabled` to
false to suspend a single task while keeping its configuration.

```yaml
risk: physical-or-operationally-dangerous
approval_required: true
authorization: superuser only
mandatory_preconditions:
  - every device's real-world purpose is known
  - unattended operation at the scheduled times is safe and intended
  - target devices, trigger, schedule, and time zone are explicitly confirmed
warning: a task repeatedly opens or closes physical access without an operator present
idempotent:
  create: false
  update: true
  delete: true
automatic_retry:
  create: forbidden-after-ambiguous-response
  update: allowed after re-reading the task
  delete: allowed
verification:
  - GET /api/automation/locks/tasks and confirm the stored schedule, trigger, and devices
  - confirm the Lock Automation module is enabled in /api/modules/settings
  - after a scheduled time, read lastFiredOccurrence and lastFiredAt
  - inspect /api/events per device for the executed or failed operation
  - confirm physical device state
errors:
  - 400 for a missing name, unknown trigger, empty device list, a device that is not a lock, lock controller, or relay, or an invalid schedule
  - 403 when the token is not a superuser
  - 404 when the task does not exist in the tenant
```

## 14. THERMOSTAT AND TEMPERATURE RECIPES

```http
POST /api/thermostat/{thermostatId}/target-temperature
Content-Type: application/json
```

```json
{
  "temperature": 21.0,
  "gw_id": "<OPTIONAL_GATEWAY_ID>"
}
```

```text
POST /api/thermostat/{thermostatId}/on
POST /api/thermostat/{thermostatId}/off
POST /api/thermostat/{thermostatId}/restart
POST /api/thermostat/{thermostatId}/wifi
POST /api/thermostat/{thermostatId}/delete-wifi
GET  /api/temperature-control/stats
GET  /api/temperature-control/history/{sensorId}
```

Wi-Fi credentials are secrets. Never print or persist them outside the intended secret store.

## 15. DEVICE PAIRING

```http
PUT /api/sensor/{sensorId}/pairings
Content-Type: application/json
```

```json
{
  "sensorIds": [
    "<PAIRED_DEVICE_ID_1>",
    "<PAIRED_DEVICE_ID_2>"
  ]
}
```

```yaml
read_current: GET /api/sensor/{sensorId}/pairings
fetch_from_hardware: POST /api/sensor/{sensorId}/pairings/fetch
risk: configuration-changing
approval_required: true
verification: compare stored and fetched pairings
```

## 16. FIRMWARE LIFECYCLE

```text
GET    /api/node-updates/firmwares
POST   /api/node-updates/sensors/{sensorId}
DELETE /api/node-updates/sensors/{sensorId}
DELETE /api/node-updates/sensors/{sensorId}/queue
```

Start payload:

```json
{ "version": "<AVAILABLE_VERSION>" }
```

```yaml
risk: operationally-dangerous
approval_required: true
automatic_retry: forbidden
preconditions:
  - version exists in firmware catalog as a complete four-file bundle
  - device compatibility confirmed
  - stable power confirmed
  - connectivity confirmed
  - maintenance window confirmed
  - rollback expectations understood
  - for a node known to hold a trust anchor, the adjacent offline-signed manifest and signature verify against the adjacent per-release signer certificate
signed_package_activation:
  applies_when: the backend security record indicates that the node holds a trust anchor
  firmware_catalog_bundle:
    - /firmware/nrf_node-<version>.bin
    - /firmware/nrf_node-<version>.manifest.bin
    - /firmware/nrf_node-<version>.manifest.sig
    - /firmware/nrf_node-<version>.signer.der
  catalog_visibility: a firmware image is listed only when all three adjacent metadata files exist
  release_signing:
    location: offline trusted operator computer
    backend_private_key_required: false
    backend_behavior:
      - load the pre-signed manifest, signature, and public signer certificate without modifying them
      - verify image digest, exact size, filename version, target node type, manifest format, and algorithms
      - validate the per-release signer certificate against the backend intermediate and verify the manifest signature
      - send the exact verified manifest, signature, and signer certificate to the node
  manifest_binds:
    - SHA-256 of the exact firmware image
    - exact image size
    - firmware version
    - target node type, or the protocol's explicit all-node value when type is unavailable
  signature: RSA-2048 PKCS#1 v1.5 with SHA-256
  certificate_chain:
    - the package includes a CA-false code-signing leaf with digitalSignature and codeSigning usage
    - the package includes the issuing intermediate
    - the node validates the chain against its existing stored root certificate
  activation_gate:
    - the node hashes the completed staged flash image
    - the staged digest must match START, FINALIZE, and the signed manifest
    - manifest format, image size, firmware version, target node type, and flags must be valid
    - certificate roles, chain, and manifest signature must verify
    - only then may the node mark the MCUboot slot for test boot
  transport: application-layer encryption is preferred when a session is active, but release-signature verification is independent of transport encryption
  backend_startup: no global flash-signing certificate or offline private key is required
  secure_ota_availability: the public signer certificate is supplied by each complete release bundle
  failure: a missing bundle file, invalid metadata, public certificate, chain, signature, or digest fails closed without activating flash or falling back to unsigned OTA
  rootless_recovery: nodes without a trust anchor retain the legacy unsigned OTA path
smart_lock_code_storage_transition:
  boundary_firmware_version: 111
  upgrade_from_legacy_to_111_or_newer:
    - code slots 1 through 987 migrate automatically
    - codes in slots 988 through 1980 are not retained and must be pushed again
  downgrade_from_111_or_newer_to_legacy:
    - legacy firmware treats the version-6 code store as empty
    - every access code must be pushed again after the downgrade
    - saving codes with legacy firmware can overwrite the firmware-111 certificate and session storage
    - after the approved secure-session retirement, device communication uses the legacy plaintext protocol
  required_action: warn the operator and plan code resynchronization before starting the update
queue_isolation:
  - while a node OTA update is active, the backend sends only OTA chunk and OTA-completion packages to that node
  - regular packages already queued for the node remain queued and resume after the OTA update finishes or is aborted
  - new non-OTA packages for the node are rejected before they enter the outbound queue, including time-setting and security-handshake packages
  - only firmware actions 21, 22, and 23 may be newly queued for the reserved node
  - OTA packages take precedence over earlier regular packages for the same node
  - packages for other nodes are unaffected
delivery_confirmation:
  - unencrypted and encrypted OTA use the node's positive six-byte BLE acknowledgement relayed by the gateway as status 2 for the exact message ID
  - that status-2 acknowledgement removes the exact queued chunk, persists its registered progress, and refills the OTA window
  - encryption applies to the OTA command payload; the node-to-gateway BLE acknowledgement intentionally remains unencrypted
  - for a trusted node, transport delivery does not authorize flash activation; the release signature and certificate chain are verified on-device at FINALIZE
  - an authenticated Action 46 result is an audit signal and idempotent delivery fallback, not a prerequisite for OTA progress
  - exception for a secure-to-legacy downgrade: status 2 advances OTA transport progress but cannot retire the active secure session
  - the old secure session is retired only when Action 46 authenticates the exact secure finalize message, or after positive finalize transport delivery when the node advertises the exact legacy firmware version recorded by the approved downgrade request
  - a missing encryption advertisement flag, a different legacy version, or an unsolicited legacy advertisement never authorizes plaintext fallback
  - up to eight OTA frames may be queued; a transport-confirmed frame frees its window position, but the backend enforces at least 250 ms before dispatching the next OTA frame to that tenant-scoped node
  - if the transport acknowledgement is absent, the same logical chunk remains queued for bounded internal retry; callers must not start a second update
  - node-originated encrypted responses retire from the node queue on the gateway transport acknowledgement and do not require a backend Action 46 command during OTA isolation
recovery:
  - an authenticated BAD_STATE for an encrypted chunk or finalize means the node lost its volatile OTA start state
  - the backend discards the current OTA window and restarts the same transfer from its authenticated START package
secure_to_legacy_downgrade:
  applies_when: a node with an active secure session is explicitly updated to firmware below version 111
  authorization: the requested target version is persisted before secure OTA packages are queued
  transport: the image, digest, chunks and finalize package remain authenticated by the existing secure session, and flash activation independently requires a release signature chaining to the node's stored root
  transition:
    - the gateway status-2 receipt alone does not retire the session
    - an authenticated Action 46 acknowledgement for the exact finalize message retires the old key
    - after positive secure-finalize transport delivery, a post-reboot advertisement retires the old key only when its firmware version exactly matches the persisted downgrade target
    - after retirement, queued logical commands resume through the legacy plaintext node protocol
  abort: clears the persisted downgrade authorization and preserves the active secure session
  warning: legacy node communication is not protected by the version-111 application-layer encryption protocol
certificate_rotation:
  availability: disabled by default; these maintenance endpoints are not registered and return 404 unless an operator explicitly starts the backend with solvotix.security.rotation-api.enabled=true
  default_enabled: false
  re_enable: set solvotix.security.rotation-api.enabled=true and restart the backend; enable only for an approved maintenance window, then disable and restart afterward
  endpoints:
    operational: POST /api/node-security/sensors/{sensorId}/operational-certificate-rotation
    session_key: POST /api/node-security/sensors/{sensorId}/session-key-rotation
    root: POST /api/node-security/sensors/{sensorId}/root-certificate-rotation
  authentication: bearer token plus tenant context supplied by the authentication filter
  operational_request_body: none
  root_request_body:
    certificatePem: exactly one PEM-encoded CA transition certificate containing the new root public key, signed by the currently trusted root; never a private key
  response: 202 with sensorId, gatewayId, queuedMessages, generation, fingerprint, and status=queued
  risk: security-sensitive
  approval_required: true
  preconditions:
    - sensor belongs to the authenticated tenant
    - node has an active authenticated session
    - the node has a known gateway route; delivery may wait in the persistent queue while that gateway changes live readiness state
    - no certificate rotation is already pending
    - backend has restarted after loading the intended generation certificate and matching private key
  errors:
    - 400 when the sensor ID is invalid or not found in the tenant
    - 409 when the node is not secure, has no known gateway route, is already pending rotation, has active OTA, or a queue fragment is rejected
    - 500 when configured certificate material cannot be encoded
  transport: action 47 rotation fragments require an active authenticated node session
  fragment_payload: maximum 177 DER bytes after the eight-byte header; remaining 16 data bytes carry the AEAD tag
  generation:
    source: non-zero unsigned 32-bit certificate serial signed by the issuing CA
    rule: a replacement generation must be greater than the persisted generation
    issuance: use deliberate serials 1, 2, 3 and not default wide random serials
  queue_confirmation:
    - every authenticated rotation fragment is retired by its exact Action 46 acknowledgement
    - fragment acknowledgement proves processing only, not certificate installation
  installation_confirmation:
    action: 47
    sub_action: 4
    payload: version, role, status, generation LE32, and installed SHA-256 fingerprint
    acceptance: backend matches role, generation, and fingerprint against the persisted pending transaction
    mismatch: retain pending state, record a security error, and do not report completion
    operational_fallback: an exact pending operational generation and fingerprint may also complete after the replacement session passes mutual challenge confirmation, because that exchange proves the node installed the matching operational public key
  rollback_protection:
    - older generations are rejected
    - equal generations are idempotent only for the identical persisted fingerprint
    - the first valid OTA signer is pinned; a different signer requires a higher signed generation
  operational_rotation:
    - send the issuing intermediate and operational certificate as one tracked transaction
    - after persistence the node starts fresh enrollment using the new operational public key
  session_key_rotation:
    - reassert the identical pinned intermediate and operational certificate through the operational rotation transaction
    - returns 409 if the node's recorded operational fingerprint differs from the backend's configured certificate; rotate the operational certificate first
    - node generates fresh AES-256 session material and encrypts it to the operational public key
    - old session remains authoritative until the pending session passes mutual challenge confirmation and is promoted atomically
    - if the node promotes the pending key but its final confirmation is lost, repeating this endpoint queues only a fresh pending-key confirmation with a new sequence; it does not restart certificate rotation
    - updated nodes answer a repeated valid backend confirmation for their active key idempotently, including after reboot, allowing the backend to promote the same pending key
    - verify completion by observing DeviceSecurity.sessionGeneration increase; a cleared certificate rotationState alone proves certificate processing, not completion of the subsequent key exchange
  root_rotation:
    - create the transition certificate offline while the current root signing key remains available
    - the backend verifies its signature with the current root, CA:TRUE role, size, and generation before queueing
    - after root confirmation, install a new intermediate and operational chain under that root and invoke operational rotation
  verification:
    - 202 means queued, not installed
    - inspect the node queue and DeviceSecurity rotationState after submission
    - completion requires rotationState to clear after the exact authenticated installation result
  retry: an explicitly repeated certificate request is idempotent only when role, generation, and fingerprint exactly match the pending transaction; repeating session-key rotation while its replacement session is pending resumes mutual confirmation; conflicting rotation requests return 409 and external agents must not retry automatically
cancellation:
  endpoint: DELETE /api/node-updates/sensors/{sensorId}
  tenant_context: required from the authenticated request
  approval_required: true
  response: 200 with the number of OTA queue packages removed
  idempotency: safe to retry; returns 200 with 0 when already clean
  effect:
    - cancel the backend OTA session
    - delete in-memory and persisted OTA queue packages for the sensor
    - clear in-flight delivery and chunk-progress tracking
    - clear any pending secure-to-legacy downgrade authorization without deleting the active secure session
    - reset the sensor update flag and progress
  authority: this is the only operation that cancels an OTA update; successful finalize completes it normally
  concurrent_start: POST returns 409 without modifying the existing update
  generic_queue_delete: DELETE /api/node-updates/sensors/{sensorId}/queue returns 409 without changing the queue while OTA state or packages exist
```

## 17. QUEUE AND EVENT VERIFICATION

```yaml
queue_endpoints:
  tenant: GET /api/gateways/getqueue
  device: GET /api/gateways/queue/device/{deviceId}
  device_transfer_packages: GET /api/gateways/queue/device/{deviceId}/packages
  gateway: GET /api/gateways/{gatewayId}/gwqueue
event_endpoints:
  tenant: GET /api/events
  device: GET /api/events/sensor/{sensorId}
  room: GET /api/events/byRoom/{roomId}
```

Booking update events use `action: 1034`. Inspect `data.state` instead of inferring a
transition from the booking snapshot: `checked_out` means the booking changed from not checked
out to checked out, while `updated` means another field changed and may still contain
`checkedOut: "true"` as the current state. Treat only `data.state: "checked_out"` as a checkout
event. Event reads are safe and require the same bearer authentication and tenant context as the
other tenant-scoped API operations.

### 17.0.1 Event action registry

Events have two action namespaces. Values `0` through `40` come from the device protocol. Values
`1024` through `1037` are server-generated application events. Do not compare a `sub_action`
without first checking its parent `action`.

| Action | Device-protocol meaning | Action | Device-protocol meaning |
|---:|---|---:|---|
| 0 | gateway online | 1 | button pressed |
| 2 | passive alarm triggered | 3 | active alarm triggered |
| 4 | temperature high | 5 | unusual motion |
| 6 | battery low | 7 | power surge |
| 8 | water leakage | 9 | unauthorized access |
| 10 | door left open | 11 | ventilation anomaly |
| 12 | freezing temperature | 13 | high air pressure |
| 14 | temperature too low | 15 | device online |
| 16 | device offline | 17 | gateway status |
| 18 | test | 19 | alarm cleared |
| 20 | update advertising profile | 21 | firmware update |
| 22 | firmware update chunk | 23 | firmware update chunk complete |
| 24 | restart node | 25 | new node |
| 26 | ask for time | 27 | set time |
| 28 | relay pulse, milliseconds | 29 | relay pulse, seconds |
| 30 | relay open | 31 | relay close |
| 32 | relay pulse, minutes | 33 | lock operation |
| 34 | wall thermostat operation | 35 | restart |
| 36 | restart mode on | 37 | restart mode off |
| 38 | gateway ping with status | 39 | gateway metering data |
| 40 | relay pulse count | | |

| Action | Application event | `data.state` | Important data |
|---:|---|---|---|
| 1024 | room marked cleaned | `cleaned` | `entityId`, optional `entityName`, `actorUserId` |
| 1025 | room marked dirty | `dirty` | `entityId`, optional `entityName`, `actorUserId` |
| 1026 | gateway marked online | `online` | `entityId`, `actorUserId` |
| 1027 | gateway marked offline | `offline` | `entityId`, `actorUserId` |
| 1028 | smart-lock code added | `added` | device identity, sensitive `code`, `actorUserId` |
| 1029 | smart-lock code removed | `removed` | device identity, sensitive `code`, `actorUserId` |
| 1030 | booking message sent | not set | booking/room, channels, recipient and message fields |
| 1031 | sensor restart mode enabled | `on` | device identity, `actorUserId` |
| 1032 | sensor restart mode disabled | `off` | device identity, `actorUserId` |
| 1033 | booking created | `created` | booking snapshot |
| 1034 | booking updated | `updated` or `checked_out` | booking snapshot; only `checked_out` is a checkout transition |
| 1035 | booking deleted | `deleted` | final booking snapshot |
| 1036 | lock automation command accepted | `queued` | `taskId`, `taskName`, `trigger`, `occurrence` |
| 1037 | lock automation failed | producer status or `failed: ...` | task and occurrence details |

Action-specific sub-actions:

| Parent action | Sub-action values |
|---:|---|
| 33, lock operation | 1 pulse open; 2 open; 3 close; 4 add codes; 5 remove codes; 6 set configuration; 7 delete all codes; 8 valid legacy PIN; 9 invalid legacy PIN; 10 pair devices; 11 fetch paired devices; 12 valid extended credential; 13 invalid extended credential; 14 add extended credential |
| 34, wall thermostat | 1 set Wi-Fi; 2 set target temperature; 3 turn on; 4 turn off; 5 restart; 6 delete Wi-Fi; 7 receive debug data |
| 39, gateway metering | 12 five-minute; 13 hourly; 14 total; 15 consumption changed |

Extended credential result events (action 33 with sub-action 12 or 13) expose `slot`, `valid`,
`state`, `factorCount`, `credentialType`, and `credentialId`. MIFARE factors additionally expose
`mifareUid`; wallet factors expose `walletPlatform`; PIN factors use `code`. A rejected credential
uses slot `0`. Credential identifiers and PINs are security-sensitive.

Common structured `data` keys are `entityType`, `entityId`, `entityName`, `state`,
`actorUserId`, `roomId`, `bookingId`, `code`, `slot`, `valid`, `factorCount`, `credentialType`,
`credentialId`, `mifareUid`, `walletPlatform`, `triggerId`, `taskId`, `taskName`, `trigger`,
`occurrence`, `channels`, `message`, `emailMessage`, `smsMessage`, `recipient`, `recipientEmail`,
and `recipientPhone`. Keys are event-specific and may be absent. Access codes, recipient details,
and message bodies are sensitive and must not be exposed outside their authorized purpose.

Example checkout transition:

```json
{
  "action": 1034,
  "sub_action": null,
  "roomId": "room-101",
  "data": {
    "entityType": "booking",
    "entityId": "booking-123",
    "bookingId": "booking-123",
    "roomId": "room-101",
    "state": "checked_out",
    "checkedOut": "true",
    "actorUserId": "system"
  }
}
```

```yaml
authentication: "Authorization: Bearer sat_<TOKEN>"
tenant_context: "Tenant: <TENANT_ID> is required and is resolved by the authentication filter"
preconditions:
  - use an event endpoint appropriate to the tenant, device, or room
  - provide valid inclusive ISO-8601 bounds where required
risk: read-only
approval_required: false
idempotency: safe
retry_policy: retry transient read failures with bounded backoff; do not create conclusions from duplicate records
verification:
  - identify the event by action and structured data
  - interpret sub_action only under its parent action
  - treat queued as accepted, not physically completed
  - corroborate physical operations with later device state or device-originated events
errors:
  - 400 means an invalid identifier or time range
  - 401 or 403 means authentication or authorization failed
  - 404 on a room query means the room was not found
```

Verification algorithm:

```text
1. Capture the returned message ID and target device.
2. Set local status to requested or queued.
3. Inspect device and gateway queue state.
4. Inspect relevant events.
5. Re-read current device state when supported.
6. Report exactly one state:
   requested | queued | delivered | confirmed | failed | unknown
7. Never translate queued into completed.
```

### 17.1 Manually transfer a device queue

```http
GET /api/gateways/queue/device/{deviceId}/packages
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```

The response is an array in outbound queue order. Each entry contains `deviceId`, `queuedAt`,
`messageId`, `action`, `subAction`, `packageBase64`, and `packageHex`. The package fields encode
the same complete 212-byte node-core message. Decode exactly one representation and deliver the
bytes unchanged through the device's direct transport.

This endpoint is read-only and tenant-scoped. It preserves the queued message ID, timestamp,
payload, and checksum, and does not mark, remove, or acknowledge a message. An empty array means
the tenant currently has no pending messages for that device.

```yaml
risk: depends-on-queued-operation
approval_required_before_delivery: inherit from each queued operation
download_idempotent: true
automatic_retry_download: allowed before delivery
automatic_retry_delivery: forbidden
mandatory_preconditions:
  - target device and tenant confirmed
  - queued action and sub-action understood
  - direct transport reaches the intended device
delivery_order: preserve response order
sensitive_data:
  - packages may contain credentials, access codes, or configuration
  - never log or persist decoded payloads outside approved secure storage
verification:
  - require device or transport acknowledgement for each message ID
  - verify resulting device state when supported
  - HTTP 200 confirms download only, never delivery or physical completion
queue_removal:
  endpoint: DELETE /api/gateways/queue/message/{messageId}
  precondition: remove only after positive device acknowledgement for that message ID
  automatic_removal_on_download: false
errors:
  - 500 when a queued node-core message cannot be serialized
```

### 17.2 Node-core package format

Lock packages, relay packages, and downloaded queue packages use the same fixed 212-byte message:

| Offset | Length | Field | Encoding |
|---:|---:|---|---|
| 0 | 6 | message ID | raw bytes; displayed as 12 uppercase hex characters |
| 6 | 2 | timestamp | unsigned 16-bit Unix-seconds value, little-endian |
| 8 | 1 | action | unsigned protocol action |
| 9 | 1 | sub-action | unsigned protocol sub-action |
| 10 | 201 | data | operation-specific bytes followed by zero padding |
| 211 | 1 | checksum | XOR of bytes 0 through 210 |

The timestamp contains the low 16 bits of Unix time and therefore wraps. Do not interpret it as
a standalone wall-clock timestamp. `packageBase64` and `packageHex` encode the entire message,
including its checksum; clients must decode one representation and must not recalculate, replace,
or otherwise modify fields in a downloaded queued package.

The node-core package is not itself a complete BLE transport specification. Service UUIDs,
characteristics, MTU negotiation, chunk framing, write mode, timeouts, and acknowledgement frames
must come from the supported device transport library or firmware protocol for the target device.
Do not guess these values. If no supported transport implementation is available, stop before
delivery.

Queue downloads can contain any queued device command, including security-sensitive configuration,
access-code payloads, restart operations, pairing operations, or firmware traffic. Inspect `action`
and `subAction`, determine that the target transport supports that operation, and apply the original
operation's approval policy. Downloading does not reserve a queue entry or suspend gateway delivery;
avoid manual transfer while a gateway can concurrently deliver the same command.

### 17.3 Tested mobile BLE discovery and delivery

This section defines the tested direct-delivery implementation for Solvotix node-core devices. It
applies when a mobile application must find a nearby device and deliver a package returned by the
relay package endpoint or manual queue-transfer endpoint.

```yaml
advertisement:
  expected_local_name: SVN
  solvotix_manufacturer_id: 0x79fd
  local_name_reliable_on_android: false
node_core_gatt:
  service_uuid: 12345678-1234-5678-1234-56789abcdef0
  write_characteristic_uuid: 12345678-1234-5678-1234-5678efbeadde
  package_bytes: 212
  preferred_write_mode: write-without-response
  fallback_write_mode: write-with-response-when-characteristic-requires-it
  maximum_chunk_bytes: 215
```

Do not use the backend `active`, online, or offline field to determine physical proximity. That
field describes backend communication state, not whether the phone currently receives the device's
BLE advertisement.

Discovery algorithm:

```text
1. Load the tenant's sensor inventory and normalize every sensor UUID to lowercase hexadecimal
   without separators.
2. Initialize BLE and obtain the platform scan and connect permissions.
3. While the device screen is visible, run a low-latency scan with duplicate advertisements enabled.
4. Use one application-wide scan coordinator. Starting a scan must stop and replace the previous
   native scan owner because mobile BLE plugins commonly expose one global scan operation.
5. Read local name and manufacturer data from every advertisement.
6. Accept the exact local name SVN, but never require it: Android may omit the name.
7. Identify a Solvotix advertisement by manufacturer ID 0x79fd or by a manufacturer-derived UUID
   that matches the authenticated tenant's sensor inventory.
8. Construct the primary 8-byte sensor identifier as the manufacturer ID in little-endian byte
   order followed by the first six manufacturer payload bytes. Also tolerate stacks that expose
   the complete identifier as payload bytes 0..7 or 2..9.
9. Mark an inventory device nearby only when its normalized UUID matches a current advertisement.
10. Refresh last-seen time for duplicate advertisements and remove nearby state after 10 seconds
    without an advertisement.
11. Renew a long-running scan periodically and retry a failed scan after a short delay.
12. Stop scanning before connecting. Resume scanning after disconnect while the screen remains
    visible.
```

Platform permissions:

```yaml
android:
  runtime:
    - Bluetooth scan permission
    - Bluetooth connect permission
  behavior:
    - request Bluetooth enablement when disabled
    - do not assume localName or device.name is present
ios:
  configuration:
    - provide the Bluetooth usage description required by the current iOS SDK
  behavior:
    - initialize Bluetooth only in response to an application flow that needs it
```

Direct-delivery algorithm:

```text
1. Confirm that the target inventory UUID currently maps to a nearby BLE device ID.
2. Obtain explicit approval for the physical operation when required by its risk class.
3. Display a blocking communication overlay before requesting or decoding the package. Keep it
   visible through connection, discovery, all writes, and disconnection.
4. Decode exactly one backend package representation. Require exactly 212 bytes and do not alter
   the package.
5. Stop the active scan and disconnect any stale connection for the target BLE device ID.
6. Connect and discover the specified node-core service and write characteristic. Do not select an
   unrelated writable characteristic as a substitute.
7. Read the negotiated MTU when supported. Use chunk size max(20, min(215, MTU - 3)); when MTU is
   unavailable, assume MTU 23 and send 20-byte chunks.
8. Write chunks sequentially. Prefer write-without-response when advertised; otherwise use
   write-with-response. Do not automatically retry after any chunk delivery attempt.
9. Treat completion of all BLE writes as transport completion, not proof that the physical action
   occurred. Use a device acknowledgement or observed current state when the firmware exposes one.
10. Disconnect in a finally/finalization path, report success or failure in the overlay, and restart
    continuous scanning after a short settling interval.
```

For manual queue delivery, preserve response order and use one connection/write lifecycle per
package unless the supported firmware transport explicitly guarantees multi-package framing. Delete
`DELETE /api/gateways/queue/message/{messageId}` only after the application's required positive
delivery acknowledgement for that exact message. Stop at the first failure; never delete the failed
message or later messages.

Logging must include lifecycle stages and non-sensitive identifiers, but never package bytes,
credentials, access codes, tokens, or decoded package payloads. Useful stages are scan ownership,
known UUID match, connection, characteristic discovery, negotiated MTU, chunk offset and length,
completion, failure, disconnection, and scan restart. Avoid per-advertisement logging; log a nameless
UUID match once per device to diagnose Android discovery without flooding the console.

UI requirements for direct communication:

```yaml
nearby_action_visibility: derived-from-current-ble-advertisement
internet_action_visibility: independent-of-ble-proximity
communication_overlay:
  show_before_async-work: true
  states: [connecting, transferring, success, failure]
queue_prompt:
  condition: queued-messages-and-device-nearby
  text: Do you want to transfer the messages to the device?
```

## 18. PUBLIC API SURFACE CATALOGUE

The production OpenAPI document is authoritative for individual request and response schemas. The
catalogue below identifies the intended runner API families and their integration role. Routes not
listed in the production OpenAPI are not supported merely because similarly named controller code
exists in another service.

### 18.1 Machine-integration API families

All routes below use bearer authentication and tenant context unless their live OpenAPI operation
explicitly says otherwise:

| Base path | Integration purpose | Important mutation semantics |
|---|---|---|
| `/api/gateways` | gateway inventory, node inventory, metering, firmware, gateway relay and queues | commands are asynchronous; verify queue/events/state |
| `/api/sensor` | sensor inventory, generic actions, claiming, pairing and communication history | action support depends on device type |
| `/api/smartlocks` | lock commands, access codes, configuration and direct packages | physical/security-sensitive; no automatic retry |
| `/api/relay` | persistent, timed and consumption-limited relay commands and packages | physical purpose and bounds must be confirmed |
| `/api/thermostat` | thermostat on/off, targets, Wi-Fi and restart | Wi-Fi and restart are operationally dangerous |
| `/api/temperature-control` | temperature statistics and history | read-only |
| `/api/node-updates` | node firmware discovery, start, abort and queue cleanup | maintenance approval required |
| `/api/events` | tenant, device and room event verification | event presence does not always prove physical completion |
| `/api/messages` | recent raw protocol-message history | in-memory, approximately 24-hour retention |
| `/api/bookings` | bookings, rooms, guest messages and room-code lifecycle | room codes are security-sensitive |
| `/api/cleaning` | cleaning rooms, settings and completion state | role-restricted |
| `/api/automation` | available automation definitions | read-only discovery |
| `/api/automation/messages` | automated-message logs and trigger lifecycle | sending side effects and recipient data require approval |
| `/api/automation/locks` | scheduled open/close/pulse tasks for locks, lock controllers and relays | superuser only; schedules unattended physical operations |
| `/api/modules` | tenant module settings | changing modules can alter available workflows |
| `/api/email-branding` | tenant email-branding configuration | validate all public URLs and sender presentation |
| `/api/lock-users` | logical lock-user lifecycle, numeric codes and MIFARE card credentials | security-sensitive; `/api/lockusers` is a compatibility alias |
| `/api/wallet-certificates` | tenant Apple/Android wallet-card metadata and phone-install artifacts | package generation is security-sensitive and does not prove phone installation or physical access |
| `/api/tenants` | tenant lifecycle | create/update are administrative; delete is destructive |
| `/api/users` | human users and notification registrations | account deletion and role changes are security-sensitive |
| `/api/ai` | tenant AI settings and authenticated conversation threads | may process personal data; follow retention policy |

#### 18.1.1 Booking monetary fields

`GET /api/bookings` returns `totalAmount` and `amountPaid` as optional decimal numbers on each
booking. Both values are gross amounts expressed in the booking source system's currency; the
booking object does not currently include a separate currency code. Either value can be `null`
when the booking source does not supply the corresponding monetary data.

For Mews bookings, Solvotix first imports charged payments linked directly to the reservation. If
that total is below the booking total, it also checks charged account-level payments and attributes
one through its bill only when every order item on that bill belongs to the same reservation. A bill
containing order items from multiple reservations is not used for this fallback, because the payment
cannot be assigned to one booking safely. Order items finalized on a closed bill are treated as paid
for booking-settlement decisions. Open bills remain unpaid, including bills owned by a third-party
payer, until they are closed or a charged payment is reported.

The same optional fields are accepted and returned by `POST /api/bookings` and
`PUT /api/bookings/{bookingId}`. These routes require bearer authentication, tenant context from the
`Tenant` header, and the receptionist role. Reads are low-risk and require no approval. Creates and
updates are reversible data mutations and require confirmation of the intended booking. Do not retry
a create automatically after an ambiguous response because it is not declared idempotent; a PUT can
be retried only after checking the current booking list and confirming the target booking ID. Verify
mutations with `GET /api/bookings`. Validation failures return `400`, missing update targets return
`404`, and duplicate creates can return `409`. No command queue or physical operation is involved.

#### Booking arrival timestamp

Booking responses can include the optional `arrivedAt` timestamp. Solvotix sets `arrived=true` and
records `arrivedAt` when the booking's assigned room code is first reported as validly used by a
lock. The lock event's Unix-seconds timestamp is authoritative; server time is used only when that
event timestamp is missing or non-positive. Later uses of the same booking code do not overwrite
`arrivedAt`.

`arrivedAt` is system-owned and is not a planned check-in time. Clients must not derive it from or
write it back into `start`. Booking create and update requests do not provide a supported way to set
the arrival time. A 3RPMS check-in undo resets both `arrived` and `arrivedAt`, allowing a later valid
code-use event to establish a new arrival. Read the booking again with `GET /api/bookings` to verify
the recorded timestamp. The lock event is asynchronous, so absence of `arrivedAt` means arrival has
not yet been recorded; it does not prove the guest has not physically arrived.

#### 18.1.2 Deferring automated guest messages until a room is clean

`POST /api/automation/messages/triggers` and
`PUT /api/automation/messages/triggers/{id}` accept the optional Boolean
`sendOnlyWhenRoomClean`. When it is `true`, `sendToBookingGuest` must also be `true`; otherwise the
request returns `400`. Existing triggers and omitted values default to `false`.

```http
POST /api/automation/messages/triggers HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json

{
  "triggerKey": "on_day_of_arrival",
  "sendToBookingGuest": true,
  "sendSms": true,
  "sendEmail": false,
  "sendOnlyWhenRoomClean": true,
  "smsMessage": "Your room is ready.",
  "timeOfDay": "15:00"
}
```

The successful `200` response is the stored trigger and includes
`sendOnlyWhenRoomClean: true`. After the trigger otherwise becomes eligible, delivery is deferred
while the booking's assigned cleaning-room record is missing, dirty, or belongs to another booking,
unless the booking is already checked in. Check-in overrides the clean-room requirement because the
guest already has access to the room. The integration processor rechecks it approximately every
minute. Once the matching room is clean or the booking is checked in, normal channel delivery and
delivery-log deduplication resume. Deferred work expires with the
trigger's existing eligibility window: arrival/departure day at local midnight, booking-created at
the booking start, start-passed/check-in at booking end, and checkout 48 hours after checkout.

This is a reversible messaging-policy change. Creating or updating a trigger requires approval
because later delivery sends guest communications. Do not automatically retry `POST` after an
ambiguous response. A `PUT` may be retried only after `GET /api/automation/messages/triggers`
confirms the target and current value. Verify configuration with that same GET route. `GET
/api/bookings` exposes the booking-level `messageSent` indication, while `GET
/api/automation/messages/logs/bookings/{bookingId}` exposes successful per-channel delivery logs.
A deferred state is not proof of delivery, and no successful delivery log is written until a channel
succeeds.
Deleting the trigger returns `204`; unknown update/delete targets return `404`. No device command
queue or physical-operation approval is involved.

#### 18.1.3 Deferring automated guest messages until a booking is paid or checked in

`POST /api/automation/messages/triggers` and
`PUT /api/automation/messages/triggers/{id}` accept the optional Boolean
`onlySendIfPaidOrCheckedIn`. When it is `true`, `sendToBookingGuest` must also be `true`; otherwise
the request returns `400`. Existing triggers and omitted values default to `false`. It is
combined with `sendOnlyWhenRoomClean`; when both are `true`, an unchecked-in booking must be both
settled and assigned to a matching clean room. A checked-in booking satisfies both gates regardless
of the recorded cleaning state.

```http
POST /api/automation/messages/triggers HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json

{
  "triggerKey": "on_day_of_arrival",
  "sendToBookingGuest": true,
  "sendSms": true,
  "sendEmail": false,
  "onlySendIfPaidOrCheckedIn": true,
  "smsMessage": "Your access details.",
  "timeOfDay": "15:00"
}
```

The successful `200` response is the stored trigger and includes `onlySendIfPaidOrCheckedIn: true`.
After the trigger otherwise becomes eligible, delivery is deferred until the booking is settled or
the guest is checked in. A booking counts as settled when its `totalAmount` is above zero and its
`amountPaid` is at least `totalAmount` minus a tolerance of `2` in the booking source system's
currency; see 18.1.1 for those fields. A booking whose `totalAmount` is absent or zero is not
treated as settled and waits for check-in. The integration processor rechecks the condition
approximately every minute, and a payment or check-in arriving through a booking update is evaluated
as soon as it is stored. Once released, normal channel delivery and delivery-log deduplication
resume. Deferred work expires with the trigger's existing eligibility window: arrival/departure day
at local midnight, booking-created at the booking start, start-passed/check-in at booking end, and
checkout 48 hours after checkout. A booking that is never settled and never checked in therefore
never receives the message.

This is a reversible messaging-policy change. Creating or updating a trigger requires approval
because later delivery sends guest communications. Do not automatically retry `POST` after an
ambiguous response. A `PUT` may be retried only after `GET /api/automation/messages/triggers`
confirms the target and current value. Verify configuration with that same GET route. A deferred
state is not proof of delivery, and no successful delivery log is written until a channel succeeds;
`GET /api/automation/messages/logs/bookings/{bookingId}` exposes successful per-channel delivery
logs. No device command queue or physical-operation approval is involved.

#### 18.1.4 Read sent-message logs for one booking

Use `GET /api/automation/messages/logs/bookings/{bookingId}` to retrieve successful automated-message
delivery logs for one booking. The operation requires bearer authentication, tenant context from the
`Tenant` header, and the receptionist role. The tenant is resolved by authentication and must not be
placed in the path or query.

```http
GET /api/automation/messages/logs/bookings/booking-123 HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Accept: application/json
```

The response is a JSON array ordered newest first. A record can contain `triggerId`, `bookingId`,
`roomId`, recipient email and phone, `recipientKey`, the tenant-local dispatch date, rendered email
and SMS content, the room code present at dispatch, successfully processed `channels`, and
`createdAt`. An unknown booking ID and a booking with no successful delivery records both return
`200` with an empty array; the endpoint does not disclose whether a booking exists in another
tenant.

This is a read-only, idempotent operation with no queue or physical side effect. No approval is
required to read it, but the response contains personal contact data, message content, and possibly
an access code. Minimize display and retention, never expose it to a booking guest, and do not log the
response. Automatic retry is allowed after a definite transport failure. Verify message delivery by
checking for the expected channel in `channels`; a trigger firing or a missing record is not proof of
delivery. Authentication failures return `401` and insufficient role access returns `403` according
to the shared security filter.

#### 18.1.5 Room-code health

`GET /api/bookings/rooms/{roomId}/codes/health` returns a read-only health table for every stored
room code and every code-capable lock assigned to the room. It requires bearer authentication,
tenant context from the `Tenant` header, and the receptionist role. Access codes and booking IDs are
security-sensitive; do not put the response in logs or analytics.

```http
GET /api/bookings/rooms/room-101/codes/health HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```

```json
{
  "roomId": "room-101",
  "roomName": "Room 101",
  "status": "DEGRADED",
  "allCodesUploadedToAllLocks": false,
  "codeCount": 1,
  "codeCapableLockCount": 2,
  "unresolvedSensorIds": [],
  "codes": [
    {
      "code": "4827",
      "taken": true,
      "takenByBookingId": "booking-123",
      "valid": true,
      "uploadedToAllLocks": false,
      "uploadedLockCount": 1,
      "requiredLockCount": 2,
      "locksMissingCode": ["lock-2"],
      "locks": [
        {"lockId": "lock-1", "lockName": "Front door", "uploaded": true, "codeStatus": "UPLOADED"},
        {"lockId": "lock-2", "lockName": "Side door", "uploaded": false, "codeStatus": "PENDING_UPLOAD"}
      ]
    }
  ]
}
```

A lock is counted as uploaded only when the backend slot state is `ADDED_TO_LOCK` and a positive
delivery acknowledgement has populated `uploadedToLockAt`. Slot states are rendered as `UPLOADED`,
`MISSING`, `PENDING_UPLOAD`, `PENDING_REMOVAL`, `PENDING_CONFIRMATION`, or `UNKNOWN_STATE`.
Non-code-capable room sensors are excluded. Missing assigned sensor records appear in
`unresolvedSensorIds` and prevent an overall healthy result. Overall status is `HEALTHY`,
`DEGRADED`, `NO_CODES`, or `NO_CODE_CAPABLE_LOCKS`.

This endpoint does not refresh a lock, enqueue commands, or repair missing codes. It is idempotent,
low-risk apart from disclosure of access credentials, requires no approval for an authorized health
view, and may be retried after failure. A missing room returns `404`; an empty room ID returns `400`.
Use the same endpoint after an independently approved resync operation to verify convergence. Do not
treat queued or pending upload state as physical completion.

### 18.2 Browser and guest API families

```yaml
browser_session:
  base_path: /api/auth/session
  credential: Firebase ID token exchanged for an HttpOnly cookie
  api_user_token_supported_for_cookie_creation: false
guest_portal:
  base_path: /api/guest-portal
  interactive_authentication: booking-scoped guest context implemented by the portal workflow
  Tenant_header: not a substitute for booking authorization
  warning: may expose room, access, checkout, offer, direction, and guest-AI data
guest_portal_administration:
  base_path: /api/guest-portal-admin
  authentication: bearer plus tenant context
```

Guest checkout uses `POST /api/guest-portal/bookings/{bookingId}/checkout?tenantId={tenantId}`
inside the booking-scoped guest workflow. The request has no body. It requires an existing booking
with a room, an enabled checkout button, and a currently valid guest-portal booking window. Success
marks the local booking checked out, moves its end to the short checkout grace window, and marks the
room dirty. For a provider-backed booking that was checked in, success also schedules an immediate,
durable checkout reconciliation with the source provider; the lifecycle monitor retries transient
provider failures independently of room-code removal. A successful HTTP response confirms the local
checkout only, not provider completion. Verify provider completion through the subsequent booking
state (`checkedOut=true`) from provider synchronization and the booking checkout-transition event.
Repeated checkout requests are rejected after the local transition, so do not retry a successful
response. Treat checkout as a high-impact state change requiring explicit guest intent. Expected
failures are `403` when the room, setting, or booking window does not permit checkout and `404` when
the booking or room cannot be resolved.

Do not treat possession of a booking ID as a general authorization scheme outside the published
guest-portal workflow. Do not expose room codes, booking identifiers, AI conversations, or guest
data in logs or analytics.

### 18.3 API-user management boundary

`/api/api-users` is for authenticated interactive tenant members. A `sat_` API user cannot list,
create, rotate, or revoke API users. `Tenant` is required. Creation and rotation return the plaintext
token exactly once; rotation invalidates the old token immediately; revocation disables it.

### 18.4 APIs outside the runner contract

CRM controllers, PMS/provider integration controllers, incoming webhook routes, internal notification
routes, the webhook forwarding gateway, and the virtual-gateway simulator are separate service
surfaces. They are not part of the `https://backend.solvotix.org/v3/api-docs` runner contract unless
they appear in that live document. Provider callbacks and `/internal/**` routes must never be called
as ordinary tenant API operations. Use the provider-specific deployment contract for OAuth state,
webhook authentication, retries, deduplication, and offboarding.

### 18.5 Mews cleaning reconciliation

`POST /integrations/mews/cleaning/sync` performs an immediate one-way reconciliation from Mews into
the tenant's built-in cleaning program. It requires authenticated access and the `Tenant` header,
has no request body, and returns `204 No Content` after the Mews resources have been fetched and
applied. Disabled or incomplete Mews configuration returns `400`; authentication failures return
`401`; upstream Mews or
persistence failures are reported as server errors.

```http
POST /integrations/mews/cleaning/sync HTTP/1.1
Authorization: Bearer <authenticated integration token>
Tenant: <TENANT_ID>
```

Mews is the master. `Clean` and `Inspected` mark a built-in room clean, while `Dirty` marks it dirty.
`OutOfService`, `OutOfOrder`, and unknown states do not change the built-in cleaning flag. The
operation never writes a cleaning state back to Mews. It is an idempotent reconciliation and may be
retried after a definite failure, but do not overlap concurrent runs. Its risk classification is
`reversible`; approval is not required when the tenant has already configured Mews as its cleaning
master. Verify the result with `GET /api/cleaning/rooms` or
`GET /api/cleaning/rooms/{roomId}`. Normal synchronization also runs at service startup, every five
minutes, and from Mews Resource WebSocket events when those events are available.

After each successfully applied or confirmed Mews `Clean` or `Inspected` result, the integrations process asks
the runner to apply its existing early-access rule for that room. This uses the private
`POST /api/internal/cleaning/rooms/{roomId}/apply-early-access` process-to-process operation with the
shared internal token and tenant context. It has no request body and returns `204` after evaluating
the rule. The operation does not itself mark a room clean: it only advances an eligible local
booking's start time according to the tenant cleaning settings. It is not a public tenant API and
must not be called with an API-user or Firebase token. A failed internal call is not treated as a
failed Mews state import; a later full reconciliation retries the evaluation.

### 18.6 Mews booking synchronization

`POST /integrations/mews/sync` imports recent Mews reservation changes and all stays that overlap
the tenant's current local calendar day. It requires authenticated access and the `Tenant` header,
has no request body, and returns `202 Accepted` with no response body after the synchronization call
has completed.

```http
POST /integrations/mews/sync HTTP/1.1
Authorization: Bearer <authenticated integration token>
Tenant: <TENANT_ID>
```

The optional `updatedSince` and `updatedTo` query parameters are ISO 8601 timestamps. By default,
the update selection ends at the current time and starts at the configured lookback, normally 48
hours. The effective update start can never be more than 48 hours before the current time, even if
an older `updatedSince` is supplied, and a future `updatedTo` is capped to the current time. An
invalid or blank timestamp is treated as omitted.

Independently of that update selection, every call also requests Mews reservations whose stay
interval collides with midnight-to-midnight today in the tenant's configured IANA time zone. This
means a current stay is synchronized even when its last Mews change was more than 48 hours ago.
Reservations returned by both selections are de-duplicated by Mews reservation ID before they are
applied.

```http
POST /integrations/mews/sync?updatedSince=2026-08-24T08:00:00Z&updatedTo=2026-08-25T08:00:00Z HTTP/1.1
Authorization: Bearer <authenticated integration token>
Tenant: <TENANT_ID>
```

Mews settings must already exist for the tenant or the operation returns `400`. Disabled or
incomplete settings result in an accepted no-op. Authentication failures return `401`; upstream
Mews and persistence failures are reported as server errors. Treat this as a state-changing,
reversible provider reconciliation: no additional approval is required after the tenant has
configured Mews, but do not start overlapping runs. A retry is allowed after a definite failure.
The reservation upsert is keyed by the Mews reservation ID, canceled reservations are removed, and
the same reservation is applied only once per run. After success, verify the affected stay through
`GET /api/bookings`, including its dates, room, guest identity, payment state, and checked-in or
checked-out state before relying on downstream room-code, messaging, cleaning, or access behavior.

## 19. ERROR POLICY

```yaml
http_400:
  meaning: invalid request or payload
  action: validate against live OpenAPI
  retry_unchanged: false
http_401:
  meaning: missing, invalid, expired, revoked, or tenant-mismatched API token
  action: stop and request API-user verification or rotation from the system owner
  automatic_retry: false
http_403:
  meaning: authorization or tenant access denied
  action: verify roles and selected tenant
  bypass: forbidden
http_404:
  meaning: endpoint or resource unavailable
  action: refresh OpenAPI and inventory
http_409:
  meaning: state conflict
  action: inspect current state before resolution
http_5xx:
  meaning: server failure
  read_retry: exponential backoff permitted
  physical_command_retry: forbidden until queue and events are inspected
network_failure_after_send:
  state: ambiguous
  action: inspect queues and events before any retry
```

## 20. RISK AND APPROVAL MATRIX

```yaml
read_only:
  examples: [inventory, state, history, metering, queues]
  default_agent_permission: execute
reversible:
  examples: [temperature target, ordinary short pulse]
  default_agent_permission: confirm target and bounds
security_sensitive:
  examples: [access codes, users, permissions]
  default_agent_permission: require explicit approval
destructive:
  examples: [delete device, tenant, codes, queue data]
  default_agent_permission: require explicit confirmation
ownership_changing:
  examples: [claim gateway, claim sensor]
  default_agent_permission: require explicit approval
operationally_dangerous:
  examples: [persistent relay, persistent lock, Wi-Fi, firmware, scheduled lock automation]
  default_agent_permission: require purpose, safe conditions, and explicit approval
```

## 21. SECRET HANDLING

Never expose or log:

```yaml
secrets:
  - Solvotix sat_ API token
  - private key
  - Solvotix session cookie
  - Wi-Fi SSID when classified as private
  - Wi-Fi password
  - smart-lock access code
  - any internal API token
```

Use redaction markers such as `<REDACTED_TOKEN>` and `<REDACTED_ACCESS_CODE>`.

## 22. COMPLETION CRITERIA

Do not report the integration complete until all applicable statements are true:

```yaml
completion:
  - live OpenAPI retrieved and validated
  - generated or typed client matches project conventions
  - API token is loaded only from a protected secret source
  - API token begins with sat_
  - bound tenant ID is explicit
  - read-only authentication check succeeds
  - gateway inventory loads
  - device inventory loads
  - device capabilities are mapped from type and OpenAPI
  - secrets are redacted
  - physical commands are approval-gated
  - non-idempotent commands are not automatically retried
  - accepted, queued, delivered, and confirmed states remain distinct
  - queue and event verification is implemented
  - direct BLE discovery does not depend on the local name or backend online status
  - direct BLE delivery uses the documented service, characteristic, and MTU-safe chunking
  - scanning stops for connection and resumes after disconnection
  - queue messages are removed only after the required positive delivery acknowledgement
  - failure and ambiguity are surfaced to the caller
```

## 23. BOOTSTRAP PROMPT

```text
Integrate this project with Solvotix. First retrieve and read https://solvotix.net/ai-first/agent-manifest.json, https://solvotix.net/ai-first/agent-guide.md, and https://backend.solvotix.org/v3/api-docs. Read all three before editing code. The system owner creates the Solvotix system at https://portal.solvotix.org/login and creates a tenant-bound API user at https://portal.solvotix.org/home/settings#system-users. Use the copied sat_ token only from a protected server-side secret and send it as Authorization: Bearer sat_<TOKEN> with the bound Tenant header. Do not implement interactive user authentication for the integration. Identify the project's language, architecture, existing HTTP client, and code-generation conventions. Implement typed API access, gateway discovery, device discovery, and queue/event verification. Treat hardware commands as asynchronous. Never report physical success solely from an accepted or queued response. Never log tokens, Wi-Fi credentials, access codes, or private customer data. Begin with read-only discovery. Require explicit approval for physical, security-sensitive, destructive, ownership-changing, Wi-Fi, and firmware operations. Do not automatically retry non-idempotent physical commands. If the guide, OpenAPI, inventory, and observed state disagree, stop and report the conflict instead of guessing.
```