feat(approvals,audit): record the reviewer as the unchecked claim it is
The hub copies the reviewer string a client sends straight into decided_by (DecideApproval, ClearEventLog); nothing on the wire ties it to the authenticated caller. Studio filled it from the OS account, so an export read like non-repudiation while being an arbitrary client claim — the legal finding of the 2026-07-26 usertest panel. The real fix is hub-side (derive decided_by from CALLER_IDENTITY); that contract is written down in docs/reviewer-identity.md and needs a hub release. Until then Studio does the one thing it can do honestly and marks its own claim as a claim, inside the record: - data/reviewer_identity.dart is the single place that produces and reads the value; wire() is idempotent, so page and HubService may both normalise. Every write path funnels through HubService, so no surface can send a bare handle. - The inbox states before the decision who will be recorded, what that attribution is worth on this hub (from AuthStatus), and the literal string that lands in decided_by. An unreadable auth policy stays unreadable — never optimistic. - Reading back: a marked value shows its plain name plus an unchecked flag; an unmarked one (legacy row, CLI decision, or a future hub-derived identity) is not classified either way. - The audit wipe seeds the same kind of marked attribution into its chain.reset marker. When the hub starts deriving the value it overwrites the field and the prefix disappears by itself — no Studio release needed. Guards: reviewer_identity_test (the value) and approvals_reviewer_identity_test (every surface that writes or renders it, against the hermetic fake hub). Visual proof for both themes via the dialog-shot harness. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
415f8a7ddb
commit
ebc668d28d
15 changed files with 1129 additions and 41 deletions
|
|
@ -1,11 +1,11 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/reviewer_identity.dart';
|
||||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -52,18 +52,26 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
/// and falls back to per-row Approve / Reject buttons.
|
||||
final Set<String> _selectedIds = <String>{};
|
||||
bool _batchInFlight = false;
|
||||
// Reviewer identity recorded in the audit log. Defaults to
|
||||
// the OS user (closest stable identity Studio has without an
|
||||
// auth backend); operators can override it per session.
|
||||
late final String _reviewer = _defaultReviewer();
|
||||
|
||||
static String _defaultReviewer() {
|
||||
final user =
|
||||
Platform.environment['USER'] ??
|
||||
Platform.environment['USERNAME'] ??
|
||||
'studio';
|
||||
return '$user@studio';
|
||||
}
|
||||
/// Operator-facing handle of whoever is deciding here. The OS
|
||||
/// account is a label, not an identity — [HubService.approve]
|
||||
/// marks it as an unchecked claim on the wire, and the strip
|
||||
/// above the inbox says so in plain words. See
|
||||
/// `lib/data/reviewer_identity.dart`.
|
||||
final String _reviewer = ReviewerIdentity.localHandle;
|
||||
|
||||
/// What actually goes into `decided_by` — the handle plus the
|
||||
/// marker saying it is an unchecked client claim. Computed here
|
||||
/// (not only inside [HubService], which normalises again as a
|
||||
/// backstop) because the strip above the inbox shows the operator
|
||||
/// this very string before they decide.
|
||||
String get _reviewerWire => ReviewerIdentity.wire(_reviewer);
|
||||
|
||||
/// What that attribution is worth on THIS hub. Starts unknown and
|
||||
/// stays unknown when the policy cannot be read (AuthStatus is
|
||||
/// admin-scoped, and old hubs answer UNIMPLEMENTED) — the strip
|
||||
/// has an honest line for each case.
|
||||
ReviewerAssurance _assurance = ReviewerAssurance.unknown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -71,6 +79,21 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
Workspace.instance.addListener(_refresh);
|
||||
Workspace.instance.ensureLoaded();
|
||||
_refresh();
|
||||
_loadAssurance();
|
||||
}
|
||||
|
||||
Future<void> _loadAssurance() async {
|
||||
ReviewerAssurance resolved;
|
||||
try {
|
||||
final policy = await HubService.instance.authStatus();
|
||||
resolved = reviewerAssuranceFor(policy.anonymousAllowed);
|
||||
} catch (_) {
|
||||
// Denied / unsupported / hub gone: not knowing is a state of
|
||||
// its own, never an excuse to imply the attribution is sound.
|
||||
resolved = ReviewerAssurance.unknown;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _assurance = resolved);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -127,7 +150,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
}
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await HubService.instance.approve(a.id, _reviewer);
|
||||
await HubService.instance.approve(a.id, _reviewerWire);
|
||||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
|
|
@ -142,7 +165,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
final reason = await _promptReason(context);
|
||||
if (reason == null || reason.isEmpty) return;
|
||||
try {
|
||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||
await HubService.instance.reject(a.id, _reviewerWire, reason);
|
||||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
|
|
@ -205,7 +228,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
Object? firstError;
|
||||
for (final a in picked) {
|
||||
try {
|
||||
await HubService.instance.approve(a.id, _reviewer);
|
||||
await HubService.instance.approve(a.id, _reviewerWire);
|
||||
ok += 1;
|
||||
_selectedIds.remove(a.id);
|
||||
} catch (e) {
|
||||
|
|
@ -235,7 +258,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
Object? firstError;
|
||||
for (final a in picked) {
|
||||
try {
|
||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||
await HubService.instance.reject(a.id, _reviewerWire, reason);
|
||||
ok += 1;
|
||||
_selectedIds.remove(a.id);
|
||||
} catch (e) {
|
||||
|
|
@ -370,6 +393,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
children: [
|
||||
_PendingList(
|
||||
future: _pendingFuture,
|
||||
reviewer: _reviewer,
|
||||
assurance: _assurance,
|
||||
selectedIds: _selectedIds,
|
||||
batchInFlight: _batchInFlight,
|
||||
onApprove: _approve,
|
||||
|
|
@ -390,6 +415,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
|||
|
||||
class _PendingList extends StatelessWidget {
|
||||
final Future<List<ApprovalRecord>> future;
|
||||
final String reviewer;
|
||||
final ReviewerAssurance assurance;
|
||||
final Set<String> selectedIds;
|
||||
final bool batchInFlight;
|
||||
final void Function(ApprovalRecord) onApprove;
|
||||
|
|
@ -403,6 +430,8 @@ class _PendingList extends StatelessWidget {
|
|||
|
||||
const _PendingList({
|
||||
required this.future,
|
||||
required this.reviewer,
|
||||
required this.assurance,
|
||||
required this.selectedIds,
|
||||
required this.batchInFlight,
|
||||
required this.onApprove,
|
||||
|
|
@ -453,6 +482,21 @@ class _PendingList extends StatelessWidget {
|
|||
),
|
||||
child: ChainInlineHelp(text: l.approvalsIntroHelp),
|
||||
),
|
||||
// Who the audit trail will name, and what that name is
|
||||
// worth — stated before the decision, not discovered
|
||||
// afterwards by a lawyer reading the export.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
ChainSpace.xl,
|
||||
ChainSpace.md,
|
||||
ChainSpace.xl,
|
||||
0,
|
||||
),
|
||||
child: _ReviewerIdentityStrip(
|
||||
handle: reviewer,
|
||||
assurance: assurance,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
|
|
@ -505,6 +549,108 @@ class _PendingList extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// "You are deciding as …" — names the attribution the audit trail
|
||||
/// will carry and, in one plain sentence, what it is worth on this
|
||||
/// hub. Studio cannot prove the identity (the hub stores whatever a
|
||||
/// client sends as `decided_by`), so the honest move is to say that
|
||||
/// out loud instead of letting the name pass for proof.
|
||||
class _ReviewerIdentityStrip extends StatelessWidget {
|
||||
final String handle;
|
||||
final ReviewerAssurance assurance;
|
||||
|
||||
const _ReviewerIdentityStrip({
|
||||
required this.handle,
|
||||
required this.assurance,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final note = switch (assurance) {
|
||||
ReviewerAssurance.anonymousHub => l.approvalsReviewerNoteAnonymous,
|
||||
ReviewerAssurance.accessControlled =>
|
||||
l.approvalsReviewerNoteAuthenticated,
|
||||
ReviewerAssurance.unknown => l.approvalsReviewerNoteUnknown,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(ChainSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.badge_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Wrap, not Row: a long handle plus the pill must
|
||||
// reflow instead of overflowing on narrow windows.
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: ChainSpace.sm,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
l.approvalsReviewerLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SelectableText(
|
||||
handle,
|
||||
maxLines: 1,
|
||||
style: ChainTheme.mono(
|
||||
size: 12,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
ChainPill(
|
||||
label: l.approvalsReviewerUnverifiedPill,
|
||||
tone: ChainPillTone.warning,
|
||||
icon: Icons.gpp_maybe_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
note,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// The literal value that lands in `decided_by`, so
|
||||
// nobody is surprised by the prefix when they read
|
||||
// the log, the CLI output or a DB export.
|
||||
SelectableText(
|
||||
l.approvalsReviewerRecordedAs(
|
||||
ReviewerIdentity.wire(handle),
|
||||
),
|
||||
style: ChainTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating action bar that surfaces when the operator
|
||||
/// multi-selects pending approvals. Lets them approve or
|
||||
/// reject the whole picked set in one round-trip per item;
|
||||
|
|
@ -967,13 +1113,7 @@ class _HistoryRow extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: ChainSpace.md),
|
||||
if (record.decidedBy.isNotEmpty)
|
||||
Text(
|
||||
record.decidedBy,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (record.decidedBy.isNotEmpty) _DecidedBy(record.decidedBy),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1002,6 +1142,58 @@ class _HistoryRow extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Renders a stored `decided_by`. A value Studio wrote carries the
|
||||
/// `unverified:` marker — show the readable name and keep the marker
|
||||
/// visible as a warning glyph rather than dropping either. A value
|
||||
/// without the marker (legacy row, a CLI decision) gets no badge at
|
||||
/// all: Studio does not know where it came from and must not imply
|
||||
/// it was checked.
|
||||
class _DecidedBy extends StatelessWidget {
|
||||
final String recorded;
|
||||
const _DecidedBy(this.recorded);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final parsed = ReviewerIdentity.parse(recorded);
|
||||
final name = Text(
|
||||
parsed.handle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
if (!parsed.isSelfDeclared) return name;
|
||||
return Tooltip(
|
||||
message: l.approvalsReviewerUnverifiedTooltip,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.gpp_maybe_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(child: name),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "who decided" for the detail dialog's one-line summary: the
|
||||
/// readable name, and — when Studio wrote it — the plain-words
|
||||
/// hint that the attribution is an unchecked claim.
|
||||
String _decidedByLabel(BuildContext context, String recorded) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
if (recorded.isEmpty) return l.approvalsUnknownReviewer;
|
||||
final parsed = ReviewerIdentity.parse(recorded);
|
||||
if (!parsed.isSelfDeclared) return parsed.handle;
|
||||
return '${parsed.handle} (${l.approvalsReviewerUnverifiedPill})';
|
||||
}
|
||||
|
||||
class _HistoryDialog extends StatelessWidget {
|
||||
final ApprovalRecord record;
|
||||
const _HistoryDialog({required this.record});
|
||||
|
|
@ -1039,7 +1231,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
theme,
|
||||
AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||
'· ${record.decidedBy.isEmpty ? AppLocalizations.of(context)!.approvalsUnknownReviewer : record.decidedBy}',
|
||||
'· ${_decidedByLabel(context, record.decidedBy)}',
|
||||
),
|
||||
if (record.createdAt != null)
|
||||
_kv(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||
import '../data/error_presentation.dart';
|
||||
import '../data/friendly_error.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/reviewer_identity.dart';
|
||||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -1258,7 +1259,9 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Outcome of the clear-audit confirmation dialog.
|
||||
/// Outcome of the clear-audit confirmation dialog. [reviewer] is
|
||||
/// already the wire value — the marked, unchecked claim that lands
|
||||
/// in the `chain.reset` marker (see `data/reviewer_identity.dart`).
|
||||
class _ClearOutcome {
|
||||
final String reviewer;
|
||||
final String reason;
|
||||
|
|
@ -1266,10 +1269,11 @@ class _ClearOutcome {
|
|||
}
|
||||
|
||||
/// Two-field confirmation dialog for "clear the audit log".
|
||||
/// Reviewer defaults to the OS user (closest stable identity
|
||||
/// without an auth backend); reason has no default so the
|
||||
/// operator has to type *something* — the chain.reset marker
|
||||
/// must carry context.
|
||||
/// Reviewer prefills with the local handle (the OS account is a
|
||||
/// label, not an identity — [HubService.clearEventLog] marks it as
|
||||
/// an unchecked claim on the wire, and the field's helper says so);
|
||||
/// reason has no default so the operator has to type *something* —
|
||||
/// the chain.reset marker must carry context.
|
||||
class _ClearAuditDialog extends StatefulWidget {
|
||||
const _ClearAuditDialog();
|
||||
|
||||
|
|
@ -1291,11 +1295,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final user =
|
||||
Platform.environment['USER'] ??
|
||||
Platform.environment['USERNAME'] ??
|
||||
'operator';
|
||||
_reviewer = TextEditingController(text: '$user@studio');
|
||||
_reviewer = TextEditingController(text: ReviewerIdentity.localHandle);
|
||||
_reason = TextEditingController();
|
||||
}
|
||||
|
||||
|
|
@ -1329,6 +1329,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
controller: _reviewer,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.auditClearReviewerLabel,
|
||||
helperText: l.auditClearReviewerHelper,
|
||||
helperMaxLines: 3,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
|
|
@ -1365,7 +1367,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
: () => Navigator.pop(
|
||||
context,
|
||||
_ClearOutcome(
|
||||
reviewer: _reviewer.text.trim(),
|
||||
reviewer: ReviewerIdentity.wire(_reviewer.text),
|
||||
reason: _reason.text.trim(),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue