Edit Locks Guide
How to wire a new resource type through the pessimistic edit-lock primitive — for engineers landing the first lockable detail page in a feature tier (treatment plan editor, form draft editor, prescription builder, …).
Foundation 1D.4 ships the framework — the
internal/core/locks/package, the four HTTP endpoints, theuseEditLockhook +<EditLockBanner />component, and the P54 convention. Foundation registers zero resource types — the registry starts empty. This guide is for the engineer landing the first lockable resource at their feature tier.
When to add a lock
Add a lock when a detail page edits a single record that two staff members might open at the same time. The classic shapes:
- Treatment plan editor (clinical, multi-tab)
- Form draft editor (long fill-in)
- Prescription builder (signature is terminal)
- Org settings page (rare but possible)
Don't add a lock when:
- The page edits a list (multiple rows). Locks are one-resource-at-a-time; a list-of-things UI is its own pattern (optimistic concurrency on each row).
- Changes are merge-friendly (collaborative document editing). Hard non-goal — the platform is staff-blocking-staff, not concurrent-edit. Wrong primitive.
- The mutation is single-shot from a small form (e.g. an "archive patient" button). The cost of running the lock dance for a one-click action exceeds the value.
Backend — three hooks
1. Register the resource at init time
Each domain that opts in adds a locks.go (or extends an existing init) that registers a ResourceDef:
// services/api/internal/core/domain/treatmentplans/locks.go
package treatmentplans
import (
"github.com/restartix/restartix-platform/services/api/internal/core/locks"
"github.com/restartix/restartix-platform/services/api/internal/core/principal"
)
func init() {
locks.RegisterResource(locks.ResourceDef{
Type: "treatment_plan",
Permission: principal.PermTreatmentPlansUpdate,
Description: "Treatment plan editor (clinical, multi-tab)",
})
}Type— the URL-style resource name. Singular, snake_case. Becomes the{resource}segment in/v1/organizations/{id}/locks/{resource}/{resourceId}.Permission— the per-org permission code required to acquire the lock. Use the same permission the underlying mutation requires (staff who can't edit can't lock).Description— one-line human-readable explanation, surfaced in tooling that lists the registered resource set.
The init() runs at process boot. The HTTP handler validates the URL resource segment against the registry — unknown types return 400. Duplicate registration with a conflicting definition panics at boot (programmer error caught early).
2. Add version to the underlying table
Edit-locks prevent the COMMON case of concurrent edit. They can race in rare paths (Redis hiccup, expired-mid-save, takeover during in-flight request). Lockable tables also carry a version column that the repo checks on UPDATE — that's the correctness layer.
-- In the table's CREATE TABLE migration (pre-prod, edit in place):
CREATE TABLE treatment_plans (
-- ...
version INTEGER NOT NULL DEFAULT 1,
-- ...
);The repo bumps version on every UPDATE and rejects writes whose WHERE version = $expected_version clause matches zero rows (returns the 409-equivalent domain error). The handler renders that as 409 Conflict the same way the lock middleware does.
Lock = UX, version = correctness. Don't drop one for the other.
3. Mount RequireLockHeld on mutation routes
In the per-org route group, apply the middleware to PATCH/POST/DELETE endpoints that mutate the lockable resource:
// services/api/internal/core/server/routes.go (under r.Route("/{id}", ...))
r.Route("/treatment-plans", func(r chi.Router) {
r.With(middleware.RequirePermission(principal.PermTreatmentPlansView)).
Get("/", h.HandleList)
r.Route("/{id}", func(r chi.Router) {
r.With(middleware.RequirePermission(principal.PermTreatmentPlansView)).
Get("/", h.HandleGet)
r.With(
middleware.RequirePermission(principal.PermTreatmentPlansUpdate),
locks.RequireLockHeld(s.locksService, "treatment_plan", "id"),
).Patch("/", h.HandleUpdate)
})
})The middleware:
- Reads the URL param (
"id"here) and parses it as a UUID. - Calls
service.CheckHeld— one Redis GET, no DB. - Returns 409
lock_held_by_otherwithholder_principal_id+acquired_atin the error envelope context. - Returns 409
lock_not_heldif no lock exists at all. - Returns 500
lock_resource_unregisteredif the resource type wasn't registered (programmer error in middleware wiring — loud failure).
Reads (GET/HEAD) don't need guarding — the lock is about edit serialization, not visibility.
Frontend — three pieces
1. Server-action wrappers
Each app (clinic, portal, console) wires server actions around the api-client methods. The wrappers translate ApiError 409 conflicts into the typed LockActionResult shape the hook expects:
// apps/clinic/app/(dashboard)/treatment-plans/[id]/lock-actions.ts
"use server";
import { createApiClient } from "@/lib/api";
import { ApiError } from "@workspace/api-client/errors";
import type { LockActionResult } from "@workspace/ui/hooks/use-edit-lock";
export async function acquireTreatmentPlanLockAction(
orgId: string,
planId: string,
takeOver: boolean,
): Promise<LockActionResult> {
try {
const api = await createApiClient();
const lock = await api.acquireEditLock(orgId, "treatment_plan", planId, {
take_over: takeOver,
});
return { ok: true, lock };
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
const ctx = e.details as {
holder_principal_id?: string;
acquired_at?: string;
};
if (ctx.holder_principal_id && ctx.acquired_at) {
return {
ok: false,
conflict: {
conflict: true,
holderPrincipalId: ctx.holder_principal_id,
acquiredAt: ctx.acquired_at,
},
};
}
}
return {
ok: false,
error: e instanceof Error ? e.message : "lock_acquire_failed",
};
}
}
export async function heartbeatTreatmentPlanLockAction(
orgId: string,
planId: string,
): Promise<LockActionResult> {
// Same shape — translate 409 lock_lost into a conflict result.
// ...
}
export async function releaseTreatmentPlanLockAction(
orgId: string,
planId: string,
): Promise<void> {
const api = await createApiClient();
await api.releaseEditLock(orgId, "treatment_plan", planId);
}
export async function getTreatmentPlanLockAction(
orgId: string,
planId: string,
) {
const api = await createApiClient();
return api.getEditLock(orgId, "treatment_plan", planId);
}No
updateTag()/refresh()calls — lock state is high-churn and uncached. The api-client lock methods are deliberately uncached.
2. Wire useEditLock into the form
In the client form component, drive the form's enabled state off the hook's typed status:
"use client";
import { useEditLock } from "@workspace/ui/hooks/use-edit-lock";
import { EditLockBanner } from "@workspace/ui/components/edit-lock-banner";
import {
acquireTreatmentPlanLockAction,
heartbeatTreatmentPlanLockAction,
releaseTreatmentPlanLockAction,
getTreatmentPlanLockAction,
} from "./lock-actions";
export function TreatmentPlanEditorForm({
orgId,
planId,
selfPrincipalId,
initialPlan,
// ...
}: Props) {
const lock = useEditLock({
selfPrincipalId,
actions: {
acquire: (takeOver) =>
acquireTreatmentPlanLockAction(orgId, planId, takeOver),
heartbeat: () => heartbeatTreatmentPlanLockAction(orgId, planId),
release: () => releaseTreatmentPlanLockAction(orgId, planId),
get: () => getTreatmentPlanLockAction(orgId, planId),
},
releaseBeaconUrl: `/api/locks/treatment-plan/${planId}/release`,
});
const writable = lock.status === "held_by_self";
return (
<div className="space-y-6">
{lock.status === "held_by_other" && (
<EditLockBanner
variant="held_by_other"
holderLabel={resolveHolder(lock.holderPrincipalId)}
acquiredAt={lock.acquiredAt}
canTakeOver={true}
onTakeOver={lock.takeOver}
labels={t.heldByOther}
/>
)}
{lock.status === "lost" && (
<EditLockBanner
variant="lost"
onRetry={lock.acquire}
labels={t.lost}
/>
)}
<form>
{/* form fields, all `disabled={!writable}` */}
</form>
</div>
);
}3. Add a release-beacon route handler (per app)
The hook's tab-close cleanup uses navigator.sendBeacon against a same-origin URL. Each app exposes a thin route handler that proxies to the Core API DELETE:
// apps/clinic/app/api/locks/treatment-plan/[id]/release/route.ts
import { createApiClient } from "@/lib/api";
import { requireCurrentOrganizationId } from "@/lib/org";
export async function POST(
_: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const orgId = await requireCurrentOrganizationId();
const api = await createApiClient();
await api.releaseEditLock(orgId, "treatment_plan", id);
} catch {
// Best-effort — the 120s TTL is the actual safety net.
}
return new Response(null, { status: 204 });
}The handler is best-effort: a Redis hiccup, an expired session, or a missing lock all silently succeed. The 120s TTL guarantees the lock will release on its own within ~2 minutes of tab close even if the beacon never fires (older browsers, network drop).
Common gotchas
Don't reuse the same registry call from a non-init function. RegisterResource panics on duplicate-with-conflict but is idempotent on identical re-registration — calling it from a per-request hook will deadlock if a different goroutine concurrently reads Lookup.
Don't add a lock to a list page. The lock primitive operates on a single resource ID. A list-of-rows surface needs a different concurrency strategy (optimistic concurrency per row, rendered as inline edit conflicts).
Don't gate GET endpoints with RequireLockHeld. Reads always pass through. The lock is about edit serialization; multiple readers can view a record while one editor holds the lock — that's the desired UX.
Don't audit acquire / release / heartbeat. Operational metadata (P54). The CLAUDE.md "operational-metadata bumps are exempt" rule applies. Only LOCK_TAKEOVER is audited.
Don't store the lock_token (it doesn't exist). Locks are per-PRINCIPAL. Same staff with two tabs share the lock. The principal_id IS the auth.
See also
- P54 Pessimistic Edit Locks — design rationale + cross-references
- P10 Audit Logging —
LOCK_TAKEOVERaudit row - Foundation 1D.4 — what shipped vs deferred
internal/core/locks/— package source@workspace/ui/hooks/use-edit-lock.ts— hook source