Six pieces of owner feedback, and what each one actually turned out to be
Gabriela Perdum
Author
11 min readSeptember 14, 2026
Key takeaways
"The request remains available even if I approve" was a handler with no else branch — a refusal produced no row change, no message, not even a console line.
Showing a preview inline meant 20 cards asking for 20 base64 data URIs at once, up to 5 MB each. The fix was a three-at-a-time queue, not a smaller image.
Start a timeout when the REQUEST starts, not when the component mounts, or a queued item is failed for other items' latency.
An unmounted card gives its queue slot back. Measured: 10 cards, 2 scrolled away, 8 ran, peak concurrency 3, in mount order.
A dropzone rendered last sat under the SEALED SECTIONS heading, so top-to-bottom it read as "to seal a section, drop a file". Layout order is documentation whether you meant it or not.
The most useful thing about feedback from someone who actually runs your app all day is that it is accurate about the experience and almost never accurate about the cause. Six items came back from an owner in one round. Not one of them was wrong. Not one of them named what was actually broken either.
This is what each turned out to be.
"The request remains available even if I choose approve"
The full report was sharper than that: "the request remains available even if I choose approve; it disappears if I choose deny." One button works, the other does nothing, same row. That reads like a broken approve handler.
Approve was working correctly. It was refusing.
approveEditRequest declines when the seal has expired, and the reason is not arbitrary: a grant carries the seal's expiresAt. Approving a request against a lapsed seal would mint a grant that is already dead on creation — an edit permission that expired before anyone could use it. Refusing is the right behaviour and it stayed.
denyEditRequest has no such check, because dismissing a request that can no longer be granted is always safe.
And every seal in the screenshot the owner sent was overdue. So approve always failed, deny always worked, and from the outside that is indistinguishable from a broken button.
The UI then finished the job. The panel's handler was if (r?.success) removeTheRow() with no else: on a refusal it ran, returned, and did nothing. No row removed, no message, not even a console line. The server had a reason and the panel swallowed it.
Two fixes, on the two halves. The refusal now names the remedy — extend the seal — rather than simply declining. The attachment inbox now surfaces it in the card's existing inline error bar, and the space console's deny handler picks up the message its own approve handler already had. The section inbox's handler still has no else branch; this round did not touch it.
Nothing new was designed. The message existed. Nothing displayed it.
The generalisable bit: a success-only branch is not a shortcut, it is a decision to say nothing on failure. Every if (ok) { ... } with no else in a handler is a silent failure waiting for someone to describe it back to you in the language of a different bug.
"I cannot tell which image is which"
The words were about naming: "currently it is not predictable who image-20230720-212157.png is". Anyone who has taken screenshots on a Mac knows the shape of that filename.
A preview did exist — behind the expand caret on each card. So identifying one screenshot among twenty meant opening every row in turn, which is not identification, it is a search.
The obvious fix is to render the thumbnail inline on the card row. The reason it was not already there is cost: a preview in this app is the whole file as a base64 data URI, up to 5 MB, fetched and encoded server-side. A page with twenty images would fire twenty of those simultaneously the moment it rendered.
So the fix was not a smaller image. It was a queue.
Three at a time, in the order the cards mounted — which is the order a person reads down a list, so the previews appear roughly where the eye already is.
Two details in there are worth more than the queue itself.
An unmounted card gives its slot back.enqueue returns a cancel function that the component calls on unmount. A card scrolled away before its turn is skipped rather than spending a slot on something nobody is looking at. Without that, scrolling a long list quickly means the queue spends its whole budget on cards that are no longer on screen while the visible ones wait.
Running the real thing with ten cards, two of which scroll away while queued:
text
1MAX_IN_FLIGHT = 3
2peak concurrent : 3 (cap 3)
3jobs that actually ran : 8 of 10
4order they ran in : 1, 2, 3, 6, 7, 8, 9, 10
5cancelled 4 and 5 ran? : false
6slots reclaimed, not spent: true
And the timeout starts when the request starts, not when the card mounts. This is the one I would most like people to take away, because it is easy to get backwards and the symptom is baffling.
If you set an 8-second timer at mount, a card sitting ninth in a queue can burn its entire timeout waiting for a turn it has not had yet, and then fail — not because its own request was slow, but because five other requests were. You would see previews failing on a fast connection, more of them the further down the page you look, and no single request would be slow enough to explain it. Starting the clock when the request actually begins means every card gets the eight seconds it was promised.
Previews are then cached across expand and collapse, bounded at sixty entries so a long session cannot grow the map without limit.
"It says it was restored, but only one of us is told"
The report: a restore produced "no specific notification for me or for the owner; it is the same for both parties." Two separate causes, which is why it presented as one vague complaint.
The first is a race. Deleting an embedded sealed file through the Confluence UI fires two events — updated:page and trashed:attachment. An earlier iteration gave both handlers one shared marker class so that a single delete could not produce two comments, which was correct as far as it went. But it also meant whichever event won the race posted the only comment there would ever be, and the trash handler usually lost.
So the user was reliably told "the page content has been reverted" and never told that the file itself had been recovered from the trash. Both things happened. Only one was announced, and it was consistently the less reassuring one.
The marker now records which outcome was announced. A repeat of the same outcome is still swallowed — that was the point of the marker — but a materially different one is not. Deduplication that cannot distinguish "the same thing twice" from "two different things" is not deduplication, it is a lossy filter.
The second cause is plainer. The comment was a single sentence addressed to the owner that merely mentioned the other party. Each side now gets its own paragraph: what happened, what it means for them, and where their work went.
"There is no way out of Overdue"
A seal that lapses moves into an Overdue state, and the reminders ask people to renew before the deadline. There was an Unseal button and a Request Edit button. There was no Extend — no way to renew a seal at all.
The only exit from Overdue was unseal-and-seal-again, which throws away the labels, the comment, the presentation baseline and every edit grant. The feature that nagged you to renew had no renew, and the workaround silently destroyed state.
Extend now appears beside Unseal on all three surfaces that show a seal you hold: the page panel, the overlay and the space console. It is offered on every seal rather than only lapsed ones, because renewing before the deadline is the behaviour being asked for, and a button that only appears once you are late rewards being late.
Three details in the implementation matter more than the button.
It extends from the current expiry when the seal is live, and from now when it has lapsed. Extending from now on a live seal would silently shorten it for anyone who renewed early — punishing exactly the behaviour the reminders request.
It carries the TTL'd edit grants forward. Without that, extending a seal would revoke every editor who currently holds permission on it, which is both surprising and completely silent.
And it refreshes the space index row, because the consoles render "Overdue" from that copy rather than from the seal itself. Extend the seal without touching the index and the seal is genuinely renewed while every console still shows it as overdue — a fix that works and looks exactly like a fix that did not.
Authorization is the same bar as unsealing, for a reason worth stating: the only input that identifies anything is an attachmentId — the optional duration is a clamped number, not a reference — and everything else is read from the seal record that id resolves to. There is no second payload object that could act as a deputy for a different one.
It also got its own colour. The card already spends cyan on Seal, red on Unseal, violet on Request Edit and amber on Requested. A fifth action sharing one of those hues would read as a variant of that action, so Extend is emerald. On a card with four coloured actions, colour is the identifier people use before they read the label.
"A file stays sealed after its owner has left"
This one was not phrased as a bug. It was phrased as a situation.
The expiry sweep notified once and then held the seal indefinitely. So a file sealed by someone who has since left the company stayed listed as sealed, permanently, with no route back short of finding a steward.
The sweep now sends up to N reminders one interval apart — three, daily, read from the stored global policy with no settings field for them yet — each naming the date the file will be handed back. Then it releases the seal. The notify-and-hold behaviour the product page describes is what you get with the reminder limit set to zero.
The subtle part is the counter. It advances only when the comment really posted. The release is counted off those reminders, so a transient 5xx while posting a reminder must not consume one — otherwise a bad afternoon on Confluence's side quietly brings forward the day a file is unsealed. A retry that costs you a warning is worse than no retry at all.
"To seal a section, drop a file"
Nobody said that. That is what the panel said, by accident.
The upload dropzone rendered last in the panel, which put "Drop files here or click to select" directly beneath the SEALED SECTIONS heading. Read top to bottom — which is how people read — that composes into an instruction: to seal a section, drop a file here.
Sections and attachments are unrelated features. One locks a heading's content on the page and involves no files at all. The other attaches a file. The layout had quietly asserted a relationship between them that does not exist, and it did so without a single word of copy being wrong.
The fix was to move the dropzone up with the attachments it uploads, give it its own Add a file heading, and put a line under each heading saying what it is for. "Attaches a file to this page — seal it afterwards.""Locks a heading's content on the page — no files involved."
The generalisable bit: vertical order is documentation. Two unrelated components stacked without a boundary between them will be read as one flow, and users will construct a causal story from the arrangement. If you have never read your own UI top to bottom as a sentence, you have a version of this somewhere.
The screenshot did more work than the sentences
One thing separated this round from the usual bug report, and it is worth copying.
The owner sent a screenshot along with the words. And the diagnosis of F1 — the one that looked most like a broken button and turned out to be correct behaviour reported honestly — came from noticing that every seal visible in that screenshot was overdue.
Without it, "approve does nothing and deny works" is a handler bug. The obvious next step is to read the approve handler, find it structurally fine, and start adding logging. With it, the question changes to what is different about these particular rows, and the answer is sitting in the image. That reframing is the entire difference between an afternoon and ten minutes.
None of that required the owner to know anything about the cause. They photographed their screen. The state that explained everything happened to be in it.
So the practical ask, when you are on the receiving end of feedback: get the artefact, not just the account. A screenshot, an issue key, a timestamp, the space. Not because the reporter is unreliable — this one was precise and completely correct about every symptom — but because the thing that explains the symptom is usually a piece of state the reporter had no reason to think was relevant, and they cannot tell you what they did not know to look at.
The corollary is uncomfortable and worth sitting with. Five of these six had been shipping since July, past review, past 330 green tests, in front of us the whole time. What made them visible was somebody using the thing every day and bothering to write down what annoyed them.
What the round actually taught
Six reports, and the pattern across them is that every one described a symptom in the user's vocabulary and none identified a cause. That is not a criticism of the reporter — it is the correct division of labour. They see the surface; the surface is real; working out that the surface is caused by a missing else rather than a stale cache is the job on this side.
Two habits came out of it that we now apply before shipping anything with a list in it.
Read the screen top to bottom as prose. Not "is each component correct" but "what does this sequence of headings assert". The dropzone bug was invisible under component-by-component review and obvious in one pass of reading it as a sentence.
Make every handler say something on the unhappy path. Not a thrown error, not a console line — something the person who clicked can see. In F1 the server had a perfectly good reason and the panel discarded it.
Trace to the line, not to the symptom. Every one of the six was fixed where it was caused. F1 could have been "closed" by making approve tolerate a lapsed seal, which would have shipped a grant born dead. F2 could have been closed by removing the shared marker, which would have brought back the double comment it was added to prevent. The symptom is where you start, not where you fix.
Two duplications, removed on the way past
Neither of these was reported, and both were about to become a third copy.
The seal teardown — six families of keys to clear — existed once for unseal and was about to be written again for the automatic release in F5. It is now shared. The hold-period policy chain was inline in sealArtifact and again in the section-seal path; also now shared.
The teardown picked something up in the process that is worth knowing if you build on Forge's key-value store: kvs.query is eventually consistent, and will cheerfully return a record you just deleted. The panel's own check had an exclusion for that; the teardown did not, which is why the inline panel kept rendering on pages whose seal was gone. The record was deleted. The query had simply not caught up, and the code believed the query.
The round shipped with 416 unit tests green and eslint clean, which proves that none of the six were the kind of thing a test suite catches. They were all correct code producing the wrong experience.
Sentinel Vault is on the Atlassian Marketplace. This round came from one owner using it day to day and writing down what annoyed them — and it is worth saying plainly that not one of these six was found by us.
I built a Forge major-version predictor on a guess, and it took a review to find the answer inside my own commit
Two triggers fired on the release that took us to 4.0.0, not one. I wrote a tool that knew about six of them when the docs list eleven, published a thesis that inverted the truth, and found the correct answer sitting in a comment in the same commit.