Problem/Motivation
The write-precondition contract (issue #3616541) refuses a governed mutation when MCP Sentinel's own lock table has an active row held by a different principal. Human editors on a typical site do not write that table. They acquire a lock in the contrib Content Lock module by opening the entity edit form.
Those two tables do not talk. A human sitting on /node/N/edit does not create an mcp_sentinel_content_locks row, and McpWritePreconditions does not consult the contrib service. A governed JSON:API PATCH (or Tool-API write) of that node therefore succeeds. Contrib Content Lock itself only disables the form and forbids delete; it does not block entity-API updates, so JSON:API is not saved by that module either.
The documented intent is that locks protect humans from agents. On a site that uses contrib Content Lock for editorial concurrency, that intent is currently unmet.
Steps to reproduce
- Enable contrib Content Lock and MCP Sentinel. Configure Content Lock for a node type.
- As a human editor, open
/node/N/editand leave the form open (contrib lock held). - As a governed agent (OAuth client credentials or any other governed channel), PATCH the same node over JSON:API.
Expected: 422 content_lock_conflict, same refusal as a Sentinel lock held by another actor. Actual: the PATCH succeeds.
Proposed resolution
When contrib Content Lock is installed, McpContentLock::conflictsForActor() (and therefore every governed write/delete channel) also consults that module's service. An active contrib lock held by a uid other than the acting principal is the same conflict as a Sentinel lock held by another actor. The acting principal's own contrib lock does not block it. Ungoverned (cookie-session) traffic stays ungated.
Content Lock remains optional: no new hard dependency. The contrib service is injected with the optional-service syntax and is a no-op when the module is absent.
Do not honour the "break content lock" permission on the governed path. That permission is for humans in the UI; letting an agent skip a human's form lock would invert the trust model.
Remaining tasks
- Consult contrib Content Lock in the shared lock service.
- Kernel coverage: other-uid denies, same-uid passes, ungoverned ignores, expired contrib lock passes, module-absent is a no-op.
- Document the optional integration.
User interface changes
None.
API changes
None for callers. McpContentLock::conflictsForActor() / isLocked() gain an optional editorial source when the module is present. Optional constructor argument; existing three-argument construction in unit tests remains valid.
Data model changes
None. Sentinel continues to own mcp_sentinel_content_locks; contrib continues to own content_lock.
Comments
Comment #2
jmcerdaCommitted to 1.x. Governed writes now consult contrib Content Lock when that module is installed: an active form lock held by a different uid is the same content_lock_conflict as a Sentinel lock held by another actor. The acting principal's own lock does not block it; ungoverned traffic stays ungated; break-content-lock is not honoured on the governed path. Credit this nid in the next release notes.