// Who decided? — the single place Studio answers that question. // // The hub copies the `reviewer` string a client sends straight into // `decided_by` (`DecideApproval` / `ClearEventLog`); nothing on the // wire ties that string to the authenticated caller. The legal // review of the approvals page (usertest 2026-07-26) called it out: // a value that reads like an identity but is an unchecked client // string lends the audit trail a non-repudiation it does not have. // // The real fix is hub-side — derive `decided_by` from the verified // caller (`CALLER_IDENTITY`, the same source `_caller` comes from) // and ignore what the client claims. That contract is written down // in `docs/reviewer-identity.md`; it needs a hub release. // // Until then Studio does the one thing it *can* do honestly: it // marks its own claim as a claim, inside the record. Every reviewer // string Studio sends carries the `unverified:` prefix, so an // export, an SQL reader or another client sees the trust level // without having to know how the deciding Studio was configured. // When the hub starts deriving the value it overwrites the field // and the prefix disappears by itself — no Studio release needed // to stop lying. import 'dart:io'; import 'package:meta/meta.dart'; /// Marks a reviewer attribution as a client-side claim. Part of the /// recorded value, not just a UI decoration — see the file header. const String kUnverifiedReviewerPrefix = 'unverified:'; /// How much a reviewer attribution Studio is about to write is /// worth, derived from the hub's authentication policy. enum ReviewerAssurance { /// The hub accepts calls without credentials: a decision cannot /// be tied to anyone at all. anonymousHub, /// The hub authenticates the connection (static token or JWT), /// but still records the reviewer name the client supplies. accessControlled, /// Studio could not read the hub's auth policy — `AuthStatus` is /// admin-scoped, so a plain reviewer token gets PermissionDenied, /// and hubs older than the RPC answer UNIMPLEMENTED. unknown, } /// Map the hub's `anonymous_allowed` flag onto an assurance level. /// `null` = policy unreadable (denied / unsupported / offline). ReviewerAssurance reviewerAssuranceFor(bool? anonymousAllowed) => switch (anonymousAllowed) { true => ReviewerAssurance.anonymousHub, false => ReviewerAssurance.accessControlled, null => ReviewerAssurance.unknown, }; /// What a stored `decided_by` value is worth when read back. enum RecordedReviewerTrust { /// Written by a Studio that labelled its own claim (`unverified:`). selfDeclared, /// No trust marker: a legacy row, a CLI decision, or a future /// hub-derived identity. Studio does not know which — and must /// not present it as proven either way. unknown, } /// A `decided_by` value split into what it says and what it is worth. @immutable class RecordedReviewer { /// The name without the trust marker — what a human should read. final String handle; final RecordedReviewerTrust trust; const RecordedReviewer({required this.handle, required this.trust}); bool get isSelfDeclared => trust == RecordedReviewerTrust.selfDeclared; } /// Resolves the reviewer identity Studio submits and reads recorded /// ones back. Never contacts the hub — the assurance level comes /// from the caller (see [reviewerAssuranceFor]). class ReviewerIdentity { ReviewerIdentity._(); static String? _debugHandle; /// Test seam: pins [localHandle] so suites do not depend on the /// OS account of whoever runs them. `null` restores the default. @visibleForTesting static set debugHandle(String? value) => _debugHandle = value; /// Human-readable handle of the operator sitting in front of /// Studio. The OS account is the closest stable label available /// locally — a label, never a proof, which is exactly why it /// leaves the machine through [wire]. static String get localHandle { final pinned = _debugHandle; if (pinned != null) return pinned; final user = Platform.environment['USER'] ?? Platform.environment['USERNAME'] ?? 'studio'; return '$user@studio'; } /// The value that goes on the wire for [handle] (default: /// [localHandle]). Idempotent, so a value that already carries the /// marker — or one round-tripped through the UI — is not prefixed /// twice. An empty/blank handle falls back to [localHandle]: the /// hub rejects an empty reviewer, and a blank one would be a worse /// record than a labelled guess. static String wire([String? handle]) { final trimmed = (handle ?? localHandle).trim(); final name = trimmed.isEmpty ? localHandle : trimmed; if (name.startsWith(kUnverifiedReviewerPrefix)) return name; return '$kUnverifiedReviewerPrefix$name'; } /// Split a recorded `decided_by` into name + trust. static RecordedReviewer parse(String recorded) { final value = recorded.trim(); if (!value.startsWith(kUnverifiedReviewerPrefix)) { return RecordedReviewer( handle: value, trust: RecordedReviewerTrust.unknown, ); } final handle = value.substring(kUnverifiedReviewerPrefix.length).trim(); return RecordedReviewer( // A marker with nothing behind it still says something true // ("someone claimed nothing"); show the marker rather than an // empty cell. handle: handle.isEmpty ? value : handle, trust: RecordedReviewerTrust.selfDeclared, ); } }