# Reviewer identity — what `decided_by` is worth ## The problem The hub copies the `reviewer` string a client sends straight into `decided_by`: - `DecideApproval` — who approved or rejected a paused flow step - `ClearEventLog` — who wiped the audit log, recorded in the seeded `chain.reset` marker Nothing on the wire ties that string to the authenticated caller. Studio fills it from the OS account (`$USER@studio`); any other client can send any string at all, including someone else's name. The legal review of the approvals page (usertest panel, 2026-07-26) graded this HIGH: a value that reads like an identity but is an unchecked client claim lends the audit trail a non-repudiation it does not have. An export, a court-facing report or an auditor reading the database sees `stefan@studio` and has no way to tell whether the hub checked anything. This is not a leak and not remote-exploitable on its own — the finding is about what the record *proves*, not about access. ## What Studio does today (0.81.0) Studio marks its own claim as a claim, **inside the recorded value**: ``` decided_by = "unverified:stefan@studio" ``` - `lib/data/reviewer_identity.dart` is the single place that produces and reads that value. `ReviewerIdentity.wire()` is idempotent, so both the page and `HubService` may normalise. - Every write path funnels through `HubService.approve` / `reject` / `clearEventLog`, so no surface can send a bare handle. - The approvals inbox states before the decision who will be recorded, what the attribution is worth on *this* hub (derived from `AuthStatus.anonymous_allowed`), and the literal string that will be stored. - Reading back: a marked value is shown by its name with an "unchecked" flag; an **unmarked** value (legacy row, a CLI decision, or a future hub-derived identity) gets no badge at all — Studio does not know its provenance and must not classify it. Guards: `test/reviewer_identity_test.dart` (the value itself) and `test/approvals_reviewer_identity_test.dart` (every surface that writes or renders it, against the hermetic fake hub). This is honest, but it is a *label*, not a fix. It does not stop a client from sending `unverified:someone.else@studio`. ## The actual fix — hub side `decided_by` must be derived server-side from the verified caller and the client-supplied `reviewer` ignored: 1. In the `DecideApproval` and `ClearEventLog` handlers, take the identity from the same source `_caller` comes from (`CALLER_IDENTITY`) rather than from the request message. - static validator: the configured token's name - `jwt-rs256` validator: the JWT subject - anonymous call: no identity — record `anonymous` explicitly (never a client-supplied name), or refuse the decision on channels where the operator requires attribution. 2. Keep accepting the request field for one release (ignored) so older Studios and CLIs keep working; log a deprecation when it is present and differs from the derived value. 3. Once the hub derives the value, it overwrites the field and the `unverified:` prefix disappears from new records by itself — no Studio release is needed to stop labelling. Studio's reader already treats an unmarked value as "provenance unknown", so a hub-derived value renders cleanly. 4. Old rows keep the prefix. That is correct: they *were* unchecked. Open product decision for step 1: whether an anonymous hub may decide approvals at all, or whether approvals require an authenticated caller. Studio surfaces the anonymous case today; it does not block it. ## Related - `lib/data/reviewer_identity.dart` — the Studio-side contract - Backlog T014, point 1 (usertest 2026-07-26, legal persona) - Points 2–7 of that finding (hash-chain-backed history, deeplink, sealed slugs, UTC offsets, auto-refresh) are untouched by this.