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:
flemming-it 2026-08-03 23:50:34 +02:00
parent 415f8a7ddb
commit ebc668d28d
15 changed files with 1129 additions and 41 deletions

View file

@ -0,0 +1,288 @@
// Reviewer identity on the surfaces that write it approvals page
// and the audit-log wipe.
//
// Guard for the legal finding of the 2026-07-26 usertest: the hub
// stores whatever reviewer string a client sends as `decided_by`,
// so Studio must (a) never send a bare handle that reads like a
// proven identity, (b) tell the reviewer BEFORE the decision what
// the attribution is worth on this hub, and (c) present a stored
// value by what it actually proves.
//
// Runs against the scriptable FakeHubService never a real hub.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/data/reviewer_identity.dart';
import 'package:chain_studio/main.dart';
import 'support/fake_hub.dart';
String _allText(WidgetTester tester) {
final buf = StringBuffer();
for (final w in tester.widgetList<Text>(find.byType(Text))) {
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
}
for (final w in tester.widgetList<SelectableText>(
find.byType(SelectableText),
)) {
buf.writeln(w.data ?? '');
}
return buf.toString();
}
ApprovalRecord _pending() => ApprovalRecord(
id: 'apr-1',
flowName: 'classify-and-file',
stepId: 'review',
prompt: 'Bitte die Klassifikation prüfen',
payloadPreview: '{"label":"Rechnung"}',
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
expiresAt: null,
status: 'pending',
decidedAt: null,
decidedBy: '',
reason: '',
project: 'lbs',
flowExecution: 'run-abc123',
);
ApprovalRecord _decided(String decidedBy) => ApprovalRecord(
id: 'apr-2',
flowName: 'classify-and-file',
stepId: 'review',
prompt: 'Bitte die Klassifikation prüfen',
payloadPreview: null,
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
expiresAt: null,
status: 'approved',
decidedAt: DateTime.utc(2026, 7, 26, 12, 5, 0),
decidedBy: decidedBy,
reason: '',
project: 'lbs',
flowExecution: 'run-abc123',
);
/// Fixed frame budget instead of `pumpAndSettle`: the page keeps a
/// progress indicator alive on the hidden tab, so settling never
/// completes (same reason the origin suite pumps explicitly).
Future<void> _frames(WidgetTester tester, [int count = 12]) async {
for (var i = 0; i < count; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
}
/// Boot Studio on the approvals page against [fake].
Future<void> _openApprovals(WidgetTester tester, FakeHubService fake) async {
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const StudioApp(
initialThemeMode: ThemeModeValue.dark,
initialLocale: Locale('de'),
),
);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
for (var i = 0; i < 12; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
}
Future<void> _teardownFrames(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(minutes: 1));
}
void main() {
setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio');
tearDown(() => ReviewerIdentity.debugHandle = null);
testWidgets('the inbox names who will be recorded, flags the claim as '
'unchecked and shows the literal stored value', (tester) async {
final fake = installFakeHub();
fake.approvals = [_pending()];
await _openApprovals(tester, fake);
final text = _allText(tester);
expect(text, contains('Sie entscheiden als'));
expect(text, contains('stefan@studio'));
expect(text, contains('nicht überprüft'));
// No surprise later: the exact string that lands in decided_by.
expect(text, contains('unverified:stefan@studio'));
await _teardownFrames(tester);
});
testWidgets('an anonymous hub says a decision cannot be pinned on '
'anyone', (tester) async {
final fake = installFakeHub();
fake.approvals = [_pending()];
// Default fake policy: static validator, anonymous allowed.
await _openApprovals(tester, fake);
expect(_allText(tester), contains('ohne Zugangsdaten'));
await _teardownFrames(tester);
});
testWidgets('an auth-enabled hub says the access is checked but the '
'name is not', (tester) async {
final fake = installFakeHub();
fake.approvals = [_pending()];
fake.authPolicy = const HubAuthPolicy(
validator: 'static',
anonymousAllowed: false,
tokens: [],
);
await _openApprovals(tester, fake);
expect(_allText(tester), contains('prüft Ihren Zugang'));
await _teardownFrames(tester);
});
testWidgets('an unreadable auth policy stays honest instead of '
'guessing', (tester) async {
final fake = installFakeHub();
fake.approvals = [_pending()];
// AuthStatus is admin-scoped: a plain reviewer token is denied.
fake.authPolicy = null;
await _openApprovals(tester, fake);
expect(_allText(tester), contains('Admin-Rechte'));
// Still flagged not knowing never upgrades the attribution.
expect(_allText(tester), contains('nicht überprüft'));
await _teardownFrames(tester);
});
testWidgets('approving sends the marked attribution, never a bare '
'handle', (tester) async {
final fake = installFakeHub();
fake.approvals = [_pending()];
await _openApprovals(tester, fake);
await tester.tap(find.widgetWithText(FilledButton, 'Freigeben').first);
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
expect(fake.decisions, hasLength(1));
expect(fake.decisions.single.id, 'apr-1');
expect(fake.decisions.single.reviewer, 'unverified:stefan@studio');
expect(
fake.decisions.single.reviewer,
startsWith(kUnverifiedReviewerPrefix),
reason: 'the decide path must not claim an identity Studio '
'cannot prove',
);
await _teardownFrames(tester);
});
testWidgets('rejecting sends the marked attribution too', (tester) async {
final fake = installFakeHub();
fake.approvals = [_pending()];
await _openApprovals(tester, fake);
await tester.tap(find.widgetWithText(OutlinedButton, 'Ablehnen').first);
await _frames(tester);
await tester.enterText(find.byType(TextField).last, 'Beleg fehlt');
await _frames(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Ablehnen').last);
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
expect(fake.decisions, hasLength(1));
expect(fake.decisions.single.reviewer, 'unverified:stefan@studio');
expect(fake.decisions.single.reason, 'Beleg fehlt');
await _teardownFrames(tester);
});
testWidgets('the history shows a marked value by its name and keeps '
'the "unchecked" flag', (tester) async {
final fake = installFakeHub();
fake.approvals = [_decided('unverified:anna@ops')];
await _openApprovals(tester, fake);
await tester.tap(find.text('Verlauf'));
await _frames(tester);
final text = _allText(tester);
expect(text, contains('anna@ops'));
// The raw marker is not what a human should read in the list.
expect(text, isNot(contains('unverified:anna@ops')));
// The detail dialog spells the trust level out in words.
await tester.tap(find.textContaining('classify-and-file').last);
await _frames(tester);
expect(_allText(tester), contains('anna@ops (nicht überprüft)'));
await _teardownFrames(tester);
});
testWidgets('the audit-log wipe records the same kind of marked '
'attribution in the chain.reset marker', (tester) async {
// Same bug class as approvals: the reset marker names a
// "Prüfer" the hub never checked, and auditors read exactly
// that marker to explain a chain discontinuity.
final fake = installFakeHub();
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const StudioApp(
initialThemeMode: ThemeModeValue.dark,
initialLocale: Locale('de'),
),
);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const ValueKey('sidebar-item-audit')));
await _frames(tester);
await tester.tap(find.byTooltip('Weitere Aktionen'));
await _frames(tester);
// Tap the entry, not its label: the overflow menu's labels are
// wider than the 256px menu, so the label's centre lies outside
// the hit box (a separate, unrelated layout defect on that
// menu). The entry itself is what the operator hits.
await tester.tap(
find.ancestor(
of: find.textContaining('Entwicklungs-Reset'),
matching: find.byType(PopupMenuItem<String>),
),
);
await _frames(tester);
// The field says what will be stored, before it is stored.
expect(_allText(tester), contains('unverified:'));
await tester.enterText(find.byType(TextField).last, 'Testdaten verworfen');
await _frames(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Log löschen'));
await _frames(tester);
expect(fake.clears, hasLength(1));
expect(fake.clears.single.reviewer, 'unverified:stefan@studio');
expect(fake.clears.single.reason, 'Testdaten verworfen');
await _teardownFrames(tester);
});
testWidgets('a legacy / CLI value without a marker is shown as-is and '
'never labelled either way', (tester) async {
final fake = installFakeHub();
fake.approvals = [_decided('ops-lead')];
await _openApprovals(tester, fake);
await tester.tap(find.text('Verlauf'));
await _frames(tester);
await tester.tap(find.textContaining('classify-and-file').last);
await _frames(tester);
final text = _allText(tester);
expect(text, contains('ops-lead'));
expect(
text,
isNot(contains('ops-lead (nicht überprüft)')),
reason: 'Studio does not know where an unmarked value came '
'from and must not classify it',
);
await _teardownFrames(tester);
});
}

View file

@ -0,0 +1,105 @@
// Reviewer identity the attribution Studio writes into the audit
// trail must never look better than it is.
//
// Background (usertest 2026-07-26, legal persona): the hub copies
// the client's `reviewer` string into `decided_by` unchecked, so a
// bare "stefan@studio" in an export reads like a proven identity
// while being an arbitrary client claim. Until the hub derives the
// value from the authenticated caller (contract:
// docs/reviewer-identity.md), every value Studio sends carries the
// `unverified:` marker and everything Studio *reads* is presented
// by what it actually proves.
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/reviewer_identity.dart';
void main() {
setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio');
tearDown(() => ReviewerIdentity.debugHandle = null);
group('what Studio sends', () {
test('every reviewer string leaves Studio marked as a claim', () {
expect(ReviewerIdentity.wire(), 'unverified:stefan@studio');
expect(ReviewerIdentity.wire('anna@ops'), 'unverified:anna@ops');
});
test('marking is idempotent — a value that already carries the '
'marker is not wrapped twice', () {
final once = ReviewerIdentity.wire('anna@ops');
expect(ReviewerIdentity.wire(once), once);
// The page computes the wire value for display AND HubService
// normalises again as a backstop; that must stay harmless.
expect(ReviewerIdentity.wire(ReviewerIdentity.wire(once)), once);
});
test('a blank handle falls back to the local one instead of '
'sending an empty attribution', () {
expect(ReviewerIdentity.wire(' '), 'unverified:stefan@studio');
expect(ReviewerIdentity.wire(''), 'unverified:stefan@studio');
});
test('surrounding whitespace never reaches the record', () {
expect(ReviewerIdentity.wire(' anna@ops '), 'unverified:anna@ops');
});
test('the local handle is a label, not an identity — but always '
'present', () {
ReviewerIdentity.debugHandle = null;
expect(ReviewerIdentity.localHandle, isNotEmpty);
expect(
ReviewerIdentity.wire(),
startsWith(kUnverifiedReviewerPrefix),
reason: 'no code path may send a bare handle',
);
});
});
group('what Studio reads back', () {
test('a marked value is shown by its name and flagged as a claim', () {
final parsed = ReviewerIdentity.parse('unverified:anna@ops');
expect(parsed.handle, 'anna@ops');
expect(parsed.trust, RecordedReviewerTrust.selfDeclared);
expect(parsed.isSelfDeclared, isTrue);
});
test('an unmarked value (legacy row, CLI decision) is never '
'presented as checked', () {
final parsed = ReviewerIdentity.parse('stefan@studio');
expect(parsed.handle, 'stefan@studio');
expect(parsed.trust, RecordedReviewerTrust.unknown);
expect(
parsed.isSelfDeclared,
isFalse,
reason: 'unknown provenance must not be labelled either way',
);
});
test('a marker with nothing behind it keeps the marker visible '
'rather than rendering an empty reviewer', () {
final parsed = ReviewerIdentity.parse('unverified:');
expect(parsed.handle, 'unverified:');
expect(parsed.isSelfDeclared, isTrue);
});
test('round-trips what Studio wrote', () {
final parsed = ReviewerIdentity.parse(ReviewerIdentity.wire());
expect(parsed.handle, 'stefan@studio');
expect(parsed.isSelfDeclared, isTrue);
});
});
group('assurance level per hub policy', () {
test('anonymous hub — nobody can be tied to a decision', () {
expect(reviewerAssuranceFor(true), ReviewerAssurance.anonymousHub);
});
test('auth-enabled hub — the access is checked, the name is not', () {
expect(reviewerAssuranceFor(false), ReviewerAssurance.accessControlled);
});
test('unreadable policy stays unknown — never optimistic', () {
expect(reviewerAssuranceFor(null), ReviewerAssurance.unknown);
});
});
}

View file

@ -230,6 +230,55 @@ class FakeHubService extends Fake implements HubService {
Future<List<Satellite>> listSatellites() =>
_async('listSatellites', () => const []);
/// Auth policy the pages read (approvals derives from it what a
/// reviewer attribution is worth). Default: the local-dev hub
/// static validator, anonymous calls allowed. Set to `null` to
/// script a hub whose policy Studio may not read; `authStatus`
/// then fails like a PermissionDenied.
HubAuthPolicy? authPolicy = const HubAuthPolicy(
validator: 'static',
anonymousAllowed: true,
tokens: [],
);
@override
Future<HubAuthPolicy> authStatus() => _async('authStatus', () {
final policy = authPolicy;
if (policy == null) {
throw const FakeGrpcError(7, 'admin scope required');
}
return policy;
});
/// Every decision that reached the wire, in order the guard for
/// "Studio never claims an identity it cannot prove" inspects the
/// reviewer string exactly as the hub would store it.
final List<({String id, String reviewer, String? reason})> decisions = [];
/// Reviewer + reason handed to the audit-log wipe, same purpose.
final List<({String reviewer, String reason})> clears = [];
@override
Future<({int purged, String channel})> clearEventLog({
required String reviewer,
required String reason,
}) => _async('clearEventLog', () {
clears.add((reviewer: reviewer, reason: reason));
return (purged: 0, channel: 'local');
});
@override
Future<void> approve(String id, String reviewer) => _async(
'approve',
() => decisions.add((id: id, reviewer: reviewer, reason: null)),
);
@override
Future<void> reject(String id, String reviewer, String reason) => _async(
'reject',
() => decisions.add((id: id, reviewer: reviewer, reason: reason)),
);
@override
Future<SystemAiStatus> systemAiStatus() => _async(
'systemAiStatus',