Skip to content

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, the useEditLock hook + <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:

go
// 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.

sql
-- 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:

go
// 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_other with holder_principal_id + acquired_at in the error envelope context.
  • Returns 409 lock_not_held if no lock exists at all.
  • Returns 500 lock_resource_unregistered if 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:

typescript
// 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:

tsx
"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:

typescript
// 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