A feedback ticket arrives carrying an email, and somebody in the review asks whether that email can decide permissions. It cannot, and the reason is worth more than the answer: recording where an identity value came from settles what to believe about the report, never what its author may do.
Where a value came from is a separate fact from what it permits
Wiring a feedback widget against an existing auth system looks like one task and is two. The first is attribution: making the ticket say which account the person was signed into, so triage does not start with a reply asking who they are. The second is access: deciding what that person may read and write afterwards.
Collapsing them is easy because the same string appears on both sides. An email that identifies a reporter looks exactly like an email that identifies a user, and a field named email on a ticket invites the assumption that somebody checked it. Usually nobody did: the widget was told, by JavaScript on a page any visitor can open a console on, and it had no way to find out for itself. A tag that reads cookies, storage or network traffic to work out who is signed in has taken the liberty the isolation contract rules out.
What is worth storing, then, is the identity together with how it was obtained, kept as a value the ticket carries for the rest of its life.
Four provenances, and the rule that orders them
Every report freezes an assurance value alongside the identity fields, and there are four of them.
| Provenance | How it arrives | What it supports |
|---|---|---|
anonymous | The tag is on the page and nothing else happened | That a series of reports came from one browser |
self_reported | Somebody typed a contact address or a name into the form | Whatever that person chose to write |
identified | The page called identify() with what the application knows | Whatever the page’s own JavaScript declared |
verified | A backend signed an assertion and the widget exchanged it | That the configured backend issued the claim |
Each rung is weaker than a permission and stronger than nothing, and the interesting work is keeping them apart rather than promoting them. A visitor with no account still gets a pseudonym, minted as a random identifier and kept in the browser wherever storage allows it to persist, because deriving one from the browser’s own characteristics would be fingerprinting somebody who was reporting a bug.
A value the client declared never climbs to the top
The rule that makes the ladder worth having is that identify() cannot reach verified, no matter what it passes. Anyone can call it from a console on the page. Reaching the top value takes a signature your backend produced with a key scoped to that one project, exchanged by the widget for an opaque proof that the widget then attaches to reports instead of the assertion itself.
| The check | What it refuses |
|---|---|
| Algorithm | Anything other than HS256, before any claim is read |
| Key id | An unknown key, and a revoked key even with a valid signature |
| Audience | An assertion minted for a different project |
| Lifetime | An assertion issued to live longer than 10 minutes |
| Issued-at | An assertion dated more than 60 seconds into the future |
| Expiry | An expired assertion, with no tolerance granted at all |
| Size | Anything over 4 KiB |
Two rows in that table are worth reading together, because they are asymmetric on purpose. Clock skew is forgiven on issuance and refused on expiry, so a backend whose clock runs fast still works while an assertion past its expiry is dead everywhere. The proof handed back in exchange lasts 30 minutes and is bound to one subject in one project, so calling identify() for another person, switching account, or calling resetIdentity() retires it. Revoking the signing key retires every proof it ever produced, checked again when the report arrives rather than only at the exchange.
Both sides of the wire demote, and the client is the stricter of the two: the widget drops its proof as soon as identify() names anyone but the proven subject, while the server keeps one that arrives with no declared id beside it. Neither costs the report. A proof naming a different subject or a different account drops to identified and the submission lands.
Two emails travel, and only one is a claim about the account
A person who is already signed in may still type a different address into the form, and squeezing both into one column loses the only interesting thing about them. So a report carries identityEmail, which the application declared, and contactEmail, which a human typed, as separate fields under one shared bound. Either address is cut at 320 characters and dropped entirely if what is left is not shaped like an email, because the column they both feed has always promised to hold something addressable.
Climbing a rung also removes a name rather than promoting it. A name typed into the form belongs to self_reported, so when a stronger identity attributes the same submission, the typed name is discarded before the stronger identity contributes its own, which it may legitimately not have. A ticket showing a blank name under verified is telling the truth about what was proven; a ticket showing the typed one would not be.
What the provenance does decide is what a ticket carries
The clearest thing provenance changes is the element anchor. When a report from a visitor points at a specific element on the page, that structured anchor is stored only at verified; below it the anchor is dropped and the report keeps its words, its screenshot and the console and network evidence around it. A reviewer inside your own team’s review session arrived through a different door and keeps the anchor whatever the ladder says. Untrusted structured data about somebody else’s DOM is worth less than the confusion it would cause on the ticket.
The declared context around the identity is bounded rather than trusted. Traits are flat primitives, capped at 16 keys with keys of 64 characters and string values of 256, and spent against a total budget of 2,048 units that a non-string value charges 24 against. The budget drops the keys that stop fitting rather than reporting a refusal, so a context object with long values arrives with fewer of them than it left.
The snapshot is written once. Signing out, switching accounts or identifying as somebody else changes the next report and rewrites nothing about an earlier one, which is what makes an old ticket readable a year later.
What decides permission is a second proof, never the value
None of this reaches authorization, and the shape of the code is the argument. A reporter reading their own tickets holds a closed capability set of exactly three entries: read tickets, read comments, write a comment. Where a reporter can reach the portal at all, that set is identical whatever the provenance, and the module deciding access never reads the provenance. There is no rung that grants more, and an anonymous report carries no addressable identity to let through in the first place.
Reaching even those three entries needs a proof that is independent of the identity value on the ticket. Either the reporter is the signed subject a backend asserted, or somebody on the project deliberately issued a portal link whose subject is the captured address, and holding that link is what opens the door. An email sitting in a field opens nothing by itself. Declared roles and plans are the same story in both directions: useful for deciding what to look at first, never for deciding what somebody may do, and never for deciding what to withhold from them either.
The top rung proves that the backend you configured signed an assertion, and it stops there: it does not prove civil identity, and it does not prove the upstream session is still alive at the moment the report is filed. The cross-tab reset that clears identity on sign-out is broadcast between tabs where the browser offers a channel and otherwise applies to the calling tab alone. Both are the kind of gap you plan around by deciding, before the widget goes on the page, that the ladder is triage context for the board and that permissions keep coming from the system that already owns them.