Locking API
SOVD-compliant resource locking for components and apps (ISO 17978-3, Section 7.17). Locks prevent concurrent modification of entity resources by multiple clients.
Overview
Locking provides mutual exclusion for entity resource collections. A client acquires a lock on a component or app, specifying which resource collections (scopes) are protected. Other clients are blocked from modifying locked collections until the lock is released or expires.
Key concepts:
Client identification: Clients use the
X-Client-Idheader (client-generated UUID)Scoped locks: Locks can protect specific collections (e.g.,
configurations,operations) or all collections when no scopes are specifiedParent propagation: A lock on a component also protects its child apps (lazy check)
Lock breaking: Privileged clients can break existing locks with
break_lock: trueAutomatic expiry: Locks expire after the specified TTL and are cleaned up periodically
Configuration
Enable locking in gateway_params.yaml:
locking:
enabled: true
default_max_expiration: 3600
cleanup_interval: 30
defaults:
components:
lock_required_scopes: [configurations, operations]
breakable: true
apps:
lock_required_scopes: [configurations]
breakable: true
Per-entity overrides in the manifest:
components:
- id: safety_controller
name: Safety Controller
lock:
required_scopes: [configurations, operations, data]
breakable: false
max_expiration: 7200
Endpoints
Acquire Lock
POST /api/v1/{components|apps}/{entity_id}/locks
Acquire a lock on an entity. Requires X-Client-Id header.
Request Body:
{
"lock_expiration": 300,
"scopes": ["configurations", "operations"],
"break_lock": false
}
lock_expiration(required): Lock TTL in secondsscopes(optional): Resource collections to lock. If omitted, all collections are locked. Valid scopes:data,operations,configurations,faults,bulk-data,modes,scripts,logs,cyclic-subscriptionsbreak_lock(optional, defaultfalse): If true, replaces any existing lock
Response (201 Created):
{
"id": "lock_1",
"owned": true,
"scopes": ["configurations", "operations"],
"lock_expiration": "2026-03-18T21:30:00Z"
}
List Locks
GET /api/v1/{components|apps}/{entity_id}/locks
List locks on an entity. X-Client-Id header is optional (determines owned field).
Response (200 OK):
{
"items": [
{
"id": "lock_1",
"owned": true,
"scopes": ["configurations"],
"lock_expiration": "2026-03-18T21:30:00Z"
}
]
}
Get Lock Details
GET /api/v1/{components|apps}/{entity_id}/locks/{lock_id}
Get details of a specific lock. Returns 404 if not found.
Extend Lock
PUT /api/v1/{components|apps}/{entity_id}/locks/{lock_id}
Extend a lock’s expiration. Requires X-Client-Id header (must be lock owner).
Request Body:
{
"lock_expiration": 600
}
Response: 204 No Content
Release Lock
DELETE /api/v1/{components|apps}/{entity_id}/locks/{lock_id}
Release a lock. Requires X-Client-Id header (must be lock owner).
Response: 204 No Content
Which Operations a Lock Blocks
The five endpoints above manage locks. A lock only means something because
other endpoints honour it: every write below reads the caller’s
X-Client-Id and answers 409 when the entity’s collection is held by a
different client. Sending no X-Client-Id makes the caller anonymous - the
write succeeds while nothing is locked and is refused once something is - so
the header is optional on these routes, not required.
Each row applies to all four entity types (areas, components, apps,
functions) except bulk-data, which only exists for components and
apps.
Endpoint |
Lock scope |
Operation IDs |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Every one of these operations carries x-medkit-lock-guarded: true in the
generated OpenAPI document, alongside the X-Client-Id parameter and the
409 response, so a generated client can select the lock-participating
surface without pattern-matching on paths.
All three appear only on a gateway that has a lock manager. With
locking.enabled set to false no LockManager is built,
validate_lock_access returns success without reading the header, and no
write can be refused for a lock - so the marker, the parameter and the 409
are all absent from that gateway’s document, which then matches the
capabilities.locking: false its own root reports. The /locks endpoints
stay in the document either way and answer 501. Pinned by
test_locking_disabled_contract.test.py.
The marker is applied per route at registration time, not inferred from the
handler. It is pinned by
test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers
against a hand-maintained list, which catches the document losing a marker but
cannot catch a new lock-checking handler that was never added to the list.
Adding a lock check to a handler means updating that list too.
The One Exception: Global Fault Clear
DELETE /api/v1/faults reads X-Client-Id like the writes above but never
answers 409. It walks every fault, skips the ones whose reporting
entity is locked by another client, clears the rest, and answers 204.
Nothing on the response says which faults were skipped - the
X-Medkit-Local-Only: true header that 204 also carries is set
unconditionally and reports that aggregated peers were not cleared, not that
a lock intervened. A caller who needs to know re-reads the entity’s faults to
see what survived. The operation declares X-Client-Id but carries no
x-medkit-lock-guarded marker, because it cannot return the 409 the
marker implies.
Its X-Client-Id follows locking.enabled like the marker does, through
RouteEntry::lock_client_header() rather than a plain header_param: with
locking off there is no lock manager to consult, nothing is ever skipped, and
the header is not declared.
Error Responses
Status |
Error Code |
Condition |
|---|---|---|
400 |
|
Missing |
403 |
|
Trying to extend/release a lock owned by another client |
404 |
|
Entity does not exist |
404 |
|
Lock ID not found on this entity |
409 |
|
Entity already locked by another client (without |
409 |
|
Mutating operation blocked by another client’s lock |
501 |
|
Locking is disabled on this gateway |