Refactor CI Results to use extension types (Part 1) (#211)

Introduce ResultRecord, ChangeRecord, and ConfigurationRecord extension types to wrap Firestore documents. Refactor builder.dart, upload_results_to_database.dart, reverted_changes.dart, status.dart, and corresponding tests to use these extension types instead of raw maps and SafeDocument. Keep tryjobs, reviews, commits, and comments using SafeDocument/Commit for now, to be refactored in subsequent PRs.

TAG=agy
CONV=a2878fa6-b79f-4b1d-9635-654148d5ceb2
diff --git a/builder/bin/upload_results_to_database.dart b/builder/bin/upload_results_to_database.dart
index 3faea3c..1e8adc7 100644
--- a/builder/bin/upload_results_to_database.dart
+++ b/builder/bin/upload_results_to_database.dart
@@ -14,18 +14,29 @@
 
 late BuildInfo buildInfo;
 
-Future<List<Map<String, dynamic>>> readChangedResults(File resultsFile) async {
-  final lines = (await resultsFile.readAsLines()).map(
-    (line) => jsonDecode(line)! as Map<String, dynamic>,
-  );
+Future<List<ChangeRecord>> readChangedResults(File resultsFile) async {
+  final lines = await resultsFile.readAsLines();
   if (lines.isEmpty) {
     print('Empty input results.json file');
     exit(1);
   }
-  buildInfo = BuildInfo.fromResult(lines.first, {
-    for (final line in lines) line[fConfiguration],
-  });
-  return lines.where(isChangedResult).toList();
+
+  ChangeRecord? firstChange;
+  final changes = <ChangeRecord>[];
+  final configurations = <String>{};
+  for (final change
+      in lines
+          .map(jsonDecode)
+          .cast<Map<String, dynamic>>()
+          .map(ChangeRecord.fromMap)) {
+    firstChange ??= change;
+    configurations.add(change.configuration);
+    if (change.isChangedResult) {
+      changes.add(change);
+    }
+  }
+  buildInfo = BuildInfo.fromResult(firstChange!, configurations);
+  return changes;
 }
 
 File fileOption(ArgResults options, String name) {
diff --git a/builder/lib/src/builder.dart b/builder/lib/src/builder.dart
index 16b1145..f41fa45 100644
--- a/builder/lib/src/builder.dart
+++ b/builder/lib/src/builder.dart
@@ -41,7 +41,7 @@
 
   void log(String string) => firestore.log(string);
 
-  Future<BuildStatus> process(List<Map<String, dynamic>> changes) async {
+  Future<BuildStatus> process(List<ChangeRecord> changes) async {
     log('store build commits info');
     await storeBuildCommitsInfo();
     log('update build info');
@@ -70,7 +70,7 @@
       }..removeWhere((key, value) => value.isEmpty);
     } catch (e) {
       log('Failed to fetch unapproved failures: $e');
-      status.unapprovedFailures = {'failed': []};
+      status.unapprovedFailures = {'failed': <ResultRecord>[]};
       status.success = false;
     }
     final report = [
@@ -153,23 +153,23 @@
     }
   }
 
-  Future<void> guardedStoreChange(Map<String, dynamic> change) =>
+  Future<void> guardedStoreChange(ChangeRecord change) =>
       testNameLock.guardedCall(storeChange, change);
 
-  Future<void> storeChange(Map<String, dynamic> change) async {
+  Future<void> storeChange(ChangeRecord change) async {
     countChanges++;
     await reviewsFetched;
-    transformChange(change);
-    final failure = isFailure(change);
+    change.transform();
+    final failure = change.isFailure;
     bool approved;
     var result = await firestore.findResult(change, startIndex, endIndex);
     var activeResults = await firestore.findActiveResults(
-      change['name'],
-      change['configuration'],
+      change.name,
+      change.configuration,
     );
     if (result == null) {
       final approvingIndex =
-          tryApprovals[testResult(change)] ??
+          tryApprovals[change.testResult] ??
           allRevertedChanges
               .firstWhereOrNull(
                 (revertedChange) => revertedChange.approveRevert(change),
@@ -189,14 +189,14 @@
         countApprovalsCopied++;
         if (countApprovalsCopied <= 10) {
           approvalMessages.add(
-            'Copied approval of result ${testResult(change)}',
+            'Copied approval of result ${change.testResult}',
           );
         }
       }
     } else {
       approved = await firestore.updateResult(
         result,
-        change['configuration'],
+        change.configuration,
         startIndex,
         endIndex,
         failure: failure,
@@ -206,14 +206,12 @@
 
     for (final activeResult in activeResults) {
       // Log error message if any expected invariants are violated
-      if (activeResult.getInt(fBlamelistEndIndex)! >= startIndex ||
-          !(activeResult
-                  .getList(fActiveConfigurations)
-                  ?.contains(change['configuration']) ??
+      if (activeResult.blamelistEndIndex >= startIndex ||
+          !(activeResult.activeConfigurations?.contains(change.configuration) ??
               false)) {
         log(
           'Unexpected active result when processing new change:\n'
-          'Active result: ${untagMap(activeResult.fields)}\n\n'
+          'Active result: $activeResult\n\n'
           'Change: $change\n\n'
           'approved: $approved',
         );
@@ -221,12 +219,12 @@
       // Removes the configuration from the list of active configurations.
       await firestore.removeActiveConfiguration(
         activeResult,
-        change['configuration'],
+        change.configuration,
       );
     }
   }
 
-  Future<List<SafeDocument>> unapprovedFailuresForConfiguration(
+  Future<List<ResultRecord>> unapprovedFailuresForConfiguration(
     String configuration,
   ) async {
     final failures = await firestore.findUnapprovedFailures(
@@ -237,37 +235,37 @@
     return failures;
   }
 
-  Future<void> addBlamelistCommits(SafeDocument result) async {
+  Future<void> addBlamelistCommits(ResultRecord result) async {
     final startCommit = await commitsCache.getCommitByIndex(
-      result.getInt(fBlamelistStartIndex)!,
+      result.blamelistStartIndex,
     );
-    result.fields[fBlamelistStartCommit] = taggedValue(startCommit.hash);
+    result.blamelistStartCommit = startCommit.hash;
     final endCommit = await commitsCache.getCommitByIndex(
-      result.getInt(fBlamelistEndIndex)!,
+      result.blamelistEndIndex,
     );
-    result.fields[fBlamelistEndCommit] = taggedValue(endCommit.hash);
+    result.blamelistEndCommit = endCommit.hash;
   }
 }
 
-Map<String, dynamic> constructResult(
-  Map<String, dynamic> change,
+ResultRecord constructResult(
+  ChangeRecord change,
   int startIndex,
   int endIndex, {
   required bool approved,
   int? landedReviewIndex,
   required bool failure,
 }) {
-  return {
-    fName: change[fName],
-    fResult: change[fResult],
-    fPreviousResult: change[fPreviousResult],
-    fExpected: change[fExpected],
+  return ResultRecord.fromMap({
+    fName: change.name,
+    fResult: change.result,
+    fPreviousResult: change.previousResult,
+    fExpected: change.expected,
     fBlamelistStartIndex: startIndex,
     fBlamelistEndIndex: endIndex,
     if (startIndex != endIndex && approved) fPinnedIndex: landedReviewIndex,
-    fConfigurations: <String>[change['configuration']],
+    fConfigurations: <String>[change.configuration],
     fApproved: approved,
     if (failure) fActive: true,
-    if (failure) fActiveConfigurations: <String>[change['configuration']],
-  };
+    if (failure) fActiveConfigurations: <String>[change.configuration],
+  });
 }
diff --git a/builder/lib/src/data.dart b/builder/lib/src/data.dart
new file mode 100644
index 0000000..b1baaa9
--- /dev/null
+++ b/builder/lib/src/data.dart
@@ -0,0 +1,91 @@
+// Copyright (c) 2026, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:googleapis/firestore/v1.dart';
+import 'firestore_helpers.dart';
+import 'result.dart';
+
+extension type ResultRecord(Document doc) {
+  ResultRecord.fromMap(Map<String, dynamic> data)
+    : this(Document(fields: taggedMap(data)));
+
+  String get name => doc.fields![fName]!.stringValue!;
+  set name(String value) => doc.fields![fName] = taggedValue(value);
+  String get result => doc.fields![fResult]!.stringValue!;
+  String get previousResult => doc.fields![fPreviousResult]!.stringValue!;
+  String get expected => doc.fields![fExpected]!.stringValue!;
+  int get blamelistStartIndex =>
+      int.parse(doc.fields![fBlamelistStartIndex]!.integerValue!);
+  int get blamelistEndIndex =>
+      int.parse(doc.fields![fBlamelistEndIndex]!.integerValue!);
+  bool get approved => doc.fields![fApproved]?.booleanValue ?? false;
+  bool get active => doc.fields![fActive]?.booleanValue ?? false;
+  List<String> get configurations => doc
+      .fields![fConfigurations]!
+      .arrayValue!
+      .values!
+      .map((v) => v.stringValue!)
+      .toList();
+  List<String>? get activeConfigurations {
+    final val = doc.fields![fActiveConfigurations];
+    if (val == null || val.nullValue != null) return null;
+    return val.arrayValue?.values?.map((v) => v.stringValue!).toList() ?? [];
+  }
+
+  int? get pinnedIndex =>
+      int.tryParse(doc.fields![fPinnedIndex]?.integerValue ?? '');
+  String? get blamelistStartCommit =>
+      doc.fields![fBlamelistStartCommit]?.stringValue;
+  String? get blamelistEndCommit =>
+      doc.fields![fBlamelistEndCommit]?.stringValue;
+
+  set blamelistStartCommit(String? value) =>
+      doc.fields![fBlamelistStartCommit] = taggedValue(value);
+  set blamelistEndCommit(String? value) =>
+      doc.fields![fBlamelistEndCommit] = taggedValue(value);
+
+  String get testResult => [name, result, previousResult, expected].join(' ');
+
+  Map<String, dynamic> toJson() =>
+      untagMap(doc.fields!).cast<String, dynamic>();
+}
+
+extension type ChangeRecord(Document doc) implements ResultRecord {
+  ChangeRecord.fromMap(Map<String, dynamic> data)
+    : this(Document(fields: taggedMap(data)));
+
+  String get configuration => doc.fields!['configuration']!.stringValue!;
+  String get builderName => doc.fields![fBuilderName]!.stringValue!;
+  int get buildNumber => int.parse(doc.fields![fBuildNumber]!.stringValue!);
+  String get commitHash => doc.fields![fCommitHash]!.stringValue!;
+  set commitHash(String value) => doc.fields![fCommitHash] = taggedValue(value);
+  String? get previousCommitHash =>
+      doc.fields![fPreviousCommitHash]?.stringValue;
+
+  bool get changed => doc.fields![fChanged]?.booleanValue ?? false;
+  bool get flaky => doc.fields![fFlaky]?.booleanValue ?? false;
+  bool get previousFlaky => doc.fields![fPreviousFlaky]?.booleanValue ?? false;
+  bool get matches => doc.fields![fMatches]?.booleanValue ?? false;
+
+  bool get isChangedResult => changed && (!flaky || !previousFlaky);
+
+  bool get isFailure => !matches && result != 'flaky';
+
+  void transform() {
+    if (doc.fields![fPreviousResult]?.stringValue == null) {
+      doc.fields![fPreviousResult] = taggedValue('new test');
+    }
+    if (doc.fields![fPreviousFlaky]?.booleanValue == true) {
+      doc.fields![fPreviousResult] = taggedValue('flaky');
+    }
+    if (doc.fields![fFlaky]?.booleanValue == true) {
+      doc.fields![fResult] = taggedValue('flaky');
+      doc.fields![fMatches] = taggedValue(false);
+    }
+  }
+}
+
+extension type ConfigurationRecord(Document doc) {
+  String get builder => doc.fields!['builder']!.stringValue!;
+}
diff --git a/builder/lib/src/firestore.dart b/builder/lib/src/firestore.dart
index 5139a18..e3a8713 100644
--- a/builder/lib/src/firestore.dart
+++ b/builder/lib/src/firestore.dart
@@ -64,7 +64,24 @@
       ..where = where
       ..orderBy = orderBy != null ? [orderBy] : null
       ..limit = limit;
-    return runQuery(query, parent: parent);
+    final results = await runQuery(query, parent: parent);
+    return results.map((d) => SafeDocument(d)).toList();
+  }
+
+  Future<List<T>> _query<T>({
+    required String from,
+    Filter? where,
+    Order? orderBy,
+    int? limit,
+    String? parent,
+  }) async {
+    final query = StructuredQuery()
+      ..from = inCollection(from)
+      ..where = where
+      ..orderBy = orderBy != null ? [orderBy] : null
+      ..limit = limit;
+    final results = await runQuery(query, parent: parent);
+    return results.cast<T>();
   }
 
   Future<Document> getDocument(String path) async {
@@ -100,7 +117,7 @@
   String get database => 'projects/$project/databases/(default)';
   String get documents => '$database/documents';
 
-  Future<List<SafeDocument>> runQuery(
+  Future<List<Document>> runQuery(
     StructuredQuery query, {
     String? parent,
   }) async {
@@ -113,8 +130,7 @@
     if (queryResponse.first.document == null) return [];
     documentsFetched += queryResponse.length;
     return [
-      for (final responseElement in queryResponse)
-        SafeDocument(responseElement.document!),
+      for (final responseElement in queryResponse) responseElement.document!,
     ];
   }
 
@@ -273,15 +289,15 @@
   }
 
   Future<String?> findResult(
-    Map<String, dynamic> change,
+    ResultRecord change,
     int startIndex,
     int endIndex,
   ) async {
-    final name = change['name'] as String;
-    final result = change['result'] as String;
-    final previousResult = change['previous_result'] as String;
-    final expected = change['expected'] as String;
-    final snapshot = await query(
+    final name = change.name;
+    final result = change.result;
+    final previousResult = change.previousResult;
+    final expected = change.expected;
+    final snapshot = await _query<ResultRecord>(
       from: 'results',
       orderBy: orderBy('blamelist_end_index', false),
       where: compositeFilter([
@@ -293,19 +309,19 @@
       limit: 5,
     );
 
-    bool blamelistIncludesChange(SafeDocument document) {
-      final before = endIndex < document.getInt('blamelist_start_index')!;
-      final after = startIndex > document.getInt('blamelist_end_index')!;
+    bool blamelistIncludesChange(ResultRecord document) {
+      final before = endIndex < document.blamelistStartIndex;
+      final after = startIndex > document.blamelistEndIndex;
       return !before && !after;
     }
 
-    return snapshot.firstWhereOrNull(blamelistIncludesChange)?.name;
+    return snapshot.firstWhereOrNull(blamelistIncludesChange)?.doc.name;
   }
 
-  Future<Document> storeResult(Map<String, dynamic> result) async {
+  Future<Document> storeResult(ResultRecord result) async {
     for (var retries = 0; true; retries++) {
       try {
-        final document = Document()..fields = taggedMap(result);
+        final document = result.doc;
         final createdDocument = await firestore.projects.databases.documents
             .createDocument(document, documents, 'results');
         documentsWritten++;
@@ -313,7 +329,7 @@
       } catch (e) {
         log('Failed creating document at path $documents/results.');
         log('Retrying in 1000 ms.');
-        log('Document contents: ${jsonEncode(result)}\n');
+        log('Document contents: ${jsonEncode(result.toJson())}\n');
         log('$e');
         if (retries > 2) {
           rethrow;
@@ -325,7 +341,7 @@
 
   Future<bool> updateResult(
     String result,
-    String? configuration,
+    String configuration,
     int startIndex,
     int endIndex, {
     required bool failure,
@@ -333,12 +349,11 @@
     late bool approved;
     await retryCommit(() async {
       final document = await getDocument(result);
-      final data = SafeDocument(document);
-      // Allow missing 'approved' field during transition period.
-      approved = data.getBool('approved') ?? false;
+      final data = ResultRecord(document);
+      approved = data.approved;
       // Add the new configuration and narrow the blamelist.
-      final newStart = max(startIndex, data.getInt('blamelist_start_index')!);
-      final newEnd = min(endIndex, data.getInt('blamelist_end_index')!);
+      final newStart = max(startIndex, data.blamelistStartIndex);
+      final newEnd = min(endIndex, data.blamelistEndIndex);
       // TODO(karlklose): check for pinned, and remove the pin if the new range
       // doesn't include it?
       final updates = [
@@ -394,35 +409,33 @@
 
   /// Returns all results which are either pinned to or have a range that is
   /// this single index. // TODO: rename this function
-  Future<List<Map<String, Value>>> findRevertedChanges(int index) async {
-    final pinnedResults = await query(
+  Future<List<ResultRecord>> findRevertedChanges(int index) async {
+    final results = await _query<ResultRecord>(
       from: 'results',
       where: fieldEquals('pinned_index', index),
     );
-    final results = pinnedResults.map((response) => response.fields).toList();
-    final unpinnedResults = await query(
+    final unpinnedResults = await _query<ResultRecord>(
       from: 'results',
       where: fieldEquals('blamelist_end_index', index),
     );
     for (final data in unpinnedResults) {
-      if (data.getInt('blamelist_start_index') == index &&
-          data.isNull('pinned_index')) {
-        results.add(data.fields);
+      if (data.blamelistStartIndex == index && data.pinnedIndex == null) {
+        results.add(data);
       }
     }
     return results;
   }
 
   Future<bool> storeTryChange(
-    Map<String, dynamic> change,
+    ChangeRecord change,
     int review,
     int patchset,
   ) async {
-    final name = change['name'] as String;
-    final result = change['result'] as String;
-    final expected = change['expected'] as String;
-    final previousResult = change['previous_result'] as String;
-    final configuration = change['configuration'] as String;
+    final name = change.name;
+    final result = change.result;
+    final expected = change.expected;
+    final previousResult = change.previousResult;
+    final configuration = change.configuration;
 
     // Find an existing result record for this test on this patchset.
     final responses = await query(
@@ -517,23 +530,23 @@
   /// Removes [configuration] from the active configurations and marks the
   /// active result inactive when we remove the last active config.
   Future<void> removeActiveConfiguration(
-    SafeDocument activeResult,
-    String? configuration,
+    ResultRecord activeResult,
+    String configuration,
   ) async {
-    final configurations = activeResult.getList('active_configurations')!;
+    final configurations = activeResult.activeConfigurations!;
     assert(configurations.contains(configuration));
     await removeArrayEntry(
-      activeResult,
+      activeResult.doc,
       'active_configurations',
       taggedValue(configuration),
     );
-    final document = await getDocument(activeResult.name);
-    activeResult = SafeDocument(document);
-    if (activeResult.getList('active_configurations')?.isEmpty == true) {
-      activeResult.fields.remove('active_configurations');
-      activeResult.fields.remove('active');
+    final document = await getDocument(activeResult.doc.name!);
+    activeResult = ResultRecord(document);
+    if (activeResult.activeConfigurations?.isEmpty == true) {
+      activeResult.doc.fields!.remove('active_configurations');
+      activeResult.doc.fields!.remove('active');
       final write = Write()
-        ..update = activeResult.toDocument()
+        ..update = activeResult.doc
         ..updateMask = (DocumentMask()
           ..fieldPaths = ['active', 'active_configurations']);
       await _executeWrite([write]);
@@ -541,7 +554,7 @@
   }
 
   Future<void> removeArrayEntry(
-    SafeDocument document,
+    Document document,
     String fieldName,
     Value entry,
   ) async {
@@ -557,11 +570,11 @@
     ]);
   }
 
-  Future<List<SafeDocument>> findActiveResults(
+  Future<List<ResultRecord>> findActiveResults(
     String name,
     String configuration,
   ) async {
-    final results = await query(
+    final results = await _query<ResultRecord>(
       from: 'results',
       where: compositeFilter([
         arrayContains('active_configurations', configuration),
@@ -580,11 +593,11 @@
     return results;
   }
 
-  Future<List<SafeDocument>> findUnapprovedFailures(
+  Future<List<ResultRecord>> findUnapprovedFailures(
     String configuration,
     int limit,
   ) async {
-    final results = await query(
+    final results = await _query<ResultRecord>(
       from: 'results',
       where: compositeFilter([
         arrayContains('active_configurations', configuration),
diff --git a/builder/lib/src/firestore_helpers.dart b/builder/lib/src/firestore_helpers.dart
index 1ea01fb..9e1903f 100644
--- a/builder/lib/src/firestore_helpers.dart
+++ b/builder/lib/src/firestore_helpers.dart
@@ -4,6 +4,8 @@
 
 import 'package:googleapis/firestore/v1.dart';
 
+export 'data.dart';
+
 class SafeDocument {
   final String name;
   final Map<String, Value> fields;
diff --git a/builder/lib/src/result.dart b/builder/lib/src/result.dart
index 526c61f..edcca29 100644
--- a/builder/lib/src/result.dart
+++ b/builder/lib/src/result.dart
@@ -7,6 +7,8 @@
 
 import 'package:googleapis/firestore/v1.dart' show Value;
 
+import 'firestore_helpers.dart';
+
 // Field names of Result document fields
 const fName = 'name';
 const fResult = 'result';
@@ -44,26 +46,6 @@
 const fRevertOf = 'revert_of';
 const fRelandOf = 'reland_of';
 
-bool isChangedResult(Map<String, dynamic> change) =>
-    change[fChanged] && (!change[fFlaky] || !change[fPreviousFlaky]);
-
-/// Whether the change will be marked as an active failure.
-/// New flaky tests will not be marked active, so they will appear in the
-/// results feed "all", but not turn the builder red
-bool isFailure(Map<String, dynamic> change) =>
-    !change[fMatches] && change[fResult] != 'flaky';
-
-void transformChange(Map<String, dynamic> change) {
-  change[fPreviousResult] ??= 'new test';
-  if (change[fPreviousFlaky]) {
-    change[fPreviousResult] = 'flaky';
-  }
-  if (change[fFlaky]) {
-    change[fResult] = 'flaky';
-    change[fMatches] = false;
-  }
-}
-
 String? fromStringOrValue(dynamic value) {
   return value is Value ? value.stringValue : value;
 }
@@ -86,17 +68,17 @@
   final String? previousCommitHash;
   final Set<String> configurations;
 
-  BuildInfo(Map<String, dynamic> result, this.configurations)
-    : builderName = result[fBuilderName],
-      buildNumber = int.parse(result[fBuildNumber]),
-      commitRef = result[fCommitHash],
-      previousCommitHash = result[fPreviousCommitHash];
+  BuildInfo(ChangeRecord result, this.configurations)
+    : builderName = result.builderName,
+      buildNumber = result.buildNumber,
+      commitRef = result.commitHash,
+      previousCommitHash = result.previousCommitHash;
 
   factory BuildInfo.fromResult(
-    Map<String, dynamic> result,
+    ChangeRecord result,
     Set<String> configurations,
   ) {
-    final commitRef = result[fCommitHash];
+    final commitRef = result.commitHash;
     final match = commitRefRegExp.matchAsPrefix(commitRef);
     if (match == null) {
       return BuildInfo(result, configurations);
@@ -122,10 +104,10 @@
   final locks = <String, Future<void>>{};
 
   Future<void> guardedCall(
-    Future<void> Function(Map<String, dynamic> change) f,
-    Map<String, dynamic> change,
+    Future<void> Function(ChangeRecord change) f,
+    ChangeRecord change,
   ) async {
-    final name = change[fName]!;
+    final name = change.name;
     while (locks.containsKey(name)) {
       await locks[name];
     }
diff --git a/builder/lib/src/reverted_changes.dart b/builder/lib/src/reverted_changes.dart
index 5085ebd..870cd17 100644
--- a/builder/lib/src/reverted_changes.dart
+++ b/builder/lib/src/reverted_changes.dart
@@ -3,10 +3,8 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:collection/collection.dart';
-import 'package:googleapis/firestore/v1.dart';
 
 import 'firestore.dart';
-import 'result.dart';
 
 Future<RevertedChanges> getRevertedChanges(
   String reverted,
@@ -23,15 +21,15 @@
     index,
     revertIndex,
     changes,
-    groupBy(changes, (change) => getValue(change[fName]!)),
+    groupBy(changes, (change) => change.name),
   );
 }
 
 class RevertedChanges {
   final int index;
   final int revertIndex;
-  final List<Map<String, Value>> changes;
-  final Map<String, List<Map<String, Value>>> changesForTest;
+  final List<ResultRecord> changes;
+  final Map<String, List<ResultRecord>> changesForTest;
 
   RevertedChanges(
     this.index,
@@ -40,12 +38,10 @@
     this.changesForTest,
   );
 
-  bool approveRevert(Map<String, dynamic> revert) {
-    final reverted = changesForTest[revert[fName]];
-    return isFailure(revert) &&
+  bool approveRevert(ChangeRecord revert) {
+    final reverted = changesForTest[revert.name];
+    return revert.isFailure &&
         reverted != null &&
-        reverted.any(
-          (change) => revert[fResult] == getValue(change[fPreviousResult]!),
-        );
+        reverted.any((change) => revert.result == change.previousResult);
   }
 }
diff --git a/builder/lib/src/status.dart b/builder/lib/src/status.dart
index 5acc843..d93e7c3 100644
--- a/builder/lib/src/status.dart
+++ b/builder/lib/src/status.dart
@@ -5,13 +5,12 @@
 import 'dart:convert' show jsonEncode;
 
 import 'firestore.dart';
-import 'result.dart';
 
 class BuildStatus {
   static const unapprovedFailuresLimit = 10;
   bool success = true;
   bool truncatedResults = false;
-  Map<String, List<SafeDocument>> unapprovedFailures = {};
+  Map<String, List<ResultRecord>> unapprovedFailures = {};
 
   String toJson() {
     return jsonEncode({
@@ -39,13 +38,13 @@
   }
 }
 
-String resultLine(SafeDocument result) {
-  final name = result.getString(fName);
-  final previous = result.getString(fPreviousResult);
-  final current = result.getString(fResult);
-  final expected = result.getString(fExpected);
-  final start = result.getString(fBlamelistStartCommit);
-  final end = result.getString(fBlamelistEndCommit);
+String resultLine(ResultRecord result) {
+  final name = result.name;
+  final previous = result.previousResult;
+  final current = result.result;
+  final expected = result.expected;
+  final start = result.blamelistStartCommit!;
+  final end = result.blamelistEndCommit!;
   final range = start == end
       ? start.substring(0, 6)
       : '${start.substring(0, 6)}..${end.substring(0, 6)}';
diff --git a/builder/lib/src/tryjob.dart b/builder/lib/src/tryjob.dart
index 682f182..15fefc9 100644
--- a/builder/lib/src/tryjob.dart
+++ b/builder/lib/src/tryjob.dart
@@ -27,15 +27,15 @@
   bool get hasTooManyPassingChanges => passes > maxReportedSuccesses;
   bool get hasTooManyFailingChanges => failures > maxReportedFailures;
 
-  void count(Map<String, dynamic> change) {
+  void count(ChangeRecord change) {
     ++changes;
-    change[fMatches] ? ++passes : ++failures;
-    if (change[fFlaky] && !change[fPreviousFlaky]) ++newFlakes;
+    change.matches ? ++passes : ++failures;
+    if (change.flaky && !change.previousFlaky) ++newFlakes;
   }
 
-  bool isNotReported(Map<String, dynamic> change) {
-    if (change[fMatches] && hasTooManyPassingChanges ||
-        !change[fMatches] && hasTooManyFailingChanges) {
+  bool isNotReported(ChangeRecord change) {
+    if (change.matches && hasTooManyPassingChanges ||
+        !change.matches && hasTooManyFailingChanges) {
       hasTruncatedChanges = true;
       return true;
     }
@@ -90,17 +90,17 @@
     ).update();
   }
 
-  bool isNotLandedResult(Map<String, dynamic> change) {
-    return change[fResult] !=
-        lastLandedResultByName[change[fName]]?.getString(fResult);
+  bool isNotLandedResult(ChangeRecord change) {
+    return change.result !=
+        lastLandedResultByName[change.name]?.getString(fResult);
   }
 
-  Future<BuildStatus> process(List<Map<String, dynamic>> results) async {
+  Future<BuildStatus> process(List<ChangeRecord> results) async {
     await update();
     log('storing ${results.length} change(s)');
-    final resultsByConfiguration = groupBy<Map<String, dynamic>, String>(
+    final resultsByConfiguration = groupBy<ChangeRecord, String>(
       results,
-      (result) => result['configuration'],
+      (result) => result.configuration,
     );
 
     for (final configuration in resultsByConfiguration.keys) {
@@ -141,11 +141,11 @@
     return status;
   }
 
-  Future<void> guardedStoreChange(Map<String, dynamic> change) =>
+  Future<void> guardedStoreChange(ChangeRecord change) =>
       testNameLock.guardedCall(storeChange, change);
 
-  Future<void> storeChange(Map<String, dynamic> change) async {
-    transformChange(change);
+  Future<void> storeChange(ChangeRecord change) async {
+    change.transform();
     counter.count(change);
     if (counter.isNotReported(change)) return;
     final approved = await firestore.storeTryChange(
@@ -153,7 +153,7 @@
       info.review,
       info.patchset,
     );
-    if (!approved && isFailure(change)) {
+    if (!approved && change.isFailure) {
       counter.unapprovedFailures++;
       success = false;
     }
diff --git a/builder/test/approvals_test.dart b/builder/test/approvals_test.dart
index e008a75..2c6b518 100644
--- a/builder/test/approvals_test.dart
+++ b/builder/test/approvals_test.dart
@@ -56,9 +56,9 @@
 final buildersToRemove = <String>{};
 final testsToRemove = <String>{};
 
-void registerChangeForDeletion(Map<String, dynamic> change) {
-  buildersToRemove.add(change['builder_name'] as String);
-  testsToRemove.add(change['name'] as String);
+void registerChangeForDeletion(ChangeRecord change) {
+  buildersToRemove.add(change.builderName);
+  testsToRemove.add(change.name);
 }
 
 Future<void> removeBuildersAndResults() async {
@@ -161,10 +161,10 @@
 
 Tryjob makeTryjob(
   String name,
-  Map<String, dynamic> firstChange, {
+  ChangeRecord firstChange, {
   String? baseCommit,
 }) => Tryjob(
-  BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]})
+  BuildInfo.fromResult(firstChange, <String>{firstChange.configuration})
       as TryBuildInfo,
   'bbID_$name',
   baseCommit ?? commit4,
@@ -174,7 +174,7 @@
 );
 
 const newFailure = 'Pass/RuntimeError/Pass';
-Map<String, dynamic> makeTryChange(
+ChangeRecord makeTryChange(
   String name,
   String result,
   String patchsetRef, {
@@ -206,11 +206,12 @@
     'bot_name': 'fake_bot_name',
     'previous_build_number': '306',
   };
-  registerChangeForDeletion(change);
-  return change;
+  final record = ChangeRecord.fromMap(change);
+  registerChangeForDeletion(record);
+  return record;
 }
 
-Map<String, dynamic> makeChange(
+ChangeRecord makeChange(
   String name,
   String result,
   String commit,
@@ -218,16 +219,16 @@
   String? testName,
 }) {
   final change = {
-    ...makeTryChange(name, result, '', testName: testName),
+    ...makeTryChange(name, result, '', testName: testName).toJson(),
     'commit_hash': commit,
     'previous_commit_hash': previousCommit,
   };
-  return change;
+  return ChangeRecord.fromMap(change);
 }
 
-Build makeBuild(String commit, Map<String, dynamic> change) {
+Build makeBuild(String commit, ChangeRecord change) {
   return Build(
-    BuildInfo.fromResult(change, <String>{change[fConfiguration]}),
+    BuildInfo.fromResult(change, <String>{change.configuration}),
     commitsCache,
     firestore,
   );
@@ -290,8 +291,10 @@
     await firestore.approveResult(documents.single.toDocument());
 
     final change3 = makeChange('approvals', newFailure, commit1, commit4);
-    final change3a = makeChange('approvals', newFailure, commit1, commit4)
-      ..['configuration'] = 'second_approvals_configuration';
+    final change3a = ChangeRecord.fromMap({
+      ...change3.toJson(),
+      'configuration': 'second_approvals_configuration',
+    });
     final change4 = makeChange(
       'approvals',
       newFailure,
@@ -303,7 +306,7 @@
       commit1,
       change3,
     ).process([change3, change3a, change4]);
-    await checkBuild(change3['builder_name'], index1, success: true);
+    await checkBuild(change3.builderName, index1, success: true);
     expect(status.success, isTrue);
     expect(status.truncatedResults, isFalse);
     await checkResult(change3, index3, index1, {'approved': true});
@@ -317,14 +320,14 @@
       testName: 'approvals',
     );
     final status2 = await makeBuild(commit1, change5).process([change5]);
-    await checkBuild(change5['builder_name'], index1, success: true);
+    await checkBuild(change5.builderName, index1, success: true);
     expect(status2.success, isTrue);
     await checkResult(change5, index2, index1, {
       'approved': true,
       'configurations': [
-        change3['configuration'],
-        change3a['configuration'],
-        change5['configuration'],
+        change3.configuration,
+        change3a.configuration,
+        change5.configuration,
       ],
     });
   });
@@ -417,7 +420,7 @@
 }
 
 Future<void> checkResult(
-  Map<String, dynamic> change,
+  ChangeRecord change,
   String startIndex,
   String endIndex,
   Map<String, dynamic> expected,
@@ -431,7 +434,7 @@
   expect(resultName, isNotNull);
   final resultDocument = await firestore.getDocument(resultName!);
   final data = untagMap(resultDocument.fields!);
-  expect(data[fName], change[fName]);
+  expect(data[fName], change.name);
   expect(data[fBlamelistStartIndex], int.parse(startIndex));
   expect(data[fBlamelistEndIndex], int.parse(endIndex));
   expect(
diff --git a/builder/test/builder_test.dart b/builder/test/builder_test.dart
index f9a9cbc..f670b6b 100644
--- a/builder/test/builder_test.dart
+++ b/builder/test/builder_test.dart
@@ -43,28 +43,33 @@
 final buildersToRemove = <String>{};
 final testsToRemove = <String>{};
 
-void registerChangeForDeletion(Map<String, dynamic> change) {
-  buildersToRemove.add(change['builder_name']!);
-  testsToRemove.add(change['name']!);
+void registerChangeForDeletion(ChangeRecord change) {
+  buildersToRemove.add(change.builderName);
+  testsToRemove.add(change.name);
 }
 
 Future<void> removeBuildersAndResults() async {
-  Future<void> deleteDocuments(List<SafeDocument> documents) async {
+  Future<void> deleteDocuments(List<Document> documents) async {
     for (final document in documents) {
-      await firestore.deleteDocument(document.name);
+      await firestore.deleteDocument(document.name!);
     }
   }
 
   for (final test in testsToRemove) {
     await deleteDocuments(
-      await firestore.query(from: 'results', where: fieldEquals(fName, test)),
+      await firestore.runQuery(
+        StructuredQuery()
+          ..from = inCollection('results')
+          ..where = fieldEquals(fName, test),
+      ),
     );
   }
   for (final builder in buildersToRemove) {
     await deleteDocuments(
-      await firestore.query(
-        from: 'builds',
-        where: fieldEquals('builder', builder),
+      await firestore.runQuery(
+        StructuredQuery()
+          ..from = inCollection('builds')
+          ..where = fieldEquals('builder', builder),
       ),
     );
   }
@@ -84,23 +89,26 @@
   );
 }
 
-Build makeBuild(Map<String, dynamic> firstChange) => Build(
-  BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]}),
+Build makeBuild(ChangeRecord firstChange) => Build(
+  BuildInfo.fromResult(firstChange, <String>{firstChange.configuration}),
   commitsCache,
   firestore,
 );
 
-Map<String, dynamic> makeChange(
+ChangeRecord makeChange(
   String name,
   String result, {
   bool flaky = false,
+  String? testName,
+  String? commitHash,
+  String? previousCommitHash,
 }) {
   final results = result.split('/');
   final previous = results[0];
   final current = results[1];
   final expected = results[2];
   final change = {
-    fName: '${name}_test',
+    fName: testName ?? '${name}_test',
     fConfiguration: '${name}_configuration',
     'suite': 'unused_field',
     'test_name': 'unused_field',
@@ -110,25 +118,34 @@
     fExpected: expected,
     fMatches: current == expected,
     fChanged: current != previous,
-    fCommitHash: commit.hash,
+    fCommitHash: commitHash ?? commit.hash,
     'commit_time': 1583906489,
     fBuildNumber: '99997',
     fBuilderName: 'builder_$name',
     fFlaky: flaky,
     fPreviousFlaky: false,
-    fPreviousCommitHash: previousCommit.hash,
+    fPreviousCommitHash: previousCommitHash ?? previousCommit.hash,
     'previous_commit_time': 1583906489,
     'bot_name': 'fake_bot_name',
     'previous_build_number': '306',
   };
-  registerChangeForDeletion(change);
-  return change;
+  final record = ChangeRecord.fromMap(change);
+  registerChangeForDeletion(record);
+  return record;
 }
 
-Map<String, dynamic> makePreviousChange(String name, String result) {
-  return makeChange(name, result)
-    ..[fCommitHash] = previousBlamelistEndCommit.hash
-    ..[fPreviousCommitHash] = previousBuildPreviousCommit.hash;
+ChangeRecord makePreviousChange(
+  String name,
+  String result, {
+  String? testName,
+}) {
+  return makeChange(
+    name,
+    result,
+    testName: testName,
+    commitHash: previousBlamelistEndCommit.hash,
+    previousCommitHash: previousBuildPreviousCommit.hash,
+  );
 }
 
 void main() async {
@@ -154,8 +171,8 @@
     final failingPreviousChange = makePreviousChange(
       'failure',
       'Pass/RuntimeError/Pass',
-    )..[fName] = 'previous_failure_test';
-    registerChangeForDeletion(failingPreviousChange); // Name changed.
+      testName: 'previous_failure_test',
+    );
     final previousBuild = makeBuild(failingPreviousChange);
     final previousStatus = await previousBuild.process([failingPreviousChange]);
     expect(previousStatus.success, isFalse);
@@ -170,21 +187,18 @@
     expect(status.unapprovedFailures.keys, contains('failure_configuration'));
     final failures = status.unapprovedFailures['failure_configuration']!;
     final previousFailure = failures
-        .where((failure) => failure.getString(fName) == 'previous_failure_test')
+        .where((failure) => failure.name == 'previous_failure_test')
         .single;
     final failure = failures
-        .where((failure) => failure.getString(fName) == 'failure_test')
+        .where((failure) => failure.name == 'failure_test')
         .single;
+    expect(previousFailure.blamelistEndCommit, previousBlamelistEndCommit.hash);
     expect(
-      previousFailure.getStringOrNull(fBlamelistEndCommit),
-      previousBlamelistEndCommit.hash,
-    );
-    expect(
-      previousFailure.getStringOrNull(fBlamelistStartCommit),
+      previousFailure.blamelistStartCommit,
       previousBlamelistStartCommit.hash,
     );
-    expect(failure.getStringOrNull(fBlamelistEndCommit), commit.hash);
-    expect(failure.getStringOrNull(fBlamelistStartCommit), commit.hash);
+    expect(failure.blamelistEndCommit, commit.hash);
+    expect(failure.blamelistStartCommit, commit.hash);
     final message = status.toJson();
     expect(message, matches(r'There are unapproved failures\\n'));
     expect(
@@ -208,7 +222,7 @@
       'RuntimeError/RuntimeError/Pass',
     );
     final unchangedBuild = makeBuild(unchangedChange);
-    final unchangedStatus = await unchangedBuild.process([]);
+    final unchangedStatus = await unchangedBuild.process(<ChangeRecord>[]);
     expect(unchangedStatus.success, isTrue);
     expect(unchangedStatus.unapprovedFailures, isNotEmpty);
     expect(
@@ -218,11 +232,12 @@
   });
 
   test('existing approved failure', () async {
-    final failingOtherConfigurationChange =
-        makeChange('other', 'Pass/RuntimeError/Pass')
-          ..[fName] = 'approved_failure_test'
-          ..[fPreviousCommitHash] = previousBuildPreviousCommit.hash;
-    registerChangeForDeletion(failingOtherConfigurationChange);
+    final failingOtherConfigurationChange = makeChange(
+      'other',
+      'Pass/RuntimeError/Pass',
+      testName: 'approved_failure_test',
+      previousCommitHash: previousBuildPreviousCommit.hash,
+    );
     final otherConfigurationBuild = makeBuild(failingOtherConfigurationChange);
     final otherStatus = await otherConfigurationBuild.process([
       failingOtherConfigurationChange,
@@ -237,9 +252,9 @@
       'approved_failure_test',
       'other_configuration',
     )).single;
-    expect(result.getInt(fBlamelistEndIndex), index);
-    expect(result.getInt(fBlamelistStartIndex), previousBlamelistStart);
-    await firestore.approveResult(result.toDocument());
+    expect(result.blamelistEndIndex, index);
+    expect(result.blamelistStartIndex, previousBlamelistStart);
+    await firestore.approveResult(result.doc);
     final failingChange = makeChange(
       'approved_failure',
       'Pass/RuntimeError/Pass',
@@ -255,7 +270,7 @@
     )).single;
     expect(result.name, changedResult.name);
     // Check blamelist narrowing.
-    expect(changedResult.getInt(fBlamelistEndIndex), index);
-    expect(changedResult.getInt(fBlamelistStartIndex), index);
+    expect(changedResult.blamelistEndIndex, index);
+    expect(changedResult.blamelistStartIndex, index);
   });
 }
diff --git a/builder/test/fakes.dart b/builder/test/fakes.dart
index c06d2fc..47a3630 100644
--- a/builder/test/fakes.dart
+++ b/builder/test/fakes.dart
@@ -18,12 +18,12 @@
   final firestore = FirestoreServiceFake();
   late CommitsCache commitsCache;
   late Build builder;
-  Map<String, dynamic> firstChange;
+  ChangeRecord firstChange;
 
   BuilderTest(this.firstChange) {
     commitsCache = CommitsCache(firestore, client);
     builder = Build(
-      BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]}),
+      BuildInfo.fromResult(firstChange, <String>{firstChange.configuration}),
       commitsCache,
       firestore,
     );
@@ -38,8 +38,8 @@
     // Test expectations
   }
 
-  Future<void> storeChange(Map<String, dynamic> change) async {
-    return builder.storeChange(change);
+  Future<void> storeChange(ChangeRecord change) async {
+    await builder.storeChange(change);
   }
 }
 
@@ -89,7 +89,7 @@
 
   @override
   Future<String?> findResult(
-    Map<String, dynamic> change,
+    ResultRecord change,
     int startIndex,
     int endIndex,
   ) {
@@ -97,10 +97,10 @@
     int? resultEndIndex;
     for (final entry in results.entries) {
       final result = entry.value;
-      if (result[fName] == change[fName] &&
-          result[fResult] == change[fResult] &&
-          result[fExpected] == change[fExpected] &&
-          result[fPreviousResult] == change[fPreviousResult] &&
+      if (result[fName] == change.name &&
+          result[fResult] == change.result &&
+          result[fExpected] == change.expected &&
+          result[fPreviousResult] == change.previousResult &&
           result[fBlamelistEndIndex] >= startIndex &&
           result[fBlamelistStartIndex] <= endIndex) {
         if (resultEndIndex == null ||
@@ -114,7 +114,7 @@
   }
 
   @override
-  Future<List<SafeDocument>> findActiveResults(
+  Future<List<ResultRecord>> findActiveResults(
     String? name,
     String? configuration,
   ) async {
@@ -123,7 +123,7 @@
         if (results[id]![fName] == name &&
             results[id]![fActiveConfigurations] != null &&
             results[id]![fActiveConfigurations].contains(configuration))
-          SafeDocument(
+          ResultRecord(
             Document()
               ..fields = taggedMap(Map.from(results[id]!))
               ..name = id,
@@ -132,19 +132,19 @@
   }
 
   @override
-  Future<Document> storeResult(Map<String, dynamic> result) async {
+  Future<Document> storeResult(ResultRecord result) async {
     final id = 'resultDocumentID$addedResultIdCounter';
     addedResultIdCounter++;
-    results[id] = result;
+    results[id] = result.toJson();
     return Document()
-      ..fields = taggedMap(result)
+      ..fields = result.doc.fields
       ..name = id;
   }
 
   @override
   Future<bool> updateResult(
     String resultId,
-    String? configuration,
+    String configuration,
     int startIndex,
     int endIndex, {
     required bool failure,
@@ -158,7 +158,7 @@
 
     result[fBlamelistEndIndex] = min<int>(endIndex, result[fBlamelistEndIndex]);
     if (!result[fConfigurations].contains(configuration)) {
-      result[fConfigurations] = List<String?>.from(result[fConfigurations])
+      result[fConfigurations] = List<String>.from(result[fConfigurations])
         ..add(configuration)
         ..sort();
     }
@@ -166,7 +166,7 @@
       result[fActive] = true;
       if (!result[fActiveConfigurations].contains(configuration)) {
         result[fActiveConfigurations] =
-            List<String?>.from(result[fActiveConfigurations])
+            List<String>.from(result[fActiveConfigurations])
               ..add(configuration)
               ..sort();
       }
@@ -177,21 +177,21 @@
 
   @override
   Future<void> removeActiveConfiguration(
-    SafeDocument activeResult,
+    ResultRecord activeResult,
     String? configuration,
   ) async {
-    final result = Map<String, dynamic>.from(results[activeResult.name]!);
+    final result = Map<String, dynamic>.from(results[activeResult.doc.name]!);
     result[fActiveConfigurations] = List.from(result[fActiveConfigurations])
       ..remove(configuration);
     if (result[fActiveConfigurations].isEmpty) {
       result.remove(fActiveConfigurations);
       result.remove(fActive);
     }
-    results[activeResult.name] = result;
+    results[activeResult.doc.name!] = result;
   }
 
   @override
-  Future<List<Map<String, Value>>> findRevertedChanges(int index) async {
+  Future<List<ResultRecord>> findRevertedChanges(int index) async {
     return results.values
         .where(
           (change) =>
@@ -199,7 +199,7 @@
               (change[fBlamelistStartIndex] == index &&
                   change[fBlamelistEndIndex] == index),
         )
-        .map(taggedMap)
+        .map(ResultRecord.fromMap)
         .toList();
   }
 
diff --git a/builder/test/firestore_test.dart b/builder/test/firestore_test.dart
index 22d79b7..dd073b8 100644
--- a/builder/test/firestore_test.dart
+++ b/builder/test/firestore_test.dart
@@ -55,27 +55,29 @@
 
     tearDown(() async {
       // Delete database records created by the tests.
-      var snapshot = await firestore.query(
-        from: 'try_builds',
-        where: fieldEquals('review', testReview),
+      var snapshot = await firestore.runQuery(
+        StructuredQuery()
+          ..from = inCollection('try_builds')
+          ..where = fieldEquals('review', testReview),
       );
       for (final doc in snapshot) {
-        await firestore.deleteDocument(doc.name);
+        await firestore.deleteDocument(doc.name!);
       }
 
-      snapshot = await firestore.query(
-        from: 'patchsets',
+      snapshot = await firestore.runQuery(
+        StructuredQuery()..from = inCollection('patchsets'),
         parent: 'reviews/$testReview/',
       );
       for (final doc in snapshot) {
-        await firestore.deleteDocument(doc.name);
+        await firestore.deleteDocument(doc.name!);
       }
-      snapshot = await firestore.query(
-        from: 'results',
-        where: fieldEquals('name', removeActiveConfigurationTestName),
+      snapshot = await firestore.runQuery(
+        StructuredQuery()
+          ..from = inCollection('results')
+          ..where = fieldEquals('name', removeActiveConfigurationTestName),
       );
       for (final doc in snapshot) {
-        await firestore.deleteDocument(doc.name);
+        await firestore.deleteDocument(doc.name!);
       }
       await firestore.deleteDocument(testReviewDocument);
     });
@@ -83,7 +85,9 @@
     test('Remove active configuration', () async {
       // Remove the two active configurations from createdResultDocument,
       // checking that the document is updated correctly at each stage.
-      final createdResultDocument = await firestore.storeResult(createdResult);
+      final createdResultDocument = await firestore.storeResult(
+        ResultRecord.fromMap(createdResult),
+      );
       final name = removeActiveConfigurationTestName;
 
       var foundActiveResults = await firestore.findActiveResults(
@@ -91,7 +95,7 @@
         testConfiguration,
       );
       var activeResult = foundActiveResults.single;
-      expect(createdResultDocument.name, activeResult.name);
+      expect(createdResultDocument.name, activeResult.doc.name);
 
       await firestore.removeActiveConfiguration(
         activeResult,
@@ -108,7 +112,7 @@
       );
       activeResult = foundActiveResults.single;
 
-      expect(activeResult.fields, contains('active'));
+      expect(activeResult.doc.fields!, contains('active'));
       await firestore.removeActiveConfiguration(
         activeResult,
         'configuration 2',
@@ -148,7 +152,7 @@
         2,
         3,
       );
-      final tryResult = {
+      final tryResult = ChangeRecord.fromMap({
         'review': testReview,
         'configuration': 'test_configuration',
         'name': 'test_suite/test_name',
@@ -156,39 +160,47 @@
         'result': 'RuntimeError',
         'expected': 'Pass',
         'previous_result': 'Pass',
-      };
+      });
       await firestore.storeTryChange(tryResult, testReview, 1);
-      final tryResult2 = Map<String, dynamic>.from(tryResult);
-      tryResult2['patchset'] = 2;
-      tryResult2['name'] = 'test_suite/test_name_2';
+      final tryResult2 = ChangeRecord.fromMap({
+        ...tryResult.toJson(),
+        'patchset': 2,
+        'name': 'test_suite/test_name_2',
+      });
       await firestore.storeTryChange(tryResult2, testReview, 2);
-      tryResult['patchset'] = 3;
-      tryResult['name'] = 'test_suite/test_name';
-      tryResult['expected'] = 'CompileTimeError';
-      await firestore.storeTryChange(tryResult, testReview, 3);
+      final tryResult3 = ChangeRecord.fromMap({
+        ...tryResult.toJson(),
+        'patchset': 3,
+        'name': 'test_suite/test_name',
+        'expected': 'CompileTimeError',
+      });
+      await firestore.storeTryChange(tryResult3, testReview, 3);
       // Set the results on patchsets 1 and 2 to approved.
-      final snapshot = await firestore.query(
-        from: 'try_results',
-        where: compositeFilter([
-          fieldEquals('approved', false),
-          fieldEquals('review', testReview),
-          fieldLessThanOrEqual('patchset', 2),
-        ]),
+      final snapshot = await firestore.runQuery(
+        StructuredQuery()
+          ..from = inCollection('try_results')
+          ..where = compositeFilter([
+            fieldEquals('approved', false),
+            fieldEquals('review', testReview),
+            fieldLessThanOrEqual('patchset', 2),
+          ]),
       );
       for (final response in snapshot) {
-        await firestore.approveResult(response.toDocument());
+        await firestore.approveResult(response);
         //await firestore.updateDocument(response.document.name, {'approved': taggedValue(true)});
       }
 
       // Should return only the approved change on patchset 2,
       // not the one on patchset 1 or the unapproved change on patchset 3.
       final approvals = await firestore.tryApprovals(testReview);
-      tryResult2['configurations'] = [tryResult2['configuration']];
-      tryResult2['approved'] = true;
-      tryResult2.remove('configuration');
+      final expectedApproval = {
+        ...tryResult2.toJson(),
+        'configurations': [tryResult2.configuration],
+        'approved': true,
+      }..remove('configuration');
       expect(1, approvals.length);
       final approval = untagMap(approvals.single.fields);
-      expect(approval, tryResult2);
+      expect(approval, expectedApproval);
     });
   });
 }
diff --git a/builder/test/results_test.dart b/builder/test/results_test.dart
index 6c385d3..9d9708e 100644
--- a/builder/test/results_test.dart
+++ b/builder/test/results_test.dart
@@ -11,19 +11,19 @@
 
 void main() async {
   test('Base builder test', () async {
-    final builderTest = BuilderTest(landedCommitChange);
+    final builderTest = BuilderTest(ChangeRecord.fromMap(landedCommitChange));
     await builderTest.update();
   });
 
   test('Get info for already saved commit', () async {
-    final builderTest = BuilderTest(existingCommitChange);
+    final builderTest = BuilderTest(ChangeRecord.fromMap(existingCommitChange));
     await builderTest.storeBuildCommitsInfo();
     expect(builderTest.builder.endIndex, existingCommitIndex);
     expect(builderTest.builder.startIndex, previousCommitIndex + 1);
   });
 
   test('Link landed commit to review', () async {
-    final builderTest = BuilderTest(landedCommitChange);
+    final builderTest = BuilderTest(ChangeRecord.fromMap(landedCommitChange));
     builderTest.firestore.commits.removeWhere(
       (key, value) => value[fIndex] > existingCommitIndex,
     );
@@ -33,8 +33,8 @@
     expect(builderTest.builder.endIndex, landedCommitIndex);
     expect(builderTest.builder.startIndex, existingCommitIndex + 1);
     expect(builderTest.builder.tryApprovals, {
-      testResult(review44445Result): 54,
-      testResult(review77779Result): 53,
+      ResultRecord.fromMap(review44445Result).testResult: 54,
+      ResultRecord.fromMap(review77779Result).testResult: 53,
     });
     expect(
       (await builderTest.firestore.getCommit(commit53Hash))!.toJson(),
@@ -47,9 +47,10 @@
   });
 
   test('update previous active result', () async {
-    final builderTest = BuilderTest(landedCommitChange);
+    final landedRecord = ChangeRecord.fromMap(landedCommitChange);
+    final builderTest = BuilderTest(landedRecord);
     await builderTest.storeBuildCommitsInfo();
-    await builderTest.storeChange(landedCommitChange);
+    await builderTest.storeChange(landedRecord);
     expect(builderTest.builder.success, true);
     expect(
       builderTest.firestore.results['activeResultID'],
@@ -57,9 +58,10 @@
         ..[fActiveConfigurations] = ['another configuration'],
     );
 
-    final changeAnotherConfiguration = Map<String, dynamic>.from(
-      landedCommitChange,
-    )..['configuration'] = 'another configuration';
+    final changeAnotherConfiguration = ChangeRecord.fromMap(
+      Map<String, dynamic>.from(landedCommitChange)
+        ..['configuration'] = 'another configuration',
+    );
     await builderTest.storeChange(changeAnotherConfiguration);
     expect(builderTest.builder.success, true);
     expect(
@@ -72,28 +74,31 @@
     expect(builderTest.builder.countChanges, 2);
     expect(
       builderTest.firestore.results[await builderTest.firestore.findResult(
-        landedCommitChange,
+        landedRecord,
         landedCommitIndex,
         landedCommitIndex,
       )],
       landedResult,
     );
     final result = (await builderTest.firestore.findActiveResults(
-      landedCommitChange['name'],
-      landedCommitChange['configuration'],
+      landedRecord.name,
+      landedRecord.configuration,
     )).single;
-    expect(untagMap(result.fields), landedResult);
+    expect(untagMap(result.doc.fields!), landedResult);
   });
 
   test('mark active result flaky', () async {
-    final builderTest = BuilderTest(landedCommitChange);
+    final landedRecord = ChangeRecord.fromMap(landedCommitChange);
+    final builderTest = BuilderTest(landedRecord);
     await builderTest.storeBuildCommitsInfo();
-    final flakyChange = Map<String, dynamic>.from(landedCommitChange)
-      ..[fPreviousResult] = 'RuntimeError'
-      ..[fFlaky] = true;
-    expect(flakyChange[fResult], 'RuntimeError');
+    final flakyChange = ChangeRecord.fromMap(
+      Map<String, dynamic>.from(landedCommitChange)
+        ..[fPreviousResult] = 'RuntimeError'
+        ..[fFlaky] = true,
+    );
+    expect(flakyChange.result, 'RuntimeError');
     await builderTest.storeChange(flakyChange);
-    expect(flakyChange[fResult], 'flaky');
+    expect(flakyChange.result, 'flaky');
     expect(builderTest.builder.success, true);
     expect(
       builderTest.firestore.results['activeResultID'],
diff --git a/builder/test/revert_test.dart b/builder/test/revert_test.dart
index d9480a7..fe56140 100644
--- a/builder/test/revert_test.dart
+++ b/builder/test/revert_test.dart
@@ -7,12 +7,15 @@
 import 'package:test/test.dart';
 
 import 'package:builder/src/result.dart';
+import 'package:builder/src/firestore_helpers.dart';
 import 'fakes.dart';
 import 'test_data.dart';
 
 void main() async {
   test('fetch commit that is a revert', () async {
-    final builderTest = BuilderTest(revertUnchangedChange);
+    final builderTest = BuilderTest(
+      ChangeRecord.fromMap(revertUnchangedChange),
+    );
     builderTest.firestore.commits[revertedCommitHash] = revertedCommit;
     builderTest.client.addDefaultResponse(revertGitilesLog);
 
@@ -28,7 +31,9 @@
   });
 
   test('fetch commit that is a reland (as a reland)', () async {
-    final builderTest = BuilderTest(relandUnchangedChange);
+    final builderTest = BuilderTest(
+      ChangeRecord.fromMap(relandUnchangedChange),
+    );
     builderTest.firestore.commits[revertedCommitHash] = revertedCommit;
     builderTest.client.addDefaultResponse(revertAndRelandGitilesLog);
     await builderTest.storeBuildCommitsInfo();
@@ -53,7 +58,9 @@
   });
 
   test('fetch commit that is a reland (as a revert)', () async {
-    final builderTest = RevertBuilderTest(relandUnchangedChange);
+    final builderTest = RevertBuilderTest(
+      ChangeRecord.fromMap(relandUnchangedChange),
+    );
     builderTest.client.addDefaultResponse(relandGitilesLog);
     await builderTest.storeBuildCommitsInfo();
     expect(builderTest.builder.endIndex, relandCommit['index']);
@@ -67,9 +74,10 @@
   });
 
   test('Automatically approve expected failure on revert', () async {
-    final builderTest = RevertBuilderTest(revertChange);
+    final record = ChangeRecord.fromMap(revertChange);
+    final builderTest = RevertBuilderTest(record);
     await builderTest.update();
-    await builderTest.storeChange(revertChange);
+    await builderTest.storeChange(record);
     expect(
       builderTest.firestore.results.values
           .where((result) => result[fBlamelistEndIndex] == 55)
@@ -79,23 +87,29 @@
   });
 
   test('Revert in blamelist, doesn\'t match new failure', () async {
-    final builderTest = RevertBuilderTest(commit56UnmatchingChange);
-    await builderTest.update();
-    await builderTest.storeChange(commit56UnmatchingChange);
-    await builderTest.storeChange(commit56DifferentNameChange);
-    await builderTest.storeChange(commit56Change);
+    final unmatchingRecord = ChangeRecord.fromMap(commit56UnmatchingChange);
+    final differentNameRecord = ChangeRecord.fromMap(
+      commit56DifferentNameChange,
+    );
+    final record = ChangeRecord.fromMap(commit56Change);
 
-    Future<bool> findApproval(Map<String, dynamic> change) async {
+    final builderTest = RevertBuilderTest(unmatchingRecord);
+    await builderTest.update();
+    await builderTest.storeChange(unmatchingRecord);
+    await builderTest.storeChange(differentNameRecord);
+    await builderTest.storeChange(record);
+
+    Future<bool> findApproval(ChangeRecord change) async {
       final result = await builderTest.firestore.findActiveResults(
-        change[fName],
-        change[fConfiguration],
+        change.name,
+        change.configuration,
       );
-      return result.single.getBool(fApproved)!;
+      return result.single.approved;
     }
 
-    expect(await findApproval(commit56UnmatchingChange), false);
-    expect(await findApproval(commit56DifferentNameChange), false);
-    expect(await findApproval(commit56Change), true);
+    expect(await findApproval(unmatchingRecord), false);
+    expect(await findApproval(differentNameRecord), false);
+    expect(await findApproval(record), true);
   });
 }
 
diff --git a/builder/test/tryjob_test.dart b/builder/test/tryjob_test.dart
index aa78de8..e6b5f50 100644
--- a/builder/test/tryjob_test.dart
+++ b/builder/test/tryjob_test.dart
@@ -34,9 +34,9 @@
 final buildersToRemove = <String?>{};
 final testsToRemove = <String?>{};
 
-void registerChangeForDeletion(Map<String, dynamic> change) {
-  buildersToRemove.add(change['builder_name']);
-  testsToRemove.add(change['name']);
+void registerChangeForDeletion(ChangeRecord change) {
+  testsToRemove.add(change.name);
+  buildersToRemove.add(change.builderName);
 }
 
 Future<void> removeTryBuildersAndResults() async {
@@ -121,8 +121,8 @@
   };
 }
 
-Tryjob makeTryjob(String name, Map<String, dynamic> firstChange) => Tryjob(
-  BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]})
+Tryjob makeTryjob(String name, ChangeRecord firstChange) => Tryjob(
+  BuildInfo.fromResult(firstChange, <String>{firstChange.configuration})
       as TryBuildInfo,
   'bbID_$name',
   data['landedCommit']!,
@@ -131,22 +131,17 @@
   client,
 );
 
-Tryjob makeLandedTryjob(String name, Map<String, dynamic> firstChange) =>
-    Tryjob(
-      BuildInfo.fromResult(firstChange, <String>{firstChange[fConfiguration]})
-          as TryBuildInfo,
-      'bbID_$name',
-      data['baseCommit']!,
-      commitsCache,
-      firestore,
-      client,
-    );
+Tryjob makeLandedTryjob(String name, ChangeRecord firstChange) => Tryjob(
+  BuildInfo.fromResult(firstChange, <String>{firstChange.configuration})
+      as TryBuildInfo,
+  'bbID_$name',
+  data['baseCommit']!,
+  commitsCache,
+  firestore,
+  client,
+);
 
-Map<String, dynamic> makeChange(
-  String name,
-  String result, {
-  bool flaky = false,
-}) {
+ChangeRecord makeChange(String name, String result, {bool flaky = false}) {
   final results = result.split('/');
   final previous = results[0];
   final current = results[1];
@@ -173,12 +168,13 @@
     'bot_name': 'fake_bot_name',
     'previous_build_number': '306',
   };
-  registerChangeForDeletion(change);
-  return change;
+  final record = ChangeRecord.fromMap(change);
+  registerChangeForDeletion(record);
+  return record;
 }
 
-Map<String, dynamic> makeLandedChange(String name, String result) {
-  return makeChange(name, result)..['commit_hash'] = data['landedPatchsetRef'];
+ChangeRecord makeLandedChange(String name, String result) {
+  return makeChange(name, result)..commitHash = data['landedPatchsetRef']!;
 }
 
 Future<void> checkTryBuild(
@@ -227,11 +223,11 @@
     expect(failedStatus.success, isFalse);
     expect(failedStatus.truncatedResults, isFalse);
     // Add a second failing configuration for the test.
-    final otherConfigurationChange = {
-      ...failingChange,
+    final otherConfigurationChange = ChangeRecord.fromMap({
+      ...failingChange.toJson(),
       'configuration': 'other_configuration',
       'builder': 'other_builder',
-    };
+    });
     registerChangeForDeletion(otherConfigurationChange);
     final otherTryjob = makeTryjob(
       'other_configuration',
@@ -297,7 +293,7 @@
     final passingChange = makeChange('truncatedPass', 'RuntimeError/Pass/Pass');
     final tryjob = makeTryjob('truncatedPass', passingChange);
     final failingChange = makeChange('truncatedPass', 'Pass/RuntimeError/Pass')
-      ..['name'] = 'truncated_pass_2_test';
+      ..name = 'truncated_pass_2_test';
     registerChangeForDeletion(failingChange);
     tryjob.counter.passes = ChangeCounter.maxReportedSuccesses;
     final truncatedStatus = await tryjob.process([
@@ -328,7 +324,10 @@
   test('truncated', () async {
     final failingChange = makeChange('truncated', 'Pass/RuntimeError/Pass');
     final tryjob = makeTryjob('truncated', failingChange);
-    final truncatedChange = {...failingChange, 'name': 'truncated_2_test'};
+    final truncatedChange = ChangeRecord.fromMap({
+      ...failingChange.toJson(),
+      'name': 'truncated_2_test',
+    });
     registerChangeForDeletion(truncatedChange);
     tryjob.counter.failures = ChangeCounter.maxReportedFailures - 1;
     final truncatedStatus = await tryjob.process([