Sentinel Vault: you cannot block an edit in Confluence, so we undo it instead
Gabriela Perdum
Author
11 min readAugust 19, 2026
Key takeaways
Forge product events like avi:confluence:updated:page fire AFTER the save commits. There is no before-save hook and no veto, so no Confluence app can actually block an edit.
That makes content protection a detect-and-restore problem, not a locking problem. The honest guarantee is eventual, measured in seconds, not immediate.
The restore is itself an edit, so it fires the same trigger that started it. Sentinel Vault caches the app's own accountId in KVS and ignores events from it — without that, the first violation loops forever.
Reverting the whole page is the wrong repair. If someone deletes a sealed embed while legitimately editing three paragraphs, a page-level revert destroys their work too. The fix is surgical: pull the media node from the previous version and re-insert it at its original position.
Writing back hits HTTP 409 whenever someone is still editing. Three attempts with exponential backoff at 500ms, 1s, 2s clears it.
Page validation is post-save for the same reason, which is why the enforcement modes are advisory, gate and revert — and why revert is opt-in and documented as able to discard work.
The licence check is biased FAIL-OPEN on purpose. context.license is undefined outside a production Marketplace install, and a content-protection app that stops protecting because a licence read was ambiguous is worse than no app at all.
Every conversation about locking a Confluence attachment starts in the same wrong place. Someone asks for a lock, and everyone in the room pictures the thing a lock does: you try to save, and the system says no.
Confluence Cloud will not do that, and neither will any app you install into it. I want to be precise about why, because the reason is not a missing feature that Atlassian might add next quarter. It is the shape of the extension model, and it changes what a protection app can honestly promise.
The event arrives too late to matter
Forge apps subscribe to product events. The ones that matter here are declared in the manifest like this:
Read the names. updated, trashed, deleted, created — all past tense, and that is not a naming accident. A product event is a notification that something happened. By the time your function runs, the new attachment version is stored, the page body is committed, and the user has seen a success message.
There is no avi:confluence:updating:page. There is no hook that hands you the pending change and waits for a verdict. Nothing in the Forge model lets you return "no" and have Confluence honour it.
So the feature everyone asks for cannot be built. What can be built is the thing one step behind it: notice within a second or two that a protected file changed hands, and put it back.
That is what Sentinel Vault does, and calling it a lock is a convenience. Internally it is a seal, and the difference is not marketing. A lock prevents. A seal is a claim, plus the machinery to enforce the claim after the fact.
The restore is an edit, and it fires your own trigger
The first thing that breaks is embarrassing and instructive.
Someone uploads a new version of a sealed spreadsheet. Your trigger fires. You fetch the previous version, re-upload it, and the file is back. Except that re-upload is an attachment update, so avi:confluence:updated:attachment fires again — this time for your own write. Your handler dutifully decides the file has been tampered with, restores it again, and you have built a machine that hammers the Confluence API until something gives out.
The fix is to recognise your own writes. Forge apps act under an app user with its own account, so the app asks Confluence who it is, once, and remembers:
javascript
1asyncfunctionshouldIgnoreEvent(userAccountId){2// Prevent infinite loops - ignore edits made by our own app3let appAccountId =await kvs.get("app-account-id");4if(!appAccountId){5const myselfResponse =awaitasApp().requestConfluence(6 route`/wiki/rest/api/user/current`,7);8if(myselfResponse.ok){9const myself =await myselfResponse.json();10 appAccountId = myself.accountId;11await kvs.set("app-account-id", appAccountId);12}13}1415returnBoolean(appAccountId && userAccountId === appAccountId);16}
The lookup is cached in the key-value store rather than repeated, because this runs on the hot path of every attachment event in every space. It is a small function and it is the difference between an app that works and an app that takes your instance down the first time somebody touches a sealed file.
If you are building anything that writes in response to a write, this is the first thing to get right. It is also the first thing everyone forgets, because it works perfectly in testing right up until the moment your handler succeeds.
Reverting the page is the wrong repair
The attachment case is comparatively easy: files have versions, and restoring one is a download followed by an upload.
Pages are harder, and this is where a naive implementation does real damage.
A sealed attachment is often embedded in the page body — an inline image, a file preview. If someone removes that embed, the file itself is untouched, so nothing in the attachment events tells you anything is wrong. What changed is the page.
The obvious repair is to restore the previous version of the page. It is also wrong. Picture the actual edit: someone opened the page, rewrote three paragraphs, fixed a table, and — deliberately or not — deleted the embedded diagram. Roll the page back and you have protected the diagram by throwing away everything else they did. You have caused a second data loss to fix the first, and this time you caused it on purpose.
So the repair has to be surgical. Read the current body, notice the media reference is gone, go to the previous version, find the node, and put that node back at its original position, leaving every other change alone.
Then you meet the next problem, which is that you are writing to a page a human may still have open:
javascript
1if(putRes.status===409){2// Version conflict — retry with exponential backoff3const delay =Math.pow(2, attempt)*500;4console.warn(5`[ADF] Version conflict on page ${pageId}, retrying in ${delay}ms (attempt ${attempt +1}/${maxRetries})`,6);7awaitnewPromise((r)=>setTimeout(r, delay));8continue;9}
Three attempts, waiting 500ms, then a second, then two. A version conflict here is not an error condition, it is the expected case — you are writing to a document precisely because somebody was recently writing to it. Treating 409 as a failure produces an app that gives up exactly when it is needed.
The same mechanism protects sealed sections. A sealed section is a bodied macro carrying a stable app-issued id, so when the page-update trigger runs it can tell whether the marked region still exists and still matches its snapshot, and restore it if not. Same detection, same surgery, same retry.
Validation is post-save too, so say so
Once you accept that events arrive after the fact, the rest of the design follows honestly.
Sentinel Vault can check pages against rules — required headings, heading hierarchy, mandatory labels, length limits. Those checks run on create and update, which means they run after the author has already saved and moved on. You cannot show them a validation error before the save, because there is no before.
That leaves three defensible things to do about a page that fails, and the app makes you choose:
Advisory posts a comment listing what is wrong. Nothing is changed. This is the right default and it is what most teams should use.
Gate stamps a pass/fail status that the panel and the page ribbon display. The page stands, and its state is visible to everyone who opens it.
Revert restores the last compliant version. It is strict, it is opt-in, and it is documented as able to discard work — because that is exactly what it does. A rule that reverts is a rule that will eventually delete something a colleague wrote sixty seconds ago.
I would rather ship that third mode with a blunt warning than ship it quietly. An enforcement action nobody understood the cost of is how a governance tool gets uninstalled.
A protection app must fail open on licensing
One more decision, and it is the one I expect to be argued with.
Forge populates context.license only when the manifest opts into paid-via-Atlassian licensing and the app is running from a production Marketplace listing. In a development install, in a custom environment, in the test harness, it is simply undefined.
So a licence check has three states, not two: active, explicitly inactive, and unknown. What you do with the third one is a real decision.
javascript
1const active = req?.context?.license?.active;// true | false | undefined
Sentinel Vault reports unlicensed only when the platform explicitly says active === false. An absent licence reads as licensed. And even when the licence is genuinely inactive, the app soft-degrades to a nag banner rather than switching off.
The reasoning is specific to what this app is. If a project-tracking app stops working when a licence lapses, you lose access to some views until somebody sorts out the billing. If a content-protection app stops working, seals stop being enforced — silently, on files people believe are protected, at the exact moment nobody is paying attention to the app. The failure is invisible and the damage is real.
An earlier version of this code was a hardcoded { isLicensed: true } stub, which is worse: it never read the licence at all and reported a state it had not checked. Reading the real value and biasing the ambiguous case toward continuing to protect is a defensible position. Lying about it was not.
What this means if you are evaluating it
Be suspicious of anyone selling you a lock on Confluence Cloud content, including me. What you can actually get is this:
A seal that is visible to everyone before they touch the file, which prevents most collisions through coordination rather than enforcement. Detection of the ones it does not prevent, in the seconds after they happen. Automatic restoration of the previous state — the attachment version, the removed embed, the edited section — without collateral damage to unrelated work. A notification trail so the person who held the seal knows it happened.
What you do not get is a save that fails. Nobody can give you that, and an app that implies otherwise is describing something the platform does not support.
For most teams the coordination is the part that pays for itself. Two people cannot download the same spreadsheet and both spend an afternoon on it if the second one saw a seal before starting. The restoration machinery is the safety net underneath, and the honest description of it is that it is fast, not instant.
Sentinel Vault is on the Atlassian Marketplace, and there is a fuller feature breakdown on the Sentinel Vault product page. If you want the same constraint explained from the Forge side rather than the product side, the pieces under Atlassian Forge cover a lot of platform behaviour that is not in the documentation.