#CRUD: optimistic locking (ETag / If-Match)
#Purpose
Prevent lost updates when two clients edit the same record concurrently. In v1.7 the injected update CRUD method always performs an optimistic-lock check using HTTP conditional requests (Decision 48, §12.9):
getandupdateresponses carry a strongETagcomputed fromid+updated_at.- Every api that calls
updatemust require anIf-Matchheader; its## Transformmapsreq.header.if-matchto the method inputif_match(the only allowed source). - Missing
If-Match→ 428PRECONDITION_REQUIRED; non-matching → 412PRECONDITION_FAILED(§8, RFC 6585 / RFC 9110). updated_atstays a system field and is never written from input.
This replaces the v1.5 pattern (client sends updated_at, conflict → 409), which v1.7 fixed as inconsistent (F4).
#Natural-language source example
models/user/user.md (relevant line)
Provide get, list and update CRUD.
api/users/v2/update-user/update-user.md (route PUT /users/v2/{id})
# Update user
PUT, path /users/v2/{id}, id is a uuid. Requires JWT; only admin may use it.
The If-Match header is required and must carry the ETag the client last read.
Body: name (1 to 50 characters, optional).
Update the user with user.update and return 200 with id and name, plus the new ETag.
User not found returns 404 NOT_FOUND. Missing If-Match returns 428 PRECONDITION_REQUIRED; a stale ETag returns 412 PRECONDITION_FAILED.
#Compiled lock md example
models/user/user.lock.md (CRUD section only, as in §12.8)
## CRUD
| method | options |
| --- | --- |
| get | sensitive-output: [] |
| list | pagination: offset, page-size: 20, max-page-size: 100, sort: [created_at, name], filter: [email] |
| update | partial: true, optimistic-lock: if-match |
api/users/v2/update-user/update-user.lock.md (sections only; kind: api, scope: feature; dependencies include user/crud/update)
## Route
- method: PUT
- path: /users/v2/{id}
## Access
- public: false
- public-writes: false
## Pipelines
### p1 auth
- mode: jwt
- roles: [admin]
## Limits
- provider: main
- timeout: 10s
- body: 1MB
## Request
| field | type | rules | required |
| --- | --- | --- | --- |
| path.id | uuid | | yes |
| header.if-match | string | | yes |
| body.name | string | min: 1, max: 50 | no |
## Response
| status | schema | headers |
| --- | --- | --- |
| 200 | { id, name } | ETag |
## Transform
- in: { id: req.path.id, name: req.body.name, if_match: req.header.if-match }
- out: { id: u.id, name: u.name }
## Steps
### s1 method
- op: method
- target: user.update
- args: transform.in
- out: u
- source-lines: 5-5
### s2 respond
- op: respond
- status: 200
- value: transform.out
- source-lines: 5-5
## Errors
| status | code | from |
| --- | --- | --- |
| 404 | NOT_FOUND | NOT_FOUND |
| 412 | PRECONDITION_FAILED | PRECONDITION_FAILED |
| 428 | PRECONDITION_REQUIRED | PRECONDITION_REQUIRED |
| 422 | VALIDATION_FAILED | VALIDATION_FAILED |
#Security considerations
- Same-tenant access: RLS only isolates tenants. This example restricts the endpoint to
roles: [admin](high-riskrolesitem). For self-service edits, declareowner-keyon the model (forusers, the table's ownid): non-admin read/update SQL must then include{owner-key} = ctx.user.id; relaxing that (ownership: tenant-wideorownership: role(admin)) must be written in source and is high risk (Decision 70, §12.6a). - PUT with partial semantics: injected
updatehas PATCH semantics (§12.9), but v1 routes only GET/POST/PUT/DELETE (§0.1), so PUT carries a partial update. Document this for clients. if_matchmay only be mapped fromreq.header.if-match; never accept a version from the body or query (Decision 48).- System fields
id,tenant_id,created_at,updated_at,deleted_at,erased_atare never written from input (§12.9). - Map 412 and 428 in
## Errors; an unmapped method error becomes 500INTERNALand raises a warning (§8, §10.9, §12.4). - Every
UPDATEneedsWHEREbounded by primary/unique key or RLS (scope: pk | rls) (§2A.4). - If you override
updatewithmodels/user/methods/update.md, you replace the injected optimistic-lock logic and the override is listed in the deploy report (§12.9).
#Spec gaps
- The
## Requestsyntax for a header field (header.if-match) and theETagentry in## Responseheaders are not shown in any spec lock; they follow thereq.header.if-matchname from Decision 48 but are illustrative. - Whether 412/428 are raised by the injected
updatemethod (and so mappedfrom: user.update) or by the platform before the method runs is not specified. - Whether
NOT_FOUNDis a declared error of injectedupdateis not stated (it is stated forget). - Whether the
getapi lock must also declareETagin its## Responseheaders is not specified.