Model permission as a versioned external input
Keep the draft separate from a permission snapshot with an explicit, immutable scope and revision or freshness marker. The scope must identify the subject, tenant, record, and action (or the source's equivalent), so a snapshot for another decision cannot update this form. React notes that each render sees a snapshot of state, so an older event handler cannot automatically see a later permission change. When a permission source lives outside React, useSyncExternalStore is the documented subscription API for a changing external store.
Ignore a permission update that is older within the same immutable scope. If a snapshot has another scope, or conflicts at the same scoped revision, fail closed: retain the draft, block submit, and request a fresh authoritative snapshot. A newer denial should also keep the draft, block the submit path, and make the supplied reason and recovery route available for the component to render. Do not silently clear the input; the user may need it to request access, transfer the work, or save it elsewhere under policy.
Recheck at submit time
The client can send the record ID, draft, and observed permission revision as context. It cannot decide that the action is permitted. The trusted service must load the current authorization state and accept or reject the command there.
For ordinary updates, render clear text in a stable status container. W3C's ARIA22 technique describes role="status" as a polite status-message pattern; use it for information, not as a substitute for an actionable error or permission policy.
Test the state boundary
The tested state model preserves a draft, ignores an older scoped update, fails closed on a scope or equal-revision conflict, blocks a submit after a newer denial, and restores editing only after a newer allowed snapshot. Its essential transitions are included below; the model is illustrative and still needs a real permission-source contract.
| Incoming snapshot | Form result |
|---|---|
| Older revision, same scope | Keep the current state. |
| Newer denial, same scope | Keep draft; block submit and show recovery. |
| Different scope or conflicting equal revision | Keep draft; block submit and refresh the authoritative snapshot. |
| Newer allowed revision, same scope | Restore editing; service still rechecks on submit. |
The essential framework-independent transition logic is:
function applyPermissionSnapshot(state, next) {
if (next.scope !== state.permission.scope) {
return { ...state, status: "refresh-required" };
}
if (next.revision < state.permission.revision) return state;
if (next.revision === state.permission.revision) {
if (next.allowed === state.permission.allowed && next.reason === state.permission.reason) {
return state;
}
return { ...state, status: "refresh-required", permissionConflict: "same-revision-conflict" };
}
return { ...state, permission: next, status: next.allowed ? "editing" : "blocked" };
}
The complete local fixture and its eight Node tests can be run with:
npm test --prefix sites/reactjsx.com/evidence/P118
The fixture does not render React or contact a permission service. Its request shape is not an authorization protocol.
Acceptance checklist
- Draft input and permission snapshot have separate state.
- Permission updates have a freshness rule; older updates cannot replace newer ones.
- A newer denial leaves the draft visible and blocks only the affected action.
- The interface explains the changed condition and recovery route in text.
- The service rechecks current authorization when it receives the submission.
Is hiding the Save button enough when a role changes?
No. It can communicate the latest client state, but a caller can bypass or outlive that screen. Require a server-side authorization decision for every consequential request.