Merge remote-tracking branch 'aosp/upstream-main' am: 42b4c61d46 am: 71e7dd63f7 am: c339216720

Original change: https://android-review.googlesource.com/c/platform/external/turbine/+/2029125

Change-Id: Ife8274d1384abd11d8db5859515dd8ab9cbd5518
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..daec318
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+  - package-ecosystem: "maven"
+    directory: "/"
+    schedule:
+      interval: "daily"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 87582f0..e12698c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,41 +29,35 @@
       fail-fast: false
       matrix:
         os: [ ubuntu-latest ]
-        java: [ 16, 11, 8 ]
+        java: [ 17, 11 ]
         experimental: [ false ]
         include:
           # Only test on macos and windows with a single recent JDK to avoid a
           # combinatorial explosion of test configurations.
           - os: macos-latest
-            java: 16
+            java: 17
             experimental: false
           - os: windows-latest
-            java: 16
+            java: 17
             experimental: false
           - os: ubuntu-latest
-            java: 17-ea
+            java: 18-ea
             experimental: true
     runs-on: ${{ matrix.os }}
     continue-on-error: ${{ matrix.experimental }}
     steps:
       - name: Cancel previous
-        uses: styfle/cancel-workflow-action@0.8.0
+        uses: styfle/cancel-workflow-action@0.9.1
         with:
           access_token: ${{ github.token }}
       - name: 'Check out repository'
         uses: actions/checkout@v2
-      - name: 'Cache local Maven repository'
-        uses: actions/cache@v2
-        with:
-          path: ~/.m2/repository
-          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
-          restore-keys: |
-            ${{ runner.os }}-maven-
       - name: 'Set up JDK ${{ matrix.java }}'
         uses: actions/setup-java@v2
         with:
           java-version: ${{ matrix.java }}
-          distribution: 'adopt'
+          distribution: 'zulu'
+          cache: 'maven'
       - name: 'Install'
         shell: bash
         run: mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
diff --git a/.mvn/jvm.config b/.mvn/jvm.config
new file mode 100644
index 0000000..504456f
--- /dev/null
+++ b/.mvn/jvm.config
@@ -0,0 +1,10 @@
+--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
+--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
+--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
+--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
+--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
+--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
+--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
+--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
+--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
+--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
diff --git a/java/com/google/common/escape/package-info.java b/java/com/google/common/escape/package-info.java
new file mode 100644
index 0000000..b69b34e
--- /dev/null
+++ b/java/com/google/common/escape/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.common.escape;
diff --git a/java/com/google/turbine/binder/Binder.java b/java/com/google/turbine/binder/Binder.java
index 6c828b3..d2ce948 100644
--- a/java/com/google/turbine/binder/Binder.java
+++ b/java/com/google/turbine/binder/Binder.java
@@ -16,6 +16,8 @@
 
 package com.google.turbine.binder;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -67,12 +69,13 @@
 import java.time.Duration;
 import java.util.Optional;
 import javax.annotation.processing.Processor;
+import org.jspecify.nullness.Nullable;
 
 /** The entry point for analysis. */
 public final class Binder {
 
   /** Binds symbols and types to the given compilation units. */
-  public static BindingResult bind(
+  public static @Nullable BindingResult bind(
       ImmutableList<CompUnit> units,
       ClassPath classpath,
       ClassPath bootclasspath,
@@ -81,7 +84,7 @@
   }
 
   /** Binds symbols and types to the given compilation units. */
-  public static BindingResult bind(
+  public static @Nullable BindingResult bind(
       ImmutableList<CompUnit> units,
       ClassPath classpath,
       ProcessorInfo processorInfo,
@@ -179,11 +182,11 @@
 
     ImmutableMap.Builder<ClassSymbol, SourceTypeBoundClass> result = ImmutableMap.builder();
     for (ClassSymbol sym : syms) {
-      result.put(sym, tenv.get(sym));
+      result.put(sym, tenv.getNonNull(sym));
     }
 
     return new BindingResult(
-        result.build(),
+        result.buildOrThrow(),
         boundModules,
         classPathEnv,
         tli,
@@ -250,11 +253,12 @@
       ImportScope wildImportScope = WildImportIndex.create(importResolver, tli, unit.imports());
       MemberImportIndex memberImports =
           new MemberImportIndex(unit.source(), importResolver, tli, unit.imports());
-      ImportScope scope =
-          ImportScope.fromScope(topLevel)
-              .append(wildImportScope)
-              .append(ImportScope.fromScope(packageScope))
-              .append(importScope);
+      ImportScope scope = ImportScope.fromScope(topLevel).append(wildImportScope);
+      // Can be null if we're compiling a package-info.java for an empty package
+      if (packageScope != null) {
+        scope = scope.append(ImportScope.fromScope(packageScope));
+      }
+      scope = scope.append(importScope);
       if (unit.module().isPresent()) {
         ModDecl module = unit.module().get();
         modules.put(
@@ -284,12 +288,12 @@
             @Override
             public SourceHeaderBoundClass complete(
                 Env<ClassSymbol, HeaderBoundClass> henv, ClassSymbol sym) {
-              PackageSourceBoundClass base = psenv.get(sym);
+              PackageSourceBoundClass base = psenv.getNonNull(sym);
               return HierarchyBinder.bind(log.withSource(base.source()), sym, base, henv);
             }
           });
     }
-    return new LazyEnv<>(completers.build(), classPathEnv);
+    return new LazyEnv<>(completers.buildOrThrow(), classPathEnv);
   }
 
   private static Env<ClassSymbol, SourceTypeBoundClass> bindTypes(
@@ -299,7 +303,7 @@
       Env<ClassSymbol, HeaderBoundClass> henv) {
     SimpleEnv.Builder<ClassSymbol, SourceTypeBoundClass> builder = SimpleEnv.builder();
     for (ClassSymbol sym : syms) {
-      SourceHeaderBoundClass base = shenv.get(sym);
+      SourceHeaderBoundClass base = shenv.getNonNull(sym);
       builder.put(sym, TypeBinder.bind(log.withSource(base.source()), henv, sym, base));
     }
     return builder.build();
@@ -311,7 +315,8 @@
       Env<ClassSymbol, TypeBoundClass> tenv) {
     SimpleEnv.Builder<ClassSymbol, SourceTypeBoundClass> builder = SimpleEnv.builder();
     for (ClassSymbol sym : syms) {
-      builder.put(sym, CanonicalTypeBinder.bind(sym, stenv.get(sym), tenv));
+      SourceTypeBoundClass base = stenv.getNonNull(sym);
+      builder.put(sym, CanonicalTypeBinder.bind(sym, base, tenv));
     }
     return builder.build();
   }
@@ -328,7 +333,7 @@
         moduleEnv.append(
             new Env<ModuleSymbol, ModuleInfo>() {
               @Override
-              public ModuleInfo get(ModuleSymbol sym) {
+              public @Nullable ModuleInfo get(ModuleSymbol sym) {
                 PackageSourceBoundModule info = modules.get(sym);
                 if (info != null) {
                   return new ModuleInfo(
@@ -366,7 +371,7 @@
     ImmutableMap.Builder<FieldSymbol, LazyEnv.Completer<FieldSymbol, Const.Value, Const.Value>>
         completers = ImmutableMap.builder();
     for (ClassSymbol sym : syms) {
-      SourceTypeBoundClass info = env.get(sym);
+      SourceTypeBoundClass info = env.getNonNull(sym);
       for (FieldInfo field : info.fields()) {
         if (!isConst(field)) {
           continue;
@@ -375,7 +380,8 @@
             field.sym(),
             new LazyEnv.Completer<FieldSymbol, Const.Value, Const.Value>() {
               @Override
-              public Const.Value complete(Env<FieldSymbol, Const.Value> env1, FieldSymbol k) {
+              public Const.@Nullable Value complete(
+                  Env<FieldSymbol, Const.Value> env1, FieldSymbol k) {
                 try {
                   return new ConstEvaluator(
                           sym,
@@ -386,7 +392,9 @@
                           env1,
                           baseEnv,
                           log.withSource(info.source()))
-                      .evalFieldInitializer(field.decl().init().get(), field.type());
+                      .evalFieldInitializer(
+                          // we're processing fields bound from sources in the compilation
+                          requireNonNull(field.decl()).init().get(), field.type());
                 } catch (LazyEnv.LazyBindingError e) {
                   // fields initializers are allowed to reference the field being initialized,
                   // but if they do they aren't constants
@@ -401,11 +409,12 @@
     // lazily evaluated fields in the current compilation unit with
     // constant fields in the classpath (which don't require evaluation).
     Env<FieldSymbol, Const.Value> constenv =
-        new LazyEnv<>(completers.build(), SimpleEnv.<FieldSymbol, Const.Value>builder().build());
+        new LazyEnv<>(
+            completers.buildOrThrow(), SimpleEnv.<FieldSymbol, Const.Value>builder().build());
 
     SimpleEnv.Builder<ClassSymbol, SourceTypeBoundClass> builder = SimpleEnv.builder();
     for (ClassSymbol sym : syms) {
-      SourceTypeBoundClass base = env.get(sym);
+      SourceTypeBoundClass base = env.getNonNull(sym);
       builder.put(
           sym, new ConstBinder(constenv, sym, baseEnv, base, log.withSource(base.source())).bind());
     }
@@ -447,7 +456,8 @@
       Env<ClassSymbol, TypeBoundClass> tenv) {
     SimpleEnv.Builder<ClassSymbol, SourceTypeBoundClass> builder = SimpleEnv.builder();
     for (ClassSymbol sym : syms) {
-      builder.put(sym, DisambiguateTypeAnnotations.bind(stenv.get(sym), tenv));
+      SourceTypeBoundClass base = stenv.getNonNull(sym);
+      builder.put(sym, DisambiguateTypeAnnotations.bind(base, tenv));
     }
     return builder.build();
   }
diff --git a/java/com/google/turbine/binder/CanonicalTypeBinder.java b/java/com/google/turbine/binder/CanonicalTypeBinder.java
index ae82b4f..5ff17bb 100644
--- a/java/com/google/turbine/binder/CanonicalTypeBinder.java
+++ b/java/com/google/turbine/binder/CanonicalTypeBinder.java
@@ -16,6 +16,8 @@
 
 package com.google.turbine.binder;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
@@ -23,6 +25,7 @@
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.ParamInfo;
+import com.google.turbine.binder.bound.TypeBoundClass.RecordComponentInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.TyVarInfo;
 import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.sym.ClassSymbol;
@@ -43,30 +46,32 @@
   static SourceTypeBoundClass bind(
       ClassSymbol sym, SourceTypeBoundClass base, Env<ClassSymbol, TypeBoundClass> env) {
     Type superClassType = base.superClassType();
+    int pos = base.decl().position();
     if (superClassType != null && superClassType.tyKind() == TyKind.CLASS_TY) {
       superClassType =
           Canonicalize.canonicalizeClassTy(
-              base.source(), base.decl().position(), env, base.owner(), (ClassTy) superClassType);
+              base.source(), pos, env, base.owner(), (ClassTy) superClassType);
     }
     ImmutableList.Builder<Type> interfaceTypes = ImmutableList.builder();
     for (Type i : base.interfaceTypes()) {
       if (i.tyKind() == TyKind.CLASS_TY) {
-        i =
-            Canonicalize.canonicalizeClassTy(
-                base.source(), base.decl().position(), env, base.owner(), (ClassTy) i);
+        i = Canonicalize.canonicalizeClassTy(base.source(), pos, env, base.owner(), (ClassTy) i);
       }
       interfaceTypes.add(i);
     }
     ImmutableMap<TyVarSymbol, TyVarInfo> typParamTypes =
-        typeParameters(base.source(), base.decl().position(), env, sym, base.typeParameterTypes());
-    ImmutableList<MethodInfo> methods =
-        methods(base.source(), base.decl().position(), env, sym, base.methods());
+        typeParameters(base.source(), pos, env, sym, base.typeParameterTypes());
+    ImmutableList<RecordComponentInfo> components =
+        components(base.source(), env, sym, pos, base.components());
+    ImmutableList<MethodInfo> methods = methods(base.source(), pos, env, sym, base.methods());
     ImmutableList<FieldInfo> fields = fields(base.source(), env, sym, base.fields());
     return new SourceTypeBoundClass(
         interfaceTypes.build(),
+        base.permits(),
         superClassType,
         typParamTypes,
         base.access(),
+        components,
         methods,
         fields,
         base.owner(),
@@ -92,7 +97,13 @@
       result.add(
           new FieldInfo(
               base.sym(),
-              Canonicalize.canonicalize(source, base.decl().position(), env, sym, base.type()),
+              Canonicalize.canonicalize(
+                  source,
+                  // we're processing fields bound from sources in the compilation
+                  requireNonNull(base.decl()).position(),
+                  env,
+                  sym,
+                  base.type()),
               base.access(),
               base.annotations(),
               base.decl(),
@@ -113,17 +124,14 @@
       ImmutableMap<TyVarSymbol, TyVarInfo> tps =
           typeParameters(source, pos, env, sym, base.tyParams());
       Type ret = Canonicalize.canonicalize(source, pos, env, sym, base.returnType());
-      ImmutableList.Builder<ParamInfo> parameters = ImmutableList.builder();
-      for (ParamInfo parameter : base.parameters()) {
-        parameters.add(param(source, pos, env, sym, parameter));
-      }
+      ImmutableList<ParamInfo> parameters = parameters(source, env, sym, pos, base.parameters());
       ImmutableList<Type> exceptions = canonicalizeList(source, pos, env, sym, base.exceptions());
       result.add(
           new MethodInfo(
               base.sym(),
               tps,
               ret,
-              parameters.build(),
+              parameters,
               exceptions,
               base.access(),
               base.defaultValue(),
@@ -134,6 +142,19 @@
     return result.build();
   }
 
+  private static ImmutableList<ParamInfo> parameters(
+      SourceFile source,
+      Env<ClassSymbol, TypeBoundClass> env,
+      ClassSymbol sym,
+      int pos,
+      ImmutableList<ParamInfo> parameters) {
+    ImmutableList.Builder<ParamInfo> result = ImmutableList.builder();
+    for (ParamInfo parameter : parameters) {
+      result.add(param(source, pos, env, sym, parameter));
+    }
+    return result.build();
+  }
+
   private static ParamInfo param(
       SourceFile source,
       int position,
@@ -147,6 +168,24 @@
         base.access());
   }
 
+  private static ImmutableList<RecordComponentInfo> components(
+      SourceFile source,
+      Env<ClassSymbol, TypeBoundClass> env,
+      ClassSymbol sym,
+      int pos,
+      ImmutableList<RecordComponentInfo> components) {
+    ImmutableList.Builder<RecordComponentInfo> result = ImmutableList.builder();
+    for (RecordComponentInfo component : components) {
+      result.add(
+          new RecordComponentInfo(
+              component.sym(),
+              Canonicalize.canonicalize(source, pos, env, sym, component.type()),
+              component.annotations(),
+              component.access()));
+    }
+    return result.build();
+  }
+
   private static ImmutableMap<TyVarSymbol, TyVarInfo> typeParameters(
       SourceFile source,
       int position,
@@ -160,7 +199,7 @@
           (IntersectionTy) Canonicalize.canonicalize(source, position, env, sym, info.upperBound());
       result.put(e.getKey(), new TyVarInfo(upperBound, /* lowerBound= */ null, info.annotations()));
     }
-    return result.build();
+    return result.buildOrThrow();
   }
 
   private static ImmutableList<Type> canonicalizeList(
diff --git a/java/com/google/turbine/binder/ClassPath.java b/java/com/google/turbine/binder/ClassPath.java
index eeea7c5..eb78099 100644
--- a/java/com/google/turbine/binder/ClassPath.java
+++ b/java/com/google/turbine/binder/ClassPath.java
@@ -23,6 +23,7 @@
 import com.google.turbine.binder.lookup.TopLevelIndex;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.ModuleSymbol;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A compilation classpath, e.g. the user or platform class path. May be backed by a search path of
@@ -38,5 +39,6 @@
   /** The classpath's top level index. */
   TopLevelIndex index();
 
+  @Nullable
   Supplier<byte[]> resource(String path);
 }
diff --git a/java/com/google/turbine/binder/ClassPathBinder.java b/java/com/google/turbine/binder/ClassPathBinder.java
index 1825c23..1c41e96 100644
--- a/java/com/google/turbine/binder/ClassPathBinder.java
+++ b/java/com/google/turbine/binder/ClassPathBinder.java
@@ -36,6 +36,7 @@
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import org.jspecify.nullness.Nullable;
 
 /** Sets up an environment for symbols on the classpath. */
 public final class ClassPathBinder {
@@ -57,7 +58,7 @@
     Env<ClassSymbol, BytecodeBoundClass> benv =
         new Env<ClassSymbol, BytecodeBoundClass>() {
           @Override
-          public BytecodeBoundClass get(ClassSymbol sym) {
+          public @Nullable BytecodeBoundClass get(ClassSymbol sym) {
             return map.get(sym);
           }
         };
@@ -92,7 +93,7 @@
       }
 
       @Override
-      public Supplier<byte[]> resource(String path) {
+      public @Nullable Supplier<byte[]> resource(String path) {
         return resources.get(path);
       }
     };
diff --git a/java/com/google/turbine/binder/CompUnitPreprocessor.java b/java/com/google/turbine/binder/CompUnitPreprocessor.java
index 9e9a0bb..970dc4b 100644
--- a/java/com/google/turbine/binder/CompUnitPreprocessor.java
+++ b/java/com/google/turbine/binder/CompUnitPreprocessor.java
@@ -149,7 +149,7 @@
         types.add(new SourceBoundClass(sym, owner, children, access, decl));
       }
     }
-    return result.build();
+    return result.buildOrThrow();
   }
 
   /** Desugars access flags for a class. */
@@ -175,6 +175,9 @@
       case ANNOTATION:
         access |= TurbineFlag.ACC_ABSTRACT | TurbineFlag.ACC_INTERFACE | TurbineFlag.ACC_ANNOTATION;
         break;
+      case RECORD:
+        access |= TurbineFlag.ACC_SUPER | TurbineFlag.ACC_FINAL;
+        break;
     }
     return access;
   }
@@ -195,12 +198,14 @@
       case INTERFACE:
       case ENUM:
       case ANNOTATION:
+      case RECORD:
         access |= TurbineFlag.ACC_STATIC;
         break;
       case CLASS:
         if ((enclosing & (TurbineFlag.ACC_INTERFACE | TurbineFlag.ACC_ANNOTATION)) != 0) {
           access |= TurbineFlag.ACC_STATIC;
         }
+        break;
     }
 
     // propagate strictfp to nested types
@@ -219,6 +224,8 @@
         Optional.empty(),
         ImmutableList.of(),
         ImmutableList.of(),
+        ImmutableList.of(),
+        ImmutableList.of(),
         TurbineTyKind.INTERFACE,
         /* javadoc= */ null);
   }
diff --git a/java/com/google/turbine/binder/ConstBinder.java b/java/com/google/turbine/binder/ConstBinder.java
index 8511183..29ae710 100644
--- a/java/com/google/turbine/binder/ConstBinder.java
+++ b/java/com/google/turbine/binder/ConstBinder.java
@@ -29,6 +29,7 @@
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.ParamInfo;
+import com.google.turbine.binder.bound.TypeBoundClass.RecordComponentInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.TyVarInfo;
 import com.google.turbine.binder.env.CompoundEnv;
 import com.google.turbine.binder.env.Env;
@@ -57,6 +58,7 @@
 import com.google.turbine.type.Type.WildUpperBoundedTy;
 import java.lang.annotation.RetentionPolicy;
 import java.util.Map;
+import org.jspecify.nullness.Nullable;
 
 /** Binding pass to evaluate constant expressions. */
 public class ConstBinder {
@@ -103,13 +105,16 @@
                 env,
                 log)
             .evaluateAnnotations(base.annotations());
+    ImmutableList<RecordComponentInfo> components = bindRecordComponents(base.components());
     ImmutableList<TypeBoundClass.FieldInfo> fields = fields(base.fields());
     ImmutableList<MethodInfo> methods = bindMethods(base.methods());
     return new SourceTypeBoundClass(
         bindTypes(base.interfaceTypes()),
+        base.permits(),
         base.superClassType() != null ? bindType(base.superClassType()) : null,
         bindTypeParameters(base.typeParameterTypes()),
         base.access(),
+        components,
         methods,
         fields,
         base.owner(),
@@ -166,7 +171,17 @@
     return new ParamInfo(base.sym(), bindType(base.type()), annos, base.access());
   }
 
-  static AnnotationMetadata bindAnnotationMetadata(
+  private ImmutableList<RecordComponentInfo> bindRecordComponents(
+      ImmutableList<RecordComponentInfo> components) {
+    ImmutableList.Builder<RecordComponentInfo> result = ImmutableList.builder();
+    for (RecordComponentInfo base : components) {
+      ImmutableList<AnnoInfo> annos = constEvaluator.evaluateAnnotations(base.annotations());
+      result.add(new RecordComponentInfo(base.sym(), bindType(base.type()), annos, base.access()));
+    }
+    return result.build();
+  }
+
+  static @Nullable AnnotationMetadata bindAnnotationMetadata(
       TurbineTyKind kind, Iterable<AnnoInfo> annotations) {
     if (kind != TurbineTyKind.ANNOTATION) {
       return null;
@@ -196,7 +211,7 @@
     return new AnnotationMetadata(retention, target, repeatable);
   }
 
-  private static RetentionPolicy bindRetention(AnnoInfo annotation) {
+  private static @Nullable RetentionPolicy bindRetention(AnnoInfo annotation) {
     Const value = annotation.values().get("value");
     if (value == null) {
       return null;
@@ -232,7 +247,7 @@
     return result.build();
   }
 
-  private static ClassSymbol bindRepeatable(AnnoInfo annotation) {
+  private static @Nullable ClassSymbol bindRepeatable(AnnoInfo annotation) {
     // requireNonNull is safe because java.lang.annotation.Repeatable declares `value`.
     Const value = requireNonNull(annotation.values().get("value"));
     if (value.kind() != Kind.CLASS_LITERAL) {
@@ -268,7 +283,7 @@
     return result.build();
   }
 
-  private Value fieldValue(TypeBoundClass.FieldInfo base) {
+  private @Nullable Value fieldValue(TypeBoundClass.FieldInfo base) {
     if (base.decl() == null || !base.decl().init().isPresent()) {
       return null;
     }
@@ -292,7 +307,9 @@
       return null;
     }
     if (type.tyKind().equals(TyKind.PRIM_TY)) {
-      value = ConstEvaluator.coerce(value, ((Type.PrimTy) type).primkind());
+      value =
+          constEvaluator.coerce(
+              base.decl().init().get().position(), value, ((Type.PrimTy) type).primkind());
     }
     return value;
   }
@@ -317,7 +334,7 @@
               /* lowerBound= */ null,
               constEvaluator.evaluateAnnotations(info.annotations())));
     }
-    return result.build();
+    return result.buildOrThrow();
   }
 
   private Type bindType(Type type) {
diff --git a/java/com/google/turbine/binder/ConstEvaluator.java b/java/com/google/turbine/binder/ConstEvaluator.java
index bef98a7..771e87f 100644
--- a/java/com/google/turbine/binder/ConstEvaluator.java
+++ b/java/com/google/turbine/binder/ConstEvaluator.java
@@ -17,6 +17,7 @@
 package com.google.turbine.binder;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -43,7 +44,12 @@
 import com.google.turbine.diag.TurbineError.ErrorKind;
 import com.google.turbine.diag.TurbineLog.TurbineLogWithSource;
 import com.google.turbine.model.Const;
+import com.google.turbine.model.Const.ArrayInitValue;
+import com.google.turbine.model.Const.CharValue;
 import com.google.turbine.model.Const.ConstCastError;
+import com.google.turbine.model.Const.DoubleValue;
+import com.google.turbine.model.Const.FloatValue;
+import com.google.turbine.model.Const.StringValue;
 import com.google.turbine.model.Const.Value;
 import com.google.turbine.model.TurbineConstantTypeKind;
 import com.google.turbine.model.TurbineFlag;
@@ -57,15 +63,18 @@
 import com.google.turbine.tree.Tree.ConstVarName;
 import com.google.turbine.tree.Tree.Expression;
 import com.google.turbine.tree.Tree.Ident;
+import com.google.turbine.tree.Tree.Paren;
 import com.google.turbine.tree.Tree.PrimTy;
 import com.google.turbine.tree.Tree.TypeCast;
 import com.google.turbine.tree.Tree.Unary;
+import com.google.turbine.tree.TurbineOperatorKind;
 import com.google.turbine.type.AnnoInfo;
 import com.google.turbine.type.Type;
 import java.util.ArrayDeque;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import org.jspecify.nullness.Nullable;
 
 /**
  * Constant expression evaluation.
@@ -75,10 +84,10 @@
 public strictfp class ConstEvaluator {
 
   /** The symbol of the originating class, for visibility checks. */
-  private final ClassSymbol origin;
+  private final @Nullable ClassSymbol origin;
 
   /** The symbol of the enclosing class, for lexical field lookups. */
-  private final ClassSymbol owner;
+  private final @Nullable ClassSymbol owner;
 
   /** Member imports of the enclosing compilation unit. */
   private final MemberImportIndex memberImports;
@@ -87,7 +96,7 @@
   private final SourceFile source;
 
   /** The constant variable environment. */
-  private final Env<FieldSymbol, Const.Value> values;
+  private final Env<FieldSymbol, Value> values;
 
   /** The class environment. */
   private final CompoundEnv<ClassSymbol, TypeBoundClass> env;
@@ -97,8 +106,8 @@
   private final TurbineLogWithSource log;
 
   public ConstEvaluator(
-      ClassSymbol origin,
-      ClassSymbol owner,
+      @Nullable ClassSymbol origin,
+      @Nullable ClassSymbol owner,
       MemberImportIndex memberImports,
       SourceFile source,
       Scope scope,
@@ -117,11 +126,11 @@
   }
 
   /** Evaluates the given expression's value. */
-  public Const eval(Tree t) {
+  public @Nullable Const eval(Tree t) {
     switch (t.kind()) {
       case LITERAL:
         {
-          Const.Value a = (Const.Value) ((Tree.Literal) t).value();
+          Value a = (Value) ((Tree.Literal) t).value();
           if (a == null) {
             return null;
           }
@@ -148,6 +157,8 @@
         return evalClassLiteral((ClassLiteral) t);
       case BINARY:
         return evalBinary((Binary) t);
+      case PAREN:
+        return eval(((Paren) t).expr());
       case TYPE_CAST:
         return evalCast((TypeCast) t);
       case UNARY:
@@ -200,11 +211,11 @@
     }
     LookupResult result = scope.lookup(new LookupKey(ImmutableList.copyOf(flat)));
     if (result == null) {
-      log.error(classTy.position(), ErrorKind.CANNOT_RESOLVE, flat.peekFirst());
+      log.error(classTy.position(), ErrorKind.CANNOT_RESOLVE, flat.getFirst());
       return Type.ErrorTy.create(flat);
     }
     if (result.sym().symKind() != Symbol.Kind.CLASS) {
-      throw error(classTy.position(), ErrorKind.UNEXPECTED_TYPE_PARAMETER, flat.peekFirst());
+      throw error(classTy.position(), ErrorKind.UNEXPECTED_TYPE_PARAMETER, flat.getFirst());
     }
     ClassSymbol classSym = (ClassSymbol) result.sym();
     for (Ident bit : result.remaining()) {
@@ -223,6 +234,7 @@
   }
 
   /** Evaluates a reference to another constant variable. */
+  @Nullable
   Const evalConstVar(ConstVarName t) {
     FieldInfo field = resolveField(t);
     if (field == null) {
@@ -273,7 +285,7 @@
         String.format("field %s", Iterables.getLast(t.name())));
   }
 
-  private FieldInfo resolveQualifiedField(ConstVarName t) {
+  private @Nullable FieldInfo resolveQualifiedField(ConstVarName t) {
     if (t.name().size() <= 1) {
       return null;
     }
@@ -296,10 +308,10 @@
   }
 
   /** Search for constant variables in lexically enclosing scopes. */
-  private FieldInfo lexicalField(
-      Env<ClassSymbol, TypeBoundClass> env, ClassSymbol sym, Ident name) {
+  private @Nullable FieldInfo lexicalField(
+      Env<ClassSymbol, TypeBoundClass> env, @Nullable ClassSymbol sym, Ident name) {
     while (sym != null) {
-      TypeBoundClass info = env.get(sym);
+      TypeBoundClass info = env.getNonNull(sym);
       FieldInfo field = Resolve.resolveField(env, origin, sym, name);
       if (field != null) {
         return field;
@@ -320,55 +332,311 @@
         if (!value.kind().equals(Const.Kind.PRIMITIVE)) {
           throw error(position, ErrorKind.EXPRESSION_ERROR);
         }
-        return coerce((Const.Value) value, ((Type.PrimTy) ty).primkind());
+        return coerce(position, (Value) value, ((Type.PrimTy) ty).primkind());
       default:
         throw new AssertionError(ty.tyKind());
     }
   }
 
   /** Casts the constant value to the given type. */
-  static Const.Value coerce(Const.Value value, TurbineConstantTypeKind kind) {
+  Value coerce(int position, Value value, TurbineConstantTypeKind kind) {
     switch (kind) {
-      case BOOLEAN:
-        return value.asBoolean();
-      case STRING:
-        return value.asString();
-      case LONG:
-        return value.asLong();
-      case INT:
-        return value.asInteger();
       case BYTE:
-        return value.asByte();
-      case CHAR:
-        return value.asChar();
+        return asByte(position, value);
       case SHORT:
-        return value.asShort();
-      case DOUBLE:
-        return value.asDouble();
+        return asShort(position, value);
+      case INT:
+        return asInt(position, value);
+      case LONG:
+        return asLong(position, value);
       case FLOAT:
-        return value.asFloat();
-      default:
-        throw new AssertionError(kind);
+        return asFloat(position, value);
+      case DOUBLE:
+        return asDouble(position, value);
+      case CHAR:
+        return asChar(position, value);
+      case BOOLEAN:
+      case STRING:
+      case NULL:
+        if (!value.constantTypeKind().equals(kind)) {
+          throw typeError(position, value, kind);
+        }
+        return value;
     }
+    throw new AssertionError(kind);
   }
 
-  private Const.Value evalValue(Expression tree) {
+  private Const.BooleanValue asBoolean(int position, Value value) {
+    if (!value.constantTypeKind().equals(TurbineConstantTypeKind.BOOLEAN)) {
+      throw typeError(position, value, TurbineConstantTypeKind.BOOLEAN);
+    }
+    return (Const.BooleanValue) value;
+  }
+
+  private Const.StringValue asString(int position, Value value) {
+    if (!value.constantTypeKind().equals(TurbineConstantTypeKind.STRING)) {
+      throw typeError(position, value, TurbineConstantTypeKind.STRING);
+    }
+    return (Const.StringValue) value;
+  }
+
+  private Const.StringValue toString(int position, Value value) {
+    String result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        result = String.valueOf(((Const.CharValue) value).value());
+        break;
+      case SHORT:
+        result = String.valueOf(((Const.ShortValue) value).value());
+        break;
+      case INT:
+        result = String.valueOf(((Const.IntValue) value).value());
+        break;
+      case LONG:
+        result = String.valueOf(((Const.LongValue) value).value());
+        break;
+      case FLOAT:
+        result = String.valueOf(((Const.FloatValue) value).value());
+        break;
+      case DOUBLE:
+        result = String.valueOf(((Const.DoubleValue) value).value());
+        break;
+      case BOOLEAN:
+        result = String.valueOf(((Const.BooleanValue) value).value());
+        break;
+      case BYTE:
+        result = String.valueOf(((Const.ByteValue) value).value());
+        break;
+      case STRING:
+        return (StringValue) value;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.STRING);
+    }
+    return new Const.StringValue(result);
+  }
+
+  private Const.CharValue asChar(int position, Value value) {
+    char result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        return (Const.CharValue) value;
+      case BYTE:
+        result = (char) ((Const.ByteValue) value).value();
+        break;
+      case SHORT:
+        result = (char) ((Const.ShortValue) value).value();
+        break;
+      case INT:
+        result = (char) ((Const.IntValue) value).value();
+        break;
+      case LONG:
+        result = (char) ((Const.LongValue) value).value();
+        break;
+      case FLOAT:
+        result = (char) ((Const.FloatValue) value).value();
+        break;
+      case DOUBLE:
+        result = (char) ((Const.DoubleValue) value).value();
+        break;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.CHAR);
+    }
+    return new Const.CharValue(result);
+  }
+
+  private Const.ByteValue asByte(int position, Value value) {
+    byte result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        result = (byte) ((Const.CharValue) value).value();
+        break;
+      case BYTE:
+        return (Const.ByteValue) value;
+      case SHORT:
+        result = (byte) ((Const.ShortValue) value).value();
+        break;
+      case INT:
+        result = (byte) ((Const.IntValue) value).value();
+        break;
+      case LONG:
+        result = (byte) ((Const.LongValue) value).value();
+        break;
+      case FLOAT:
+        result = (byte) ((Const.FloatValue) value).value();
+        break;
+      case DOUBLE:
+        result = (byte) ((Const.DoubleValue) value).value();
+        break;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.BYTE);
+    }
+    return new Const.ByteValue(result);
+  }
+
+  private Const.ShortValue asShort(int position, Value value) {
+    short result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        result = (short) ((Const.CharValue) value).value();
+        break;
+      case BYTE:
+        result = ((Const.ByteValue) value).value();
+        break;
+      case SHORT:
+        return (Const.ShortValue) value;
+      case INT:
+        result = (short) ((Const.IntValue) value).value();
+        break;
+      case LONG:
+        result = (short) ((Const.LongValue) value).value();
+        break;
+      case FLOAT:
+        result = (short) ((Const.FloatValue) value).value();
+        break;
+      case DOUBLE:
+        result = (short) ((Const.DoubleValue) value).value();
+        break;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.SHORT);
+    }
+    return new Const.ShortValue(result);
+  }
+
+  private Const.IntValue asInt(int position, Value value) {
+    int result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        result = ((CharValue) value).value();
+        break;
+      case BYTE:
+        result = ((Const.ByteValue) value).value();
+        break;
+      case SHORT:
+        result = ((Const.ShortValue) value).value();
+        break;
+      case INT:
+        return (Const.IntValue) value;
+      case LONG:
+        result = (int) ((Const.LongValue) value).value();
+        break;
+      case FLOAT:
+        result = (int) ((Const.FloatValue) value).value();
+        break;
+      case DOUBLE:
+        result = (int) ((Const.DoubleValue) value).value();
+        break;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.INT);
+    }
+    return new Const.IntValue(result);
+  }
+
+  private Const.LongValue asLong(int position, Value value) {
+    long result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        result = ((CharValue) value).value();
+        break;
+      case BYTE:
+        result = ((Const.ByteValue) value).value();
+        break;
+      case SHORT:
+        result = ((Const.ShortValue) value).value();
+        break;
+      case INT:
+        result = ((Const.IntValue) value).value();
+        break;
+      case LONG:
+        return (Const.LongValue) value;
+      case FLOAT:
+        result = (long) ((Const.FloatValue) value).value();
+        break;
+      case DOUBLE:
+        result = (long) ((Const.DoubleValue) value).value();
+        break;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.LONG);
+    }
+    return new Const.LongValue(result);
+  }
+
+  private Const.FloatValue asFloat(int position, Value value) {
+    float result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        result = ((CharValue) value).value();
+        break;
+      case BYTE:
+        result = ((Const.ByteValue) value).value();
+        break;
+      case SHORT:
+        result = ((Const.ShortValue) value).value();
+        break;
+      case INT:
+        result = (float) ((Const.IntValue) value).value();
+        break;
+      case LONG:
+        result = (float) ((Const.LongValue) value).value();
+        break;
+      case FLOAT:
+        return (FloatValue) value;
+      case DOUBLE:
+        result = (float) ((Const.DoubleValue) value).value();
+        break;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.FLOAT);
+    }
+    return new Const.FloatValue(result);
+  }
+
+  private Const.DoubleValue asDouble(int position, Value value) {
+    double result;
+    switch (value.constantTypeKind()) {
+      case CHAR:
+        result = ((CharValue) value).value();
+        break;
+      case BYTE:
+        result = ((Const.ByteValue) value).value();
+        break;
+      case SHORT:
+        result = ((Const.ShortValue) value).value();
+        break;
+      case INT:
+        result = ((Const.IntValue) value).value();
+        break;
+      case LONG:
+        result = (double) ((Const.LongValue) value).value();
+        break;
+      case FLOAT:
+        result = ((Const.FloatValue) value).value();
+        break;
+      case DOUBLE:
+        return (DoubleValue) value;
+      default:
+        throw typeError(position, value, TurbineConstantTypeKind.DOUBLE);
+    }
+    return new Const.DoubleValue(result);
+  }
+
+  private @Nullable Value evalValue(Expression tree) {
     Const result = eval(tree);
     // TODO(cushon): consider distinguishing between constant field and annotation values,
     // and only allowing class literals / enum constants in the latter
-    return (result instanceof Const.Value) ? (Const.Value) result : null;
+    return (result instanceof Value) ? (Value) result : null;
   }
 
-  private Const.Value evalConditional(Conditional t) {
-    Const.Value condition = evalValue(t.cond());
+  private @Nullable Value evalConditional(Conditional t) {
+    Value condition = evalValue(t.cond());
     if (condition == null) {
       return null;
     }
-    return condition.asBoolean().value() ? evalValue(t.iftrue()) : evalValue(t.iffalse());
+    return asBoolean(t.position(), condition).value()
+        ? evalValue(t.iftrue())
+        : evalValue(t.iffalse());
   }
 
-  private Const.Value evalUnary(Unary t) {
-    Const.Value expr = evalValue(t.expr());
+  private @Nullable Value evalUnary(Unary t) {
+    Value expr = evalValue(t.expr());
     if (expr == null) {
       return null;
     }
@@ -386,67 +654,67 @@
     }
   }
 
-  private Value unaryNegate(int position, Value expr) {
+  private @Nullable Value unaryNegate(int position, Value expr) {
     switch (expr.constantTypeKind()) {
       case BOOLEAN:
-        return new Const.BooleanValue(!expr.asBoolean().value());
+        return new Const.BooleanValue(!asBoolean(position, expr).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, expr.constantTypeKind());
     }
   }
 
-  private Value bitwiseComp(int position, Value expr) {
+  private @Nullable Value bitwiseComp(int position, Value expr) {
     expr = promoteUnary(position, expr);
     switch (expr.constantTypeKind()) {
       case INT:
-        return new Const.IntValue(~expr.asInteger().value());
+        return new Const.IntValue(~asInt(position, expr).value());
       case LONG:
-        return new Const.LongValue(~expr.asLong().value());
+        return new Const.LongValue(~asLong(position, expr).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, expr.constantTypeKind());
     }
   }
 
-  private Value unaryPlus(int position, Value expr) {
+  private @Nullable Value unaryPlus(int position, Value expr) {
     expr = promoteUnary(position, expr);
     switch (expr.constantTypeKind()) {
       case INT:
-        return new Const.IntValue(+expr.asInteger().value());
+        return new Const.IntValue(+asInt(position, expr).value());
       case LONG:
-        return new Const.LongValue(+expr.asLong().value());
+        return new Const.LongValue(+asLong(position, expr).value());
       case FLOAT:
-        return new Const.FloatValue(+expr.asFloat().value());
+        return new Const.FloatValue(+asFloat(position, expr).value());
       case DOUBLE:
-        return new Const.DoubleValue(+expr.asDouble().value());
+        return new Const.DoubleValue(+asDouble(position, expr).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, expr.constantTypeKind());
     }
   }
 
-  private Value unaryMinus(int position, Value expr) {
+  private @Nullable Value unaryMinus(int position, Value expr) {
     expr = promoteUnary(position, expr);
     switch (expr.constantTypeKind()) {
       case INT:
-        return new Const.IntValue(-expr.asInteger().value());
+        return new Const.IntValue(-asInt(position, expr).value());
       case LONG:
-        return new Const.LongValue(-expr.asLong().value());
+        return new Const.LongValue(-asLong(position, expr).value());
       case FLOAT:
-        return new Const.FloatValue(-expr.asFloat().value());
+        return new Const.FloatValue(-asFloat(position, expr).value());
       case DOUBLE:
-        return new Const.DoubleValue(-expr.asDouble().value());
+        return new Const.DoubleValue(-asDouble(position, expr).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, expr.constantTypeKind());
     }
   }
 
-  private Const.Value evalCast(TypeCast t) {
-    Const.Value expr = evalValue(t.expr());
+  private @Nullable Value evalCast(TypeCast t) {
+    Value expr = evalValue(t.expr());
     if (expr == null) {
       return null;
     }
     switch (t.ty().kind()) {
       case PRIM_TY:
-        return coerce(expr, ((Tree.PrimTy) t.ty()).tykind());
+        return coerce(t.expr().position(), expr, ((Tree.PrimTy) t.ty()).tykind());
       case CLASS_TY:
         {
           ClassTy classTy = (ClassTy) t.ty();
@@ -455,102 +723,102 @@
             // Explicit boxing cases (e.g. `(Boolean) false`) are legal, but not const exprs.
             return null;
           }
-          return expr.asString();
+          return toString(t.expr().position(), expr);
         }
       default:
         throw new AssertionError(t.ty().kind());
     }
   }
 
-  private Const.Value add(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value add(int position, Value a, Value b) {
     if (a.constantTypeKind() == TurbineConstantTypeKind.STRING
         || b.constantTypeKind() == TurbineConstantTypeKind.STRING) {
-      return new Const.StringValue(a.asString().value() + b.asString().value());
+      return new Const.StringValue(toString(position, a).value() + toString(position, b).value());
     }
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() + b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() + asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() + b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() + asLong(position, b).value());
       case FLOAT:
-        return new Const.FloatValue(a.asFloat().value() + b.asFloat().value());
+        return new Const.FloatValue(asFloat(position, a).value() + asFloat(position, b).value());
       case DOUBLE:
-        return new Const.DoubleValue(a.asDouble().value() + b.asDouble().value());
+        return new Const.DoubleValue(asDouble(position, a).value() + asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value subtract(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value subtract(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() - b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() - asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() - b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() - asLong(position, b).value());
       case FLOAT:
-        return new Const.FloatValue(a.asFloat().value() - b.asFloat().value());
+        return new Const.FloatValue(asFloat(position, a).value() - asFloat(position, b).value());
       case DOUBLE:
-        return new Const.DoubleValue(a.asDouble().value() - b.asDouble().value());
+        return new Const.DoubleValue(asDouble(position, a).value() - asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value mult(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value mult(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() * b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() * asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() * b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() * asLong(position, b).value());
       case FLOAT:
-        return new Const.FloatValue(a.asFloat().value() * b.asFloat().value());
+        return new Const.FloatValue(asFloat(position, a).value() * asFloat(position, b).value());
       case DOUBLE:
-        return new Const.DoubleValue(a.asDouble().value() * b.asDouble().value());
+        return new Const.DoubleValue(asDouble(position, a).value() * asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value divide(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value divide(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() / b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() / asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() / b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() / asLong(position, b).value());
       case FLOAT:
-        return new Const.FloatValue(a.asFloat().value() / b.asFloat().value());
+        return new Const.FloatValue(asFloat(position, a).value() / asFloat(position, b).value());
       case DOUBLE:
-        return new Const.DoubleValue(a.asDouble().value() / b.asDouble().value());
+        return new Const.DoubleValue(asDouble(position, a).value() / asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value mod(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value mod(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() % b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() % asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() % b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() % asLong(position, b).value());
       case FLOAT:
-        return new Const.FloatValue(a.asFloat().value() % b.asFloat().value());
+        return new Const.FloatValue(asFloat(position, a).value() % asFloat(position, b).value());
       case DOUBLE:
-        return new Const.DoubleValue(a.asDouble().value() % b.asDouble().value());
+        return new Const.DoubleValue(asDouble(position, a).value() % asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
@@ -560,289 +828,319 @@
 
   private static final int LONG_SHIFT_MASK = 0b111111;
 
-  private Const.Value shiftLeft(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value shiftLeft(int position, Value a, Value b) {
     a = promoteUnary(position, a);
     b = promoteUnary(position, b);
     switch (a.constantTypeKind()) {
       case INT:
         return new Const.IntValue(
-            a.asInteger().value() << (b.asInteger().value() & INT_SHIFT_MASK));
-      case LONG:
-        return new Const.LongValue(a.asLong().value() << (b.asInteger().value() & LONG_SHIFT_MASK));
-      default:
-        throw error(position, ErrorKind.OPERAND_TYPE, a.constantTypeKind());
-    }
-  }
-
-  private Const.Value shiftRight(int position, Const.Value a, Const.Value b) {
-    a = promoteUnary(position, a);
-    b = promoteUnary(position, b);
-    switch (a.constantTypeKind()) {
-      case INT:
-        return new Const.IntValue(
-            a.asInteger().value() >> (b.asInteger().value() & INT_SHIFT_MASK));
-      case LONG:
-        return new Const.LongValue(a.asLong().value() >> (b.asInteger().value() & LONG_SHIFT_MASK));
-      default:
-        throw error(position, ErrorKind.OPERAND_TYPE, a.constantTypeKind());
-    }
-  }
-
-  private Const.Value unsignedShiftRight(int position, Const.Value a, Const.Value b) {
-    a = promoteUnary(position, a);
-    b = promoteUnary(position, b);
-    switch (a.constantTypeKind()) {
-      case INT:
-        return new Const.IntValue(
-            a.asInteger().value() >>> (b.asInteger().value() & INT_SHIFT_MASK));
+            asInt(position, a).value() << (asInt(position, b).value() & INT_SHIFT_MASK));
       case LONG:
         return new Const.LongValue(
-            a.asLong().value() >>> (b.asInteger().value() & LONG_SHIFT_MASK));
+            asLong(position, a).value() << (asInt(position, b).value() & LONG_SHIFT_MASK));
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, a.constantTypeKind());
     }
   }
 
-  private Const.Value lessThan(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value shiftRight(int position, Value a, Value b) {
+    a = promoteUnary(position, a);
+    b = promoteUnary(position, b);
+    switch (a.constantTypeKind()) {
+      case INT:
+        return new Const.IntValue(
+            asInt(position, a).value() >> (asInt(position, b).value() & INT_SHIFT_MASK));
+      case LONG:
+        return new Const.LongValue(
+            asLong(position, a).value() >> (asInt(position, b).value() & LONG_SHIFT_MASK));
+      default:
+        throw error(position, ErrorKind.OPERAND_TYPE, a.constantTypeKind());
+    }
+  }
+
+  private @Nullable Value unsignedShiftRight(int position, Value a, Value b) {
+    a = promoteUnary(position, a);
+    b = promoteUnary(position, b);
+    switch (a.constantTypeKind()) {
+      case INT:
+        return new Const.IntValue(
+            asInt(position, a).value() >>> (asInt(position, b).value() & INT_SHIFT_MASK));
+      case LONG:
+        return new Const.LongValue(
+            asLong(position, a).value() >>> (asInt(position, b).value() & LONG_SHIFT_MASK));
+      default:
+        throw error(position, ErrorKind.OPERAND_TYPE, a.constantTypeKind());
+    }
+  }
+
+  private @Nullable Value lessThan(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.BooleanValue(a.asInteger().value() < b.asInteger().value());
+        return new Const.BooleanValue(asInt(position, a).value() < asInt(position, b).value());
       case LONG:
-        return new Const.BooleanValue(a.asLong().value() < b.asLong().value());
+        return new Const.BooleanValue(asLong(position, a).value() < asLong(position, b).value());
       case FLOAT:
-        return new Const.BooleanValue(a.asFloat().value() < b.asFloat().value());
+        return new Const.BooleanValue(asFloat(position, a).value() < asFloat(position, b).value());
       case DOUBLE:
-        return new Const.BooleanValue(a.asDouble().value() < b.asDouble().value());
+        return new Const.BooleanValue(
+            asDouble(position, a).value() < asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value lessThanEqual(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value lessThanEqual(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.BooleanValue(a.asInteger().value() <= b.asInteger().value());
+        return new Const.BooleanValue(asInt(position, a).value() <= asInt(position, b).value());
       case LONG:
-        return new Const.BooleanValue(a.asLong().value() <= b.asLong().value());
+        return new Const.BooleanValue(asLong(position, a).value() <= asLong(position, b).value());
       case FLOAT:
-        return new Const.BooleanValue(a.asFloat().value() <= b.asFloat().value());
+        return new Const.BooleanValue(asFloat(position, a).value() <= asFloat(position, b).value());
       case DOUBLE:
-        return new Const.BooleanValue(a.asDouble().value() <= b.asDouble().value());
+        return new Const.BooleanValue(
+            asDouble(position, a).value() <= asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value greaterThan(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value greaterThan(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.BooleanValue(a.asInteger().value() > b.asInteger().value());
+        return new Const.BooleanValue(asInt(position, a).value() > asInt(position, b).value());
       case LONG:
-        return new Const.BooleanValue(a.asLong().value() > b.asLong().value());
+        return new Const.BooleanValue(asLong(position, a).value() > asLong(position, b).value());
       case FLOAT:
-        return new Const.BooleanValue(a.asFloat().value() > b.asFloat().value());
+        return new Const.BooleanValue(asFloat(position, a).value() > asFloat(position, b).value());
       case DOUBLE:
-        return new Const.BooleanValue(a.asDouble().value() > b.asDouble().value());
+        return new Const.BooleanValue(
+            asDouble(position, a).value() > asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value greaterThanEqual(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value greaterThanEqual(int position, Value a, Value b) {
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.BooleanValue(a.asInteger().value() >= b.asInteger().value());
+        return new Const.BooleanValue(asInt(position, a).value() >= asInt(position, b).value());
       case LONG:
-        return new Const.BooleanValue(a.asLong().value() >= b.asLong().value());
+        return new Const.BooleanValue(asLong(position, a).value() >= asLong(position, b).value());
       case FLOAT:
-        return new Const.BooleanValue(a.asFloat().value() >= b.asFloat().value());
+        return new Const.BooleanValue(asFloat(position, a).value() >= asFloat(position, b).value());
       case DOUBLE:
-        return new Const.BooleanValue(a.asDouble().value() >= b.asDouble().value());
+        return new Const.BooleanValue(
+            asDouble(position, a).value() >= asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value equal(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value equal(int position, Value a, Value b) {
     switch (a.constantTypeKind()) {
       case STRING:
-        return new Const.BooleanValue(a.asString().value().equals(b.asString().value()));
+        return new Const.BooleanValue(
+            asString(position, a).value().equals(asString(position, b).value()));
       case BOOLEAN:
-        return new Const.BooleanValue(a.asBoolean().value() == b.asBoolean().value());
+        return new Const.BooleanValue(
+            asBoolean(position, a).value() == asBoolean(position, b).value());
       default:
         break;
     }
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.BooleanValue(a.asInteger().value() == b.asInteger().value());
+        return new Const.BooleanValue(asInt(position, a).value() == asInt(position, b).value());
       case LONG:
-        return new Const.BooleanValue(a.asLong().value() == b.asLong().value());
+        return new Const.BooleanValue(asLong(position, a).value() == asLong(position, b).value());
       case FLOAT:
-        return new Const.BooleanValue(a.asFloat().value() == b.asFloat().value());
+        return new Const.BooleanValue(asFloat(position, a).value() == asFloat(position, b).value());
       case DOUBLE:
-        return new Const.BooleanValue(a.asDouble().value() == b.asDouble().value());
+        return new Const.BooleanValue(
+            asDouble(position, a).value() == asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value notEqual(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value notEqual(int position, Value a, Value b) {
     switch (a.constantTypeKind()) {
       case STRING:
-        return new Const.BooleanValue(!a.asString().value().equals(b.asString().value()));
+        return new Const.BooleanValue(
+            !asString(position, a).value().equals(asString(position, b).value()));
       case BOOLEAN:
-        return new Const.BooleanValue(a.asBoolean().value() != b.asBoolean().value());
+        return new Const.BooleanValue(
+            asBoolean(position, a).value() != asBoolean(position, b).value());
       default:
         break;
     }
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.BooleanValue(a.asInteger().value() != b.asInteger().value());
+        return new Const.BooleanValue(asInt(position, a).value() != asInt(position, b).value());
       case LONG:
-        return new Const.BooleanValue(a.asLong().value() != b.asLong().value());
+        return new Const.BooleanValue(asLong(position, a).value() != asLong(position, b).value());
       case FLOAT:
-        return new Const.BooleanValue(a.asFloat().value() != b.asFloat().value());
+        return new Const.BooleanValue(asFloat(position, a).value() != asFloat(position, b).value());
       case DOUBLE:
-        return new Const.BooleanValue(a.asDouble().value() != b.asDouble().value());
+        return new Const.BooleanValue(
+            asDouble(position, a).value() != asDouble(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value bitwiseAnd(int position, Const.Value a, Const.Value b) {
+  private Value bitwiseAnd(int position, Value a, Value b) {
     switch (a.constantTypeKind()) {
       case BOOLEAN:
-        return new Const.BooleanValue(a.asBoolean().value() & b.asBoolean().value());
+        return new Const.BooleanValue(
+            asBoolean(position, a).value() & asBoolean(position, b).value());
       default:
         break;
     }
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() & b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() & asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() & b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() & asLong(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value bitwiseOr(int position, Const.Value a, Const.Value b) {
+  private Value bitwiseOr(int position, Value a, Value b) {
     switch (a.constantTypeKind()) {
       case BOOLEAN:
-        return new Const.BooleanValue(a.asBoolean().value() | b.asBoolean().value());
+        return new Const.BooleanValue(
+            asBoolean(position, a).value() | asBoolean(position, b).value());
       default:
         break;
     }
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() | b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() | asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() | b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() | asLong(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value bitwiseXor(int position, Const.Value a, Const.Value b) {
+  private @Nullable Value bitwiseXor(int position, Value a, Value b) {
     switch (a.constantTypeKind()) {
       case BOOLEAN:
-        return new Const.BooleanValue(a.asBoolean().value() ^ b.asBoolean().value());
+        return new Const.BooleanValue(
+            asBoolean(position, a).value() ^ asBoolean(position, b).value());
       default:
         break;
     }
     TurbineConstantTypeKind type = promoteBinary(position, a, b);
-    a = coerce(a, type);
-    b = coerce(b, type);
+    a = coerce(position, a, type);
+    b = coerce(position, b, type);
     switch (type) {
       case INT:
-        return new Const.IntValue(a.asInteger().value() ^ b.asInteger().value());
+        return new Const.IntValue(asInt(position, a).value() ^ asInt(position, b).value());
       case LONG:
-        return new Const.LongValue(a.asLong().value() ^ b.asLong().value());
+        return new Const.LongValue(asLong(position, a).value() ^ asLong(position, b).value());
       default:
         throw error(position, ErrorKind.OPERAND_TYPE, type);
     }
   }
 
-  private Const.Value evalBinary(Binary t) {
-    Const.Value lhs = evalValue(t.lhs());
-    Const.Value rhs = evalValue(t.rhs());
-    if (lhs == null || rhs == null) {
-      return null;
+  private @Nullable Value evalBinary(Binary t) {
+    Value result = null;
+    boolean first = true;
+    for (Expression child : t.children()) {
+      Value value = evalValue(child);
+      if (value == null) {
+        return null;
+      }
+      if (first) {
+        result = value;
+      } else {
+        result = evalBinary(child.position(), t.op(), requireNonNull(result), value);
+      }
+      first = false;
     }
-    switch (t.op()) {
+    return result;
+  }
+
+  private @Nullable Value evalBinary(int position, TurbineOperatorKind op, Value lhs, Value rhs) {
+    switch (op) {
       case PLUS:
-        return add(t.position(), lhs, rhs);
+        return add(position, lhs, rhs);
       case MINUS:
-        return subtract(t.position(), lhs, rhs);
+        return subtract(position, lhs, rhs);
       case MULT:
-        return mult(t.position(), lhs, rhs);
+        return mult(position, lhs, rhs);
       case DIVIDE:
-        return divide(t.position(), lhs, rhs);
+        return divide(position, lhs, rhs);
       case MODULO:
-        return mod(t.position(), lhs, rhs);
+        return mod(position, lhs, rhs);
       case SHIFT_LEFT:
-        return shiftLeft(t.position(), lhs, rhs);
+        return shiftLeft(position, lhs, rhs);
       case SHIFT_RIGHT:
-        return shiftRight(t.position(), lhs, rhs);
+        return shiftRight(position, lhs, rhs);
       case UNSIGNED_SHIFT_RIGHT:
-        return unsignedShiftRight(t.position(), lhs, rhs);
+        return unsignedShiftRight(position, lhs, rhs);
       case LESS_THAN:
-        return lessThan(t.position(), lhs, rhs);
+        return lessThan(position, lhs, rhs);
       case GREATER_THAN:
-        return greaterThan(t.position(), lhs, rhs);
+        return greaterThan(position, lhs, rhs);
       case LESS_THAN_EQ:
-        return lessThanEqual(t.position(), lhs, rhs);
+        return lessThanEqual(position, lhs, rhs);
       case GREATER_THAN_EQ:
-        return greaterThanEqual(t.position(), lhs, rhs);
+        return greaterThanEqual(position, lhs, rhs);
       case EQUAL:
-        return equal(t.position(), lhs, rhs);
+        return equal(position, lhs, rhs);
       case NOT_EQUAL:
-        return notEqual(t.position(), lhs, rhs);
+        return notEqual(position, lhs, rhs);
       case AND:
-        return new Const.BooleanValue(lhs.asBoolean().value() && rhs.asBoolean().value());
+        return new Const.BooleanValue(
+            asBoolean(position, lhs).value() && asBoolean(position, rhs).value());
       case OR:
-        return new Const.BooleanValue(lhs.asBoolean().value() || rhs.asBoolean().value());
+        return new Const.BooleanValue(
+            asBoolean(position, lhs).value() || asBoolean(position, rhs).value());
       case BITWISE_AND:
-        return bitwiseAnd(t.position(), lhs, rhs);
+        return bitwiseAnd(position, lhs, rhs);
       case BITWISE_XOR:
-        return bitwiseXor(t.position(), lhs, rhs);
+        return bitwiseXor(position, lhs, rhs);
       case BITWISE_OR:
-        return bitwiseOr(t.position(), lhs, rhs);
+        return bitwiseOr(position, lhs, rhs);
       default:
-        throw new AssertionError(t.op());
+        throw new AssertionError(op);
     }
   }
 
-  private Const.Value promoteUnary(int position, Value v) {
+  private Value promoteUnary(int position, Value v) {
     switch (v.constantTypeKind()) {
       case CHAR:
       case SHORT:
       case BYTE:
-        return v.asInteger();
+        return asInt(position, v);
       case INT:
       case LONG:
       case FLOAT:
@@ -853,7 +1151,7 @@
     }
   }
 
-  private TurbineConstantTypeKind promoteBinary(int position, Const.Value a, Const.Value b) {
+  private TurbineConstantTypeKind promoteBinary(int position, Value a, Value b) {
     a = promoteUnary(position, a);
     b = promoteUnary(position, b);
     switch (a.constantTypeKind()) {
@@ -921,7 +1219,7 @@
     if (info.sym() == null) {
       return info;
     }
-    TypeBoundClass annoClass = env.get(info.sym());
+    TypeBoundClass annoClass = env.getNonNull(info.sym());
     if (annoClass.kind() != TurbineTyKind.ANNOTATION) {
       // we've already reported an error for non-annotation symbols used as annotations,
       // skip error handling for annotation arguments
@@ -943,6 +1241,9 @@
         key = assign.name().value();
         expr = assign.expr();
       } else {
+        if (info.args().size() != 1) {
+          throw error(arg.position(), ErrorKind.ANNOTATION_VALUE_NAME);
+        }
         // expand the implicit 'value' name; `@Foo(42)` is sugar for `@Foo(value=42)`
         key = "value";
         expr = arg;
@@ -968,13 +1269,14 @@
     }
     for (MethodInfo methodInfo : template.values()) {
       if (!methodInfo.hasDefaultValue()) {
-        log.error(info.tree().position(), ErrorKind.MISSING_ANNOTATION_ARGUMENT, methodInfo.name());
+        throw error(
+            info.tree().position(), ErrorKind.MISSING_ANNOTATION_ARGUMENT, methodInfo.name());
       }
     }
     return info.withValues(ImmutableMap.copyOf(values));
   }
 
-  private TurbineAnnotationValue evalAnno(Tree.Anno t) {
+  private @Nullable TurbineAnnotationValue evalAnno(Tree.Anno t) {
     LookupResult result = scope.lookup(new LookupKey(t.name()));
     if (result == null) {
       log.error(
@@ -991,14 +1293,14 @@
     if (sym == null) {
       return null;
     }
-    if (env.get(sym).kind() != TurbineTyKind.ANNOTATION) {
+    if (env.getNonNull(sym).kind() != TurbineTyKind.ANNOTATION) {
       log.error(t.position(), ErrorKind.NOT_AN_ANNOTATION, sym);
     }
     AnnoInfo annoInfo = evaluateAnnotation(new AnnoInfo(source, sym, t, ImmutableMap.of()));
     return new TurbineAnnotationValue(annoInfo);
   }
 
-  private Const.ArrayInitValue evalArrayInit(ArrayInit t) {
+  private @Nullable ArrayInitValue evalArrayInit(ArrayInit t) {
     ImmutableList.Builder<Const> elements = ImmutableList.builder();
     for (Expression e : t.exprs()) {
       Const arg = eval(e);
@@ -1010,6 +1312,7 @@
     return new Const.ArrayInitValue(elements.build());
   }
 
+  @Nullable
   Const evalAnnotationValue(Tree tree, Type ty) {
     if (ty == null) {
       throw error(tree.position(), ErrorKind.EXPRESSION_ERROR);
@@ -1021,10 +1324,10 @@
     }
     switch (ty.tyKind()) {
       case PRIM_TY:
-        if (!(value instanceof Const.Value)) {
+        if (!(value instanceof Value)) {
           throw error(tree.position(), ErrorKind.EXPRESSION_ERROR);
         }
-        return coerce((Const.Value) value, ((Type.PrimTy) ty).primkind());
+        return coerce(tree.position(), (Value) value, ((Type.PrimTy) ty).primkind());
       case CLASS_TY:
       case TY_VAR:
         return value;
@@ -1050,13 +1353,17 @@
     return TurbineError.format(source, position, kind, args);
   }
 
-  public Const.Value evalFieldInitializer(Expression expression, Type type) {
+  private TurbineError typeError(int position, Value value, TurbineConstantTypeKind kind) {
+    return error(position, ErrorKind.TYPE_CONVERSION, value, value.constantTypeKind(), kind);
+  }
+
+  public @Nullable Value evalFieldInitializer(Expression expression, Type type) {
     try {
       Const value = eval(expression);
       if (value == null || value.kind() != Const.Kind.PRIMITIVE) {
         return null;
       }
-      return (Const.Value) cast(expression.position(), type, value);
+      return (Value) cast(expression.position(), type, value);
     } catch (TurbineError error) {
       for (TurbineDiagnostic diagnostic : error.diagnostics()) {
         switch (diagnostic.kind()) {
diff --git a/java/com/google/turbine/binder/CtSymClassBinder.java b/java/com/google/turbine/binder/CtSymClassBinder.java
index 1d7ece7..f0e21f2 100644
--- a/java/com/google/turbine/binder/CtSymClassBinder.java
+++ b/java/com/google/turbine/binder/CtSymClassBinder.java
@@ -24,7 +24,6 @@
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.primitives.Ints;
 import com.google.turbine.binder.bound.ModuleInfo;
 import com.google.turbine.binder.bytecode.BytecodeBinder;
 import com.google.turbine.binder.bytecode.BytecodeBoundClass;
@@ -36,19 +35,19 @@
 import com.google.turbine.binder.sym.ModuleSymbol;
 import com.google.turbine.zip.Zip;
 import java.io.IOException;
-import java.lang.reflect.Method;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.HashMap;
 import java.util.Map;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** Constructs a platform {@link ClassPath} from the current JDK's ct.sym file. */
 public final class CtSymClassBinder {
 
-  @Nullable
-  public static ClassPath bind(String version) throws IOException {
+  private static final int FEATURE_VERSION = Runtime.version().feature();
+
+  public static @Nullable ClassPath bind(int version) throws IOException {
     String javaHome = JAVA_HOME.value();
     requireNonNull(javaHome, "attempted to use --release, but JAVA_HOME is not set");
     Path ctSym = Paths.get(javaHome).resolve("lib/ct.sym");
@@ -60,7 +59,7 @@
     Env<ClassSymbol, BytecodeBoundClass> benv =
         new Env<ClassSymbol, BytecodeBoundClass>() {
           @Override
-          public BytecodeBoundClass get(ClassSymbol sym) {
+          public @Nullable BytecodeBoundClass get(ClassSymbol sym) {
             return map.get(sym);
           }
         };
@@ -81,7 +80,7 @@
       if (!ze.name().substring(0, idx).contains(releaseString)) {
         continue;
       }
-      if (isAtLeastJDK12()) {
+      if (FEATURE_VERSION >= 12) {
         // JDK >= 12 includes the module name as a prefix
         idx = name.indexOf('/', idx + 1);
       }
@@ -118,7 +117,7 @@
       }
 
       @Override
-      public Supplier<byte[]> resource(String input) {
+      public @Nullable Supplier<byte[]> resource(String input) {
         return null;
       }
     };
@@ -135,26 +134,12 @@
   }
 
   @VisibleForTesting
-  static String formatReleaseVersion(String version) {
-    Integer n = Ints.tryParse(version);
-    if (n == null || n <= 4 || n >= 36) {
-      throw new IllegalArgumentException("invalid release version: " + version);
+  static String formatReleaseVersion(int n) {
+    if (n <= 4 || n >= 36) {
+      throw new IllegalArgumentException("invalid release version: " + n);
     }
     return toUpperCase(Integer.toString(n, 36));
   }
 
-  private static boolean isAtLeastJDK12() {
-    int major;
-    try {
-      Method versionMethod = Runtime.class.getMethod("version");
-      Object version = versionMethod.invoke(null);
-      major = (int) version.getClass().getMethod("major").invoke(version);
-    } catch (ReflectiveOperationException e) {
-      // `Runtime.version()` was added in JDK 9
-      return false;
-    }
-    return major >= 12;
-  }
-
   private CtSymClassBinder() {}
 }
diff --git a/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java b/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java
index c5de8c1..65c1021 100644
--- a/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java
+++ b/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java
@@ -30,6 +30,7 @@
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.ParamInfo;
+import com.google.turbine.binder.bound.TypeBoundClass.RecordComponentInfo;
 import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.diag.TurbineError;
@@ -70,9 +71,11 @@
       SourceTypeBoundClass base, Env<ClassSymbol, TypeBoundClass> env) {
     return new SourceTypeBoundClass(
         base.interfaceTypes(),
+        base.permits(),
         base.superClassType(),
         base.typeParameterTypes(),
         base.access(),
+        bindComponents(env, base.components(), TurbineElementType.RECORD_COMPONENT),
         bindMethods(env, base.methods()),
         bindFields(env, base.fields()),
         base.owner(),
@@ -112,36 +115,58 @@
         base.sym(),
         base.tyParams(),
         returnType,
-        bindParameters(env, base.parameters()),
+        bindParameters(env, base.parameters(), TurbineElementType.PARAMETER),
         base.exceptions(),
         base.access(),
         base.defaultValue(),
         base.decl(),
         declarationAnnotations.build(),
-        base.receiver() != null ? bindParam(env, base.receiver()) : null);
+        base.receiver() != null
+            ? bindParam(env, base.receiver(), TurbineElementType.PARAMETER)
+            : null);
   }
 
   private static ImmutableList<ParamInfo> bindParameters(
-      Env<ClassSymbol, TypeBoundClass> env, ImmutableList<ParamInfo> params) {
+      Env<ClassSymbol, TypeBoundClass> env,
+      ImmutableList<ParamInfo> params,
+      TurbineElementType declarationTarget) {
     ImmutableList.Builder<ParamInfo> result = ImmutableList.builder();
     for (ParamInfo param : params) {
-      result.add(bindParam(env, param));
+      result.add(bindParam(env, param, declarationTarget));
     }
     return result.build();
   }
 
-  private static ParamInfo bindParam(Env<ClassSymbol, TypeBoundClass> env, ParamInfo base) {
+  private static ParamInfo bindParam(
+      Env<ClassSymbol, TypeBoundClass> env, ParamInfo base, TurbineElementType declarationTarget) {
     ImmutableList.Builder<AnnoInfo> declarationAnnotations = ImmutableList.builder();
     Type type =
         disambiguate(
-            env,
-            TurbineElementType.PARAMETER,
-            base.type(),
-            base.annotations(),
-            declarationAnnotations);
+            env, declarationTarget, base.type(), base.annotations(), declarationAnnotations);
     return new ParamInfo(base.sym(), type, declarationAnnotations.build(), base.access());
   }
 
+  private static ImmutableList<RecordComponentInfo> bindComponents(
+      Env<ClassSymbol, TypeBoundClass> env,
+      ImmutableList<RecordComponentInfo> components,
+      TurbineElementType declarationTarget) {
+    ImmutableList.Builder<RecordComponentInfo> result = ImmutableList.builder();
+    for (RecordComponentInfo component : components) {
+      ImmutableList.Builder<AnnoInfo> declarationAnnotations = ImmutableList.builder();
+      Type type =
+          disambiguate(
+              env,
+              declarationTarget,
+              component.type(),
+              component.annotations(),
+              declarationAnnotations);
+      result.add(
+          new RecordComponentInfo(
+              component.sym(), type, declarationAnnotations.build(), component.access()));
+    }
+    return result.build();
+  }
+
   /**
    * Moves type annotations in {@code annotations} to {@code type}, and adds any declaration
    * annotations on {@code type} to {@code declarationAnnotations}.
diff --git a/java/com/google/turbine/binder/FileManagerClassBinder.java b/java/com/google/turbine/binder/FileManagerClassBinder.java
index 42a8162..d36d2d8 100644
--- a/java/com/google/turbine/binder/FileManagerClassBinder.java
+++ b/java/com/google/turbine/binder/FileManagerClassBinder.java
@@ -40,7 +40,7 @@
 import javax.tools.JavaFileObject;
 import javax.tools.StandardJavaFileManager;
 import javax.tools.StandardLocation;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * Binds a {@link StandardJavaFileManager} to an {@link ClassPath}. This can be used to share a
@@ -54,7 +54,7 @@
     Env<ClassSymbol, BytecodeBoundClass> env =
         new Env<ClassSymbol, BytecodeBoundClass>() {
           @Override
-          public BytecodeBoundClass get(ClassSymbol sym) {
+          public @Nullable BytecodeBoundClass get(ClassSymbol sym) {
             return packageLookup.getPackage(this, sym.packageName()).get(sym);
           }
         };
@@ -77,7 +77,7 @@
       }
 
       @Override
-      public Supplier<byte[]> resource(String path) {
+      public @Nullable Supplier<byte[]> resource(String path) {
         return packageLookup.resource(path);
       }
     };
@@ -138,7 +138,7 @@
           });
     }
 
-    public Supplier<byte[]> resource(String resource) {
+    public @Nullable Supplier<byte[]> resource(String resource) {
       String dir;
       String name;
       int idx = resource.lastIndexOf('/');
@@ -203,7 +203,7 @@
     }
 
     @Override
-    public PackageScope lookupPackage(Iterable<String> names) {
+    public @Nullable PackageScope lookupPackage(Iterable<String> names) {
       String packageName = Joiner.on('/').join(names);
       Map<ClassSymbol, BytecodeBoundClass> pkg = packageLookup.getPackage(env, packageName);
       if (pkg.isEmpty()) {
diff --git a/java/com/google/turbine/binder/HierarchyBinder.java b/java/com/google/turbine/binder/HierarchyBinder.java
index 07d255c..ac2c840 100644
--- a/java/com/google/turbine/binder/HierarchyBinder.java
+++ b/java/com/google/turbine/binder/HierarchyBinder.java
@@ -34,6 +34,7 @@
 import com.google.turbine.tree.Tree;
 import com.google.turbine.tree.Tree.ClassTy;
 import java.util.ArrayDeque;
+import org.jspecify.nullness.Nullable;
 
 /** Type hierarchy binding. */
 public class HierarchyBinder {
@@ -82,6 +83,9 @@
         case CLASS:
           superclass = !origin.equals(ClassSymbol.OBJECT) ? ClassSymbol.OBJECT : null;
           break;
+        case RECORD:
+          superclass = ClassSymbol.RECORD;
+          break;
         default:
           throw new AssertionError(decl.tykind());
       }
@@ -110,14 +114,15 @@
       typeParameters.put(p.name().value(), new TyVarSymbol(origin, p.name().value()));
     }
 
-    return new SourceHeaderBoundClass(base, superclass, interfaces.build(), typeParameters.build());
+    return new SourceHeaderBoundClass(
+        base, superclass, interfaces.build(), typeParameters.buildOrThrow());
   }
 
   /**
    * Resolves the {@link ClassSymbol} for the given {@link Tree.ClassTy}, with handling for
    * non-canonical qualified type names.
    */
-  private ClassSymbol resolveClass(Tree.ClassTy ty) {
+  private @Nullable ClassSymbol resolveClass(Tree.ClassTy ty) {
     // flatten a left-recursive qualified type name to its component simple names
     // e.g. Foo<Bar>.Baz -> ["Foo", "Bar"]
     ArrayDeque<Tree.Ident> flat = new ArrayDeque<>();
@@ -142,7 +147,7 @@
     return sym;
   }
 
-  private ClassSymbol resolveNext(ClassTy ty, ClassSymbol sym, Tree.Ident bit) {
+  private @Nullable ClassSymbol resolveNext(ClassTy ty, ClassSymbol sym, Tree.Ident bit) {
     ClassSymbol next;
     try {
       next = Resolve.resolve(env, origin, sym, bit);
@@ -160,11 +165,11 @@
   }
 
   /** Resolve a qualified type name to a symbol. */
-  private LookupResult lookup(Tree tree, LookupKey lookup) {
+  private @Nullable LookupResult lookup(Tree tree, LookupKey lookup) {
     // Handle any lexically enclosing class declarations (if we're binding a member class).
     // We could build out scopes for this, but it doesn't seem worth it. (And sharing the scopes
     // with other members of the same enclosing declaration would be complicated.)
-    for (ClassSymbol curr = base.owner(); curr != null; curr = env.get(curr).owner()) {
+    for (ClassSymbol curr = base.owner(); curr != null; curr = env.getNonNull(curr).owner()) {
       ClassSymbol result;
       try {
         result = Resolve.resolve(env, origin, curr, lookup.first());
diff --git a/java/com/google/turbine/binder/JimageClassBinder.java b/java/com/google/turbine/binder/JimageClassBinder.java
index d11dda1..53a6a3a 100644
--- a/java/com/google/turbine/binder/JimageClassBinder.java
+++ b/java/com/google/turbine/binder/JimageClassBinder.java
@@ -17,6 +17,7 @@
 package com.google.turbine.binder;
 
 import static com.google.common.base.StandardSystemProperty.JAVA_HOME;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Supplier;
@@ -52,7 +53,7 @@
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** Constructs a platform {@link ClassPath} from the current JDK's jimage file using jrtfs. */
 public class JimageClassBinder {
@@ -104,11 +105,13 @@
     this.modulesRoot = modules;
   }
 
+  @Nullable
   Path modulePath(String moduleName) {
     Path path = modulesRoot.resolve(moduleName);
     return Files.exists(path) ? path : null;
   }
 
+  @Nullable
   ModuleInfo module(String moduleName) {
     ModuleInfo result = moduleMap.get(moduleName);
     if (result == null) {
@@ -134,13 +137,14 @@
     Env<ClassSymbol, BytecodeBoundClass> env =
         new Env<ClassSymbol, BytecodeBoundClass>() {
           @Override
-          public BytecodeBoundClass get(ClassSymbol sym) {
+          public @Nullable BytecodeBoundClass get(ClassSymbol sym) {
             return JimageClassBinder.this.env.get(sym);
           }
         };
     for (String moduleName : moduleNames) {
       if (moduleName != null) {
-        Path modulePath = modulePath(moduleName);
+        // TODO(cushon): is this requireNonNull safe?
+        Path modulePath = requireNonNull(modulePath(moduleName), moduleName);
         Path modulePackagePath = modulePath.resolve(packageName);
         try (DirectoryStream<Path> ds = Files.newDirectoryStream(modulePackagePath)) {
           for (Path path : ds) {
@@ -181,9 +185,8 @@
 
     final Scope topLevelScope =
         new Scope() {
-          @Nullable
           @Override
-          public LookupResult lookup(LookupKey lookupKey) {
+          public @Nullable LookupResult lookup(LookupKey lookupKey) {
             // Find the longest prefix of the key that corresponds to a package name.
             // TODO(cushon): SimpleTopLevelIndex uses a prefix map for this, does it matter?
             Scope scope = null;
@@ -213,15 +216,14 @@
     }
 
     @Override
-    public PackageScope lookupPackage(Iterable<String> name) {
+    public @Nullable PackageScope lookupPackage(Iterable<String> name) {
       String packageName = Joiner.on('/').join(name);
       if (!initPackage(packageName)) {
         return null;
       }
       return new PackageScope() {
-        @Nullable
         @Override
-        public LookupResult lookup(LookupKey lookupKey) {
+        public @Nullable LookupResult lookup(LookupKey lookupKey) {
           ClassSymbol sym = packageClassesBySimpleName.get(packageName, lookupKey.first().value());
           return sym != null ? new LookupResult(sym, lookupKey) : null;
         }
@@ -242,7 +244,7 @@
     public Env<ClassSymbol, BytecodeBoundClass> env() {
       return new Env<ClassSymbol, BytecodeBoundClass>() {
         @Override
-        public BytecodeBoundClass get(ClassSymbol sym) {
+        public @Nullable BytecodeBoundClass get(ClassSymbol sym) {
           return initPackage(sym.packageName()) ? env.get(sym) : null;
         }
       };
@@ -252,7 +254,7 @@
     public Env<ModuleSymbol, ModuleInfo> moduleEnv() {
       return new Env<ModuleSymbol, ModuleInfo>() {
         @Override
-        public ModuleInfo get(ModuleSymbol module) {
+        public @Nullable ModuleInfo get(ModuleSymbol module) {
           return module(module.name());
         }
       };
@@ -264,7 +266,7 @@
     }
 
     @Override
-    public Supplier<byte[]> resource(String input) {
+    public @Nullable Supplier<byte[]> resource(String input) {
       return null;
     }
   }
diff --git a/java/com/google/turbine/binder/ModuleBinder.java b/java/com/google/turbine/binder/ModuleBinder.java
index 04ce81d..e88440d 100644
--- a/java/com/google/turbine/binder/ModuleBinder.java
+++ b/java/com/google/turbine/binder/ModuleBinder.java
@@ -16,8 +16,6 @@
 
 package com.google.turbine.binder;
 
-import static com.google.common.base.Verify.verifyNotNull;
-
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -139,16 +137,16 @@
       }
     }
     if (!requiresJavaBase) {
-      // everything requires java.base, either explicitly or implicitly
+      // Everything requires java.base, either explicitly or implicitly.
       ModuleInfo javaBaseModule = moduleEnv.get(ModuleSymbol.JAVA_BASE);
-      verifyNotNull(javaBaseModule, ModuleSymbol.JAVA_BASE.name());
+      // Tolerate a missing java.base module, e.g. when compiling a module against a non-modular
+      // bootclasspath, and just omit the version below.
+      String javaBaseVersion = javaBaseModule != null ? javaBaseModule.version() : null;
       requires =
           ImmutableList.<RequireInfo>builder()
               .add(
                   new RequireInfo(
-                      ModuleSymbol.JAVA_BASE.name(),
-                      TurbineFlag.ACC_MANDATED,
-                      javaBaseModule.version()))
+                      ModuleSymbol.JAVA_BASE.name(), TurbineFlag.ACC_MANDATED, javaBaseVersion))
               .addAll(requires.build());
     }
 
diff --git a/java/com/google/turbine/binder/Processing.java b/java/com/google/turbine/binder/Processing.java
index 16407aa..616bf2c 100644
--- a/java/com/google/turbine/binder/Processing.java
+++ b/java/com/google/turbine/binder/Processing.java
@@ -19,7 +19,6 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Stopwatch;
@@ -30,7 +29,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Sets;
-import com.google.common.primitives.Ints;
 import com.google.turbine.binder.Binder.BindingResult;
 import com.google.turbine.binder.Binder.Statistics;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
@@ -61,7 +59,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -74,13 +71,12 @@
 import javax.lang.model.SourceVersion;
 import javax.lang.model.element.TypeElement;
 import javax.tools.Diagnostic;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** Top level annotation processing logic, see also {@link Binder}. */
 public class Processing {
 
-  @Nullable
-  static BindingResult process(
+  static @Nullable BindingResult process(
       TurbineLog log,
       final ImmutableList<CompUnit> initialSources,
       final ClassPath classpath,
@@ -99,10 +95,9 @@
     TurbineFiler filer =
         new TurbineFiler(
             seen,
-            new Function<String, Supplier<byte[]>>() {
-              @Nullable
+            new Function<String, @Nullable Supplier<byte[]>>() {
               @Override
-              public Supplier<byte[]> apply(@Nullable String input) {
+              public @Nullable Supplier<byte[]> apply(String input) {
                 // TODO(cushon): should annotation processors be allowed to generate code with
                 // dependencies between source and bytecode, or vice versa?
                 // Currently generated classes are not available on the classpath when compiling
@@ -277,7 +272,7 @@
     for (Processor processor : processorInfo.processors()) {
       result.put(processor, SupportedAnnotationTypes.create(processor));
     }
-    return result.build();
+    return result.buildOrThrow();
   }
 
   @AutoValue
@@ -316,7 +311,7 @@
       Env<ClassSymbol, TypeBoundClass> env, Iterable<ClassSymbol> syms) {
     ImmutableSetMultimap.Builder<ClassSymbol, Symbol> result = ImmutableSetMultimap.builder();
     for (ClassSymbol sym : syms) {
-      TypeBoundClass info = env.get(sym);
+      TypeBoundClass info = env.getNonNull(sym);
       for (AnnoInfo annoInfo : info.annotations()) {
         if (sym.simpleName().equals("package-info")) {
           addAnno(result, annoInfo, sym.owner());
@@ -349,7 +344,7 @@
 
   // TODO(cushon): consider memoizing this (or isAnnotationInherited) if they show up in profiles
   private static ImmutableSet<ClassSymbol> inheritedAnnotations(
-      Set<ClassSymbol> seen, ClassSymbol sym, Env<ClassSymbol, TypeBoundClass> env) {
+      Set<ClassSymbol> seen, @Nullable ClassSymbol sym, Env<ClassSymbol, TypeBoundClass> env) {
     ImmutableSet.Builder<ClassSymbol> result = ImmutableSet.builder();
     ClassSymbol curr = sym;
     while (curr != null && seen.add(curr)) {
@@ -394,6 +389,7 @@
   }
 
   public static ProcessorInfo initializeProcessors(
+      SourceVersion sourceVersion,
       ImmutableList<String> javacopts,
       ImmutableSet<String> processorNames,
       ClassLoader processorLoader) {
@@ -402,7 +398,6 @@
     }
     ImmutableList<Processor> processors = instantiateProcessors(processorNames, processorLoader);
     ImmutableMap<String, String> processorOptions = processorOptions(javacopts);
-    SourceVersion sourceVersion = parseSourceVersion(javacopts);
     return ProcessorInfo.create(processors, processorLoader, processorOptions, sourceVersion);
   }
 
@@ -429,7 +424,7 @@
     }
     return new URLClassLoader(
         toUrls(processorPath),
-        new ClassLoader(getPlatformClassLoader()) {
+        new ClassLoader(ClassLoader.getPlatformClassLoader()) {
           @Override
           protected Class<?> findClass(String name) throws ClassNotFoundException {
             if (name.equals("com.google.turbine.processing.TurbineProcessingEnvironment")) {
@@ -453,54 +448,6 @@
         });
   }
 
-  @VisibleForTesting
-  static SourceVersion parseSourceVersion(ImmutableList<String> javacopts) {
-    SourceVersion sourceVersion = SourceVersion.latestSupported();
-    Iterator<String> it = javacopts.iterator();
-    while (it.hasNext()) {
-      String option = it.next();
-      switch (option) {
-        case "-source":
-          if (!it.hasNext()) {
-            throw new IllegalArgumentException("-source requires an argument");
-          }
-          sourceVersion = parseSourceVersion(it.next());
-          break;
-        default:
-          break;
-      }
-    }
-    return sourceVersion;
-  }
-
-  private static SourceVersion parseSourceVersion(String value) {
-    boolean hasPrefix = value.startsWith("1.");
-    Integer version = Ints.tryParse(hasPrefix ? value.substring("1.".length()) : value);
-    if (!isValidSourceVersion(version, hasPrefix)) {
-      throw new IllegalArgumentException("invalid -source version: " + value);
-    }
-    try {
-      return SourceVersion.valueOf("RELEASE_" + version);
-    } catch (IllegalArgumentException unused) {
-      throw new IllegalArgumentException("invalid -source version: " + value);
-    }
-  }
-
-  private static boolean isValidSourceVersion(Integer version, boolean hasPrefix) {
-    if (version == null) {
-      return false;
-    }
-    if (version < 5) {
-      // the earliest source version supported by JDK 8 is Java 5
-      return false;
-    }
-    if (hasPrefix && version > 10) {
-      // javac supports legacy `1.*` version numbers for source versions up to Java 10
-      return false;
-    }
-    return true;
-  }
-
   private static URL[] toUrls(ImmutableList<String> processorPath) throws MalformedURLException {
     URL[] urls = new URL[processorPath.size()];
     int i = 0;
@@ -510,15 +457,6 @@
     return urls;
   }
 
-  public static ClassLoader getPlatformClassLoader() {
-    try {
-      return (ClassLoader) ClassLoader.class.getMethod("getPlatformClassLoader").invoke(null);
-    } catch (ReflectiveOperationException e) {
-      // In earlier releases, set 'null' as the parent to delegate to the boot class loader.
-      return null;
-    }
-  }
-
   private static ImmutableMap<String, String> processorOptions(ImmutableList<String> javacopts) {
     Map<String, String> result = new LinkedHashMap<>(); // ImmutableMap.Builder rejects duplicates
     for (String javacopt : javacopts) {
@@ -550,8 +488,7 @@
      * The classloader to use for annotation processor implementations, and any annotations they
      * access reflectively.
      */
-    @Nullable
-    abstract ClassLoader loader();
+    abstract @Nullable ClassLoader loader();
 
     /** Command line annotation processing options, passed to javac with {@code -Akey=value}. */
     abstract ImmutableMap<String, String> options();
@@ -609,7 +546,7 @@
         // requireNonNull is safe, barring bizarre processor implementations (e.g., anonymous class)
         result.put(requireNonNull(e.getKey().getCanonicalName()), e.getValue().elapsed());
       }
-      return result.build();
+      return result.buildOrThrow();
     }
   }
 
diff --git a/java/com/google/turbine/binder/Resolve.java b/java/com/google/turbine/binder/Resolve.java
index 66e1036..6b76389 100644
--- a/java/com/google/turbine/binder/Resolve.java
+++ b/java/com/google/turbine/binder/Resolve.java
@@ -31,6 +31,7 @@
 import java.util.HashSet;
 import java.util.Objects;
 import java.util.Set;
+import org.jspecify.nullness.Nullable;
 
 /** Qualified name resolution. */
 public final class Resolve {
@@ -40,17 +41,17 @@
    * qualified by the given symbol. The search considers members that are inherited from
    * superclasses or interfaces.
    */
-  public static ClassSymbol resolve(
+  public static @Nullable ClassSymbol resolve(
       Env<ClassSymbol, ? extends HeaderBoundClass> env,
-      ClassSymbol origin,
+      @Nullable ClassSymbol origin,
       ClassSymbol sym,
       Tree.Ident simpleName) {
     return resolve(env, origin, sym, simpleName, new HashSet<>());
   }
 
-  private static ClassSymbol resolve(
+  private static @Nullable ClassSymbol resolve(
       Env<ClassSymbol, ? extends HeaderBoundClass> env,
-      ClassSymbol origin,
+      @Nullable ClassSymbol origin,
       ClassSymbol sym,
       Tree.Ident simpleName,
       Set<ClassSymbol> seen) {
@@ -69,13 +70,13 @@
     }
     if (bound.superclass() != null) {
       result = resolve(env, origin, bound.superclass(), simpleName, seen);
-      if (result != null && visible(origin, result, env.get(result))) {
+      if (result != null && visible(origin, result, env.getNonNull(result))) {
         return result;
       }
     }
     for (ClassSymbol i : bound.interfaces()) {
       result = resolve(env, origin, i, simpleName, seen);
-      if (result != null && visible(origin, result, env.get(result))) {
+      if (result != null && visible(origin, result, env.getNonNull(result))) {
         return result;
       }
     }
@@ -87,10 +88,10 @@
    * env} and {@code origin} symbol.
    */
   public static ResolveFunction resolveFunction(
-      Env<ClassSymbol, ? extends HeaderBoundClass> env, ClassSymbol origin) {
+      Env<ClassSymbol, ? extends HeaderBoundClass> env, @Nullable ClassSymbol origin) {
     return new ResolveFunction() {
       @Override
-      public ClassSymbol resolveOne(ClassSymbol base, Tree.Ident name) {
+      public @Nullable ClassSymbol resolveOne(ClassSymbol base, Tree.Ident name) {
         try {
           return Resolve.resolve(env, origin, base, name);
         } catch (LazyBindingError e) {
@@ -113,24 +114,24 @@
     }
 
     @Override
-    public ClassSymbol resolveOne(ClassSymbol sym, Tree.Ident bit) {
+    public @Nullable ClassSymbol resolveOne(ClassSymbol sym, Tree.Ident bit) {
       BoundClass ci = env.get(sym);
       if (ci == null) {
         return null;
       }
-      sym = ci.children().get(bit.value());
-      if (sym == null) {
+      ClassSymbol result = ci.children().get(bit.value());
+      if (result == null) {
         return null;
       }
-      if (!visible(sym)) {
+      if (!visible(result)) {
         return null;
       }
-      return sym;
+      return result;
     }
 
     @Override
     public boolean visible(ClassSymbol sym) {
-      TurbineVisibility visibility = TurbineVisibility.fromAccess(env.get(sym).access());
+      TurbineVisibility visibility = TurbineVisibility.fromAccess(env.getNonNull(sym).access());
       switch (visibility) {
         case PUBLIC:
           return true;
@@ -149,14 +150,17 @@
    * qualified by the given symbol. The search considers members that are inherited from
    * superclasses or interfaces.
    */
-  public static FieldInfo resolveField(
-      Env<ClassSymbol, TypeBoundClass> env, ClassSymbol origin, ClassSymbol sym, Tree.Ident name) {
+  public static @Nullable FieldInfo resolveField(
+      Env<ClassSymbol, TypeBoundClass> env,
+      @Nullable ClassSymbol origin,
+      ClassSymbol sym,
+      Tree.Ident name) {
     return resolveField(env, origin, sym, name, new HashSet<>());
   }
 
-  private static FieldInfo resolveField(
+  private static @Nullable FieldInfo resolveField(
       Env<ClassSymbol, TypeBoundClass> env,
-      ClassSymbol origin,
+      @Nullable ClassSymbol origin,
       ClassSymbol sym,
       Tree.Ident name,
       Set<ClassSymbol> seen) {
@@ -189,23 +193,26 @@
   }
 
   /** Is the given field visible when inherited into class origin? */
-  private static boolean visible(ClassSymbol origin, FieldInfo info) {
+  private static boolean visible(@Nullable ClassSymbol origin, FieldInfo info) {
     return visible(origin, info.sym().owner(), info.access());
   }
 
   /** Is the given type visible when inherited into class origin? */
-  private static boolean visible(ClassSymbol origin, ClassSymbol sym, HeaderBoundClass info) {
+  private static boolean visible(
+      @Nullable ClassSymbol origin, ClassSymbol sym, HeaderBoundClass info) {
     return visible(origin, sym, info.access());
   }
 
-  private static boolean visible(ClassSymbol origin, ClassSymbol owner, int access) {
+  private static boolean visible(@Nullable ClassSymbol origin, ClassSymbol owner, int access) {
     TurbineVisibility visibility = TurbineVisibility.fromAccess(access);
     switch (visibility) {
       case PUBLIC:
       case PROTECTED:
         return true;
       case PACKAGE:
-        return Objects.equals(owner.packageName(), origin.packageName());
+        // origin can be null if we aren't in a package scope (e.g. we're processing a module
+        // declaration), in which case package-visible members aren't visible
+        return origin != null && Objects.equals(owner.packageName(), origin.packageName());
       case PRIVATE:
         // Private members of lexically enclosing declarations are not handled,
         // since this visibility check is only used for inherited members.
diff --git a/java/com/google/turbine/binder/TypeBinder.java b/java/com/google/turbine/binder/TypeBinder.java
index a28acd9..92d2827 100644
--- a/java/com/google/turbine/binder/TypeBinder.java
+++ b/java/com/google/turbine/binder/TypeBinder.java
@@ -16,6 +16,7 @@
 
 package com.google.turbine.binder;
 
+import static com.google.common.collect.Iterables.getLast;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Joiner;
@@ -27,6 +28,7 @@
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.ParamInfo;
+import com.google.turbine.binder.bound.TypeBoundClass.RecordComponentInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.TyVarInfo;
 import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.lookup.CompoundScope;
@@ -37,6 +39,7 @@
 import com.google.turbine.binder.sym.FieldSymbol;
 import com.google.turbine.binder.sym.MethodSymbol;
 import com.google.turbine.binder.sym.ParamSymbol;
+import com.google.turbine.binder.sym.RecordComponentSymbol;
 import com.google.turbine.binder.sym.Symbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
 import com.google.turbine.diag.TurbineError.ErrorKind;
@@ -63,6 +66,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.jspecify.nullness.Nullable;
 
 /** Type binding. */
 public class TypeBinder {
@@ -79,7 +83,7 @@
     }
 
     @Override
-    public LookupResult lookup(LookupKey lookup) {
+    public @Nullable LookupResult lookup(LookupKey lookup) {
       if (name.equals(lookup.first().value())) {
         return new LookupResult(sym, lookup);
       }
@@ -96,7 +100,7 @@
     }
 
     @Override
-    public LookupResult lookup(LookupKey lookupKey) {
+    public @Nullable LookupResult lookup(LookupKey lookupKey) {
       Symbol sym = tps.get(lookupKey.first().value());
       return sym != null ? new LookupResult(sym, lookupKey) : null;
     }
@@ -116,14 +120,14 @@
     }
 
     @Override
-    public LookupResult lookup(LookupKey lookup) {
+    public @Nullable LookupResult lookup(LookupKey lookup) {
       ClassSymbol curr = sym;
       while (curr != null) {
-        HeaderBoundClass info = env.get(curr);
         Symbol result = Resolve.resolve(env, sym, curr, lookup.first());
         if (result != null) {
           return new LookupResult(result, lookup);
         }
+        HeaderBoundClass info = env.getNonNull(curr);
         result = info.typeParameters().get(lookup.first().value());
         if (result != null) {
           return new LookupResult(result, lookup);
@@ -168,8 +172,10 @@
     CompoundScope enclosingScope =
         base.scope()
             .toScope(Resolve.resolveFunction(env, owner))
-            .append(new SingletonScope(base.decl().name().value(), owner))
-            .append(new ClassMemberScope(base.owner(), env));
+            .append(new SingletonScope(base.decl().name().value(), owner));
+    if (base.owner() != null) {
+      enclosingScope = enclosingScope.append(new ClassMemberScope(base.owner(), env));
+    }
 
     ImmutableList<AnnoInfo> annotations = bindAnnotations(enclosingScope, base.decl().annos());
 
@@ -212,6 +218,9 @@
         }
         superClassType = Type.ClassTy.OBJECT;
         break;
+      case RECORD:
+        superClassType = Type.ClassTy.asNonParametricClassTy(ClassSymbol.RECORD);
+        break;
       default:
         throw new AssertionError(base.decl().tykind());
     }
@@ -220,26 +229,43 @@
       interfaceTypes.add(bindClassTy(bindingScope, i));
     }
 
+    ImmutableList.Builder<ClassSymbol> permits = ImmutableList.builder();
+    for (Tree.ClassTy i : base.decl().permits()) {
+      Type type = bindClassTy(bindingScope, i);
+      if (!type.tyKind().equals(Type.TyKind.CLASS_TY)) {
+        throw new AssertionError(type.tyKind());
+      }
+      permits.add(((Type.ClassTy) type).sym());
+    }
+
     CompoundScope scope =
         base.scope()
             .toScope(Resolve.resolveFunction(env, owner))
             .append(new SingletonScope(base.decl().name().value(), owner))
             .append(new ClassMemberScope(owner, env));
 
-    List<MethodInfo> methods =
+    SyntheticMethods syntheticMethods = new SyntheticMethods();
+
+    ImmutableList<RecordComponentInfo> components = bindComponents(scope, base.decl().components());
+
+    ImmutableList.Builder<MethodInfo> methods =
         ImmutableList.<MethodInfo>builder()
-            .addAll(syntheticMethods())
-            .addAll(bindMethods(scope, base.decl().members()))
-            .build();
+            .addAll(syntheticMethods(syntheticMethods, components))
+            .addAll(bindMethods(scope, base.decl().members(), components));
+    if (base.kind().equals(TurbineTyKind.RECORD)) {
+      methods.addAll(syntheticRecordMethods(syntheticMethods, components));
+    }
 
     ImmutableList<FieldInfo> fields = bindFields(scope, base.decl().members());
 
     return new SourceTypeBoundClass(
         interfaceTypes.build(),
+        permits.build(),
         superClassType,
         typeParameterTypes,
         base.access(),
-        ImmutableList.copyOf(methods),
+        components,
+        methods.build(),
         fields,
         base.owner(),
         base.kind(),
@@ -254,23 +280,79 @@
         base.decl());
   }
 
+  /**
+   * A generated for synthetic {@link MethodSymbol}s.
+   *
+   * <p>Each {@link MethodSymbol} contains an index into its enclosing class, to enable comparing
+   * the symbols for equality. For synthetic methods we use an arbitrary unique negative index.
+   */
+  private static class SyntheticMethods {
+
+    private int idx = -1;
+
+    MethodSymbol create(ClassSymbol owner, String name) {
+      return new MethodSymbol(idx--, owner, name);
+    }
+  }
+
+  private ImmutableList<RecordComponentInfo> bindComponents(
+      CompoundScope scope, ImmutableList<Tree.VarDecl> components) {
+    ImmutableList.Builder<RecordComponentInfo> result = ImmutableList.builder();
+    for (Tree.VarDecl p : components) {
+      int access = 0;
+      for (TurbineModifier m : p.mods()) {
+        access |= m.flag();
+      }
+      RecordComponentInfo param =
+          new RecordComponentInfo(
+              new RecordComponentSymbol(owner, p.name().value()),
+              bindTy(scope, p.ty()),
+              bindAnnotations(scope, p.annos()),
+              access);
+      result.add(param);
+    }
+    return result.build();
+  }
+
   /** Collect synthetic and implicit methods, including default constructors and enum methods. */
-  ImmutableList<MethodInfo> syntheticMethods() {
+  ImmutableList<MethodInfo> syntheticMethods(
+      SyntheticMethods syntheticMethods, ImmutableList<RecordComponentInfo> components) {
     switch (base.kind()) {
       case CLASS:
-        return maybeDefaultConstructor();
+        return maybeDefaultConstructor(syntheticMethods);
+      case RECORD:
+        return maybeDefaultRecordConstructor(syntheticMethods, components);
       case ENUM:
-        return syntheticEnumMethods();
+        return syntheticEnumMethods(syntheticMethods);
       default:
         return ImmutableList.of();
     }
   }
 
-  private ImmutableList<MethodInfo> maybeDefaultConstructor() {
+  private ImmutableList<MethodInfo> maybeDefaultRecordConstructor(
+      SyntheticMethods syntheticMethods, ImmutableList<RecordComponentInfo> components) {
     if (hasConstructor()) {
       return ImmutableList.of();
     }
-    MethodSymbol symbol = new MethodSymbol(-1, owner, "<init>");
+    MethodSymbol symbol = syntheticMethods.create(owner, "<init>");
+    ImmutableList.Builder<ParamInfo> params = ImmutableList.builder();
+    for (RecordComponentInfo component : components) {
+      params.add(
+          new ParamInfo(
+              new ParamSymbol(symbol, component.name()),
+              component.type(),
+              component.annotations(),
+              component.access()));
+    }
+    return ImmutableList.of(
+        syntheticConstructor(symbol, params.build(), TurbineVisibility.fromAccess(base.access())));
+  }
+
+  private ImmutableList<MethodInfo> maybeDefaultConstructor(SyntheticMethods syntheticMethods) {
+    if (hasConstructor()) {
+      return ImmutableList.of();
+    }
+    MethodSymbol symbol = syntheticMethods.create(owner, "<init>");
     ImmutableList<ParamInfo> formals;
     if (hasEnclosingInstance(base)) {
       formals = ImmutableList.of(enclosingInstanceParameter(symbol));
@@ -285,6 +367,10 @@
       MethodSymbol symbol, ImmutableList<ParamInfo> formals, TurbineVisibility visibility) {
     int access = visibility.flag();
     access |= (base.access() & TurbineFlag.ACC_STRICT);
+    if (!formals.isEmpty()
+        && (getLast(formals).access() & TurbineFlag.ACC_VARARGS) == TurbineFlag.ACC_VARARGS) {
+      access |= TurbineFlag.ACC_VARARGS;
+    }
     return new MethodInfo(
         symbol,
         ImmutableMap.of(),
@@ -307,7 +393,7 @@
     }
     int enclosingInstances = 0;
     for (ClassSymbol sym = base.owner(); sym != null; ) {
-      HeaderBoundClass info = env.get(sym);
+      HeaderBoundClass info = env.getNonNull(sym);
       if (((info.access() & TurbineFlag.ACC_STATIC) == TurbineFlag.ACC_STATIC)
           || info.owner() == null) {
         break;
@@ -338,15 +424,15 @@
             TurbineFlag.ACC_SYNTHETIC));
   }
 
-  private ImmutableList<MethodInfo> syntheticEnumMethods() {
+  private ImmutableList<MethodInfo> syntheticEnumMethods(SyntheticMethods syntheticMethods) {
     ImmutableList.Builder<MethodInfo> methods = ImmutableList.builder();
     int access = 0;
     access |= (base.access() & TurbineFlag.ACC_STRICT);
     if (!hasConstructor()) {
-      MethodSymbol symbol = new MethodSymbol(-1, owner, "<init>");
+      MethodSymbol symbol = syntheticMethods.create(owner, "<init>");
       methods.add(syntheticConstructor(symbol, enumCtorParams(symbol), TurbineVisibility.PRIVATE));
     }
-    MethodSymbol valuesMethod = new MethodSymbol(-2, owner, "values");
+    MethodSymbol valuesMethod = syntheticMethods.create(owner, "values");
     methods.add(
         new MethodInfo(
             valuesMethod,
@@ -359,7 +445,7 @@
             null,
             ImmutableList.of(),
             null));
-    MethodSymbol valueOfMethod = new MethodSymbol(-3, owner, "valueOf");
+    MethodSymbol valueOfMethod = syntheticMethods.create(owner, "valueOf");
     methods.add(
         new MethodInfo(
             valueOfMethod,
@@ -380,6 +466,71 @@
     return methods.build();
   }
 
+  private ImmutableList<MethodInfo> syntheticRecordMethods(
+      SyntheticMethods syntheticMethods, ImmutableList<RecordComponentInfo> components) {
+    ImmutableList.Builder<MethodInfo> methods = ImmutableList.builder();
+    MethodSymbol toStringMethod = syntheticMethods.create(owner, "toString");
+    methods.add(
+        new MethodInfo(
+            toStringMethod,
+            ImmutableMap.of(),
+            Type.ClassTy.STRING,
+            ImmutableList.of(),
+            ImmutableList.of(),
+            TurbineFlag.ACC_PUBLIC | TurbineFlag.ACC_FINAL,
+            null,
+            null,
+            ImmutableList.of(),
+            null));
+    MethodSymbol hashCodeMethod = syntheticMethods.create(owner, "hashCode");
+    methods.add(
+        new MethodInfo(
+            hashCodeMethod,
+            ImmutableMap.of(),
+            Type.PrimTy.create(TurbineConstantTypeKind.INT, ImmutableList.of()),
+            ImmutableList.of(),
+            ImmutableList.of(),
+            TurbineFlag.ACC_PUBLIC | TurbineFlag.ACC_FINAL,
+            null,
+            null,
+            ImmutableList.of(),
+            null));
+    MethodSymbol equalsMethod = syntheticMethods.create(owner, "equals");
+    methods.add(
+        new MethodInfo(
+            equalsMethod,
+            ImmutableMap.of(),
+            Type.PrimTy.create(TurbineConstantTypeKind.BOOLEAN, ImmutableList.of()),
+            ImmutableList.of(
+                new ParamInfo(
+                    new ParamSymbol(equalsMethod, "other"),
+                    Type.ClassTy.OBJECT,
+                    ImmutableList.of(),
+                    TurbineFlag.ACC_MANDATED)),
+            ImmutableList.of(),
+            TurbineFlag.ACC_PUBLIC | TurbineFlag.ACC_FINAL,
+            null,
+            null,
+            ImmutableList.of(),
+            null));
+    for (RecordComponentInfo c : components) {
+      MethodSymbol componentMethod = syntheticMethods.create(owner, c.name());
+      methods.add(
+          new MethodInfo(
+              componentMethod,
+              ImmutableMap.of(),
+              c.type(),
+              ImmutableList.of(),
+              ImmutableList.of(),
+              TurbineFlag.ACC_PUBLIC,
+              null,
+              null,
+              c.annotations(),
+              null));
+    }
+    return methods.build();
+  }
+
   private boolean hasConstructor() {
     for (Tree m : base.decl().members()) {
       if (m.kind() != Kind.METH_DECL) {
@@ -409,21 +560,25 @@
           new TyVarInfo(
               IntersectionTy.create(bounds.build()), /* lowerBound= */ null, annotations));
     }
-    return result.build();
+    return result.buildOrThrow();
   }
 
-  private List<MethodInfo> bindMethods(CompoundScope scope, ImmutableList<Tree> members) {
+  private List<MethodInfo> bindMethods(
+      CompoundScope scope,
+      ImmutableList<Tree> members,
+      ImmutableList<RecordComponentInfo> components) {
     List<MethodInfo> methods = new ArrayList<>();
     int idx = 0;
     for (Tree member : members) {
       if (member.kind() == Tree.Kind.METH_DECL) {
-        methods.add(bindMethod(idx++, scope, (Tree.MethDecl) member));
+        methods.add(bindMethod(idx++, scope, (MethDecl) member, components));
       }
     }
     return methods;
   }
 
-  private MethodInfo bindMethod(int idx, CompoundScope scope, Tree.MethDecl t) {
+  private MethodInfo bindMethod(
+      int idx, CompoundScope scope, MethDecl t, ImmutableList<RecordComponentInfo> components) {
 
     MethodSymbol sym = new MethodSymbol(idx, owner, t.name().value());
 
@@ -433,7 +588,7 @@
       for (Tree.TyParam pt : t.typarams()) {
         builder.put(pt.name().value(), new TyVarSymbol(sym, pt.name().value()));
       }
-      typeParameters = builder.build();
+      typeParameters = builder.buildOrThrow();
     }
 
     // type parameters can refer to each other in f-bounds, so update the scope first
@@ -453,8 +608,26 @@
     if (name.equals("<init>")) {
       if (hasEnclosingInstance(base)) {
         parameters.add(enclosingInstanceParameter(sym));
-      } else if (base.kind() == TurbineTyKind.ENUM && name.equals("<init>")) {
-        parameters.addAll(enumCtorParams(sym));
+      } else {
+        switch (base.kind()) {
+          case ENUM:
+            parameters.addAll(enumCtorParams(sym));
+            break;
+          case RECORD:
+            if (t.mods().contains(TurbineModifier.COMPACT_CTOR)) {
+              for (RecordComponentInfo component : components) {
+                parameters.add(
+                    new ParamInfo(
+                        new ParamSymbol(sym, component.name()),
+                        component.type(),
+                        component.annotations(),
+                        component.access()));
+              }
+            }
+            break;
+          default:
+            break;
+        }
       }
     }
     ParamInfo receiver = null;
@@ -582,8 +755,8 @@
     return result.build();
   }
 
-  private ClassSymbol resolveAnnoSymbol(
-      Anno tree, ImmutableList<Ident> name, LookupResult lookupResult) {
+  private @Nullable ClassSymbol resolveAnnoSymbol(
+      Anno tree, ImmutableList<Ident> name, @Nullable LookupResult lookupResult) {
     if (lookupResult == null) {
       log.error(tree.position(), ErrorKind.CANNOT_RESOLVE, Joiner.on('.').join(name));
       return null;
@@ -595,13 +768,13 @@
         return null;
       }
     }
-    if (env.get(sym).kind() != TurbineTyKind.ANNOTATION) {
+    if (env.getNonNull(sym).kind() != TurbineTyKind.ANNOTATION) {
       log.error(tree.position(), ErrorKind.NOT_AN_ANNOTATION, sym);
     }
     return sym;
   }
 
-  private ClassSymbol resolveNext(ClassSymbol sym, Ident bit) {
+  private @Nullable ClassSymbol resolveNext(ClassSymbol sym, Ident bit) {
     ClassSymbol next = Resolve.resolve(env, owner, sym, bit);
     if (next == null) {
       log.error(
@@ -705,10 +878,11 @@
             sym, bindTyArgs(scope, flat.get(idx++).tyargs()), annotations));
     for (; idx < flat.size(); idx++) {
       Tree.ClassTy curr = flat.get(idx);
-      sym = resolveNext(sym, curr.name());
-      if (sym == null) {
+      ClassSymbol next = resolveNext(sym, curr.name());
+      if (next == null) {
         return Type.ErrorTy.create(bits);
       }
+      sym = next;
 
       annotations = bindAnnotations(scope, curr.annos());
       classes.add(
diff --git a/java/com/google/turbine/binder/bound/AnnotationMetadata.java b/java/com/google/turbine/binder/bound/AnnotationMetadata.java
index a4d3037..5ae04b0 100644
--- a/java/com/google/turbine/binder/bound/AnnotationMetadata.java
+++ b/java/com/google/turbine/binder/bound/AnnotationMetadata.java
@@ -23,6 +23,7 @@
 import com.google.turbine.model.TurbineElementType;
 import java.lang.annotation.RetentionPolicy;
 import java.util.EnumSet;
+import org.jspecify.nullness.Nullable;
 
 /**
  * Annotation metadata, e.g. from {@link java.lang.annotation.Target}, {@link
@@ -41,12 +42,12 @@
 
   private final RetentionPolicy retention;
   private final ImmutableSet<TurbineElementType> target;
-  private final ClassSymbol repeatable;
+  private final @Nullable ClassSymbol repeatable;
 
   public AnnotationMetadata(
-      RetentionPolicy retention,
-      ImmutableSet<TurbineElementType> annotationTarget,
-      ClassSymbol repeatable) {
+      @Nullable RetentionPolicy retention,
+      @Nullable ImmutableSet<TurbineElementType> annotationTarget,
+      @Nullable ClassSymbol repeatable) {
     this.retention = firstNonNull(retention, RetentionPolicy.CLASS);
     this.target = firstNonNull(annotationTarget, DEFAULT_TARGETS);
     this.repeatable = repeatable;
@@ -63,7 +64,7 @@
   }
 
   /** The container annotation for {@code @Repeated} annotations. */
-  public ClassSymbol repeatable() {
+  public @Nullable ClassSymbol repeatable() {
     return repeatable;
   }
 }
diff --git a/java/com/google/turbine/binder/bound/BoundClass.java b/java/com/google/turbine/binder/bound/BoundClass.java
index 61dee0f..1e29b42 100644
--- a/java/com/google/turbine/binder/bound/BoundClass.java
+++ b/java/com/google/turbine/binder/bound/BoundClass.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.model.TurbineTyKind;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * The initial bound tree representation.
diff --git a/java/com/google/turbine/binder/bound/EnumConstantValue.java b/java/com/google/turbine/binder/bound/EnumConstantValue.java
index e99c6ed..20a5756 100644
--- a/java/com/google/turbine/binder/bound/EnumConstantValue.java
+++ b/java/com/google/turbine/binder/bound/EnumConstantValue.java
@@ -18,6 +18,7 @@
 
 import com.google.turbine.binder.sym.FieldSymbol;
 import com.google.turbine.model.Const;
+import org.jspecify.nullness.Nullable;
 
 /** An enum constant. */
 public class EnumConstantValue extends Const {
@@ -43,7 +44,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     return obj instanceof EnumConstantValue && sym().equals(((EnumConstantValue) obj).sym());
   }
 
diff --git a/java/com/google/turbine/binder/bound/HeaderBoundClass.java b/java/com/google/turbine/binder/bound/HeaderBoundClass.java
index 7aeb3d8..9658016 100644
--- a/java/com/google/turbine/binder/bound/HeaderBoundClass.java
+++ b/java/com/google/turbine/binder/bound/HeaderBoundClass.java
@@ -20,10 +20,12 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
+import org.jspecify.nullness.Nullable;
 
 /** A bound node that augments {@link BoundClass} with superclasses and interfaces. */
 public interface HeaderBoundClass extends BoundClass {
   /** The superclass of the declaration. */
+  @Nullable
   ClassSymbol superclass();
 
   /** The interfaces of the declaration. */
diff --git a/java/com/google/turbine/binder/bound/ModuleInfo.java b/java/com/google/turbine/binder/bound/ModuleInfo.java
index f21213b..5dc8720 100644
--- a/java/com/google/turbine/binder/bound/ModuleInfo.java
+++ b/java/com/google/turbine/binder/bound/ModuleInfo.java
@@ -19,13 +19,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.type.AnnoInfo;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A bound module declaration (see JLS §7.7). */
 public class ModuleInfo {
 
   private final String name;
-  @Nullable private final String version;
+  private final @Nullable String version;
   private final int flags;
   private final ImmutableList<AnnoInfo> annos;
   private final ImmutableList<RequireInfo> requires;
@@ -59,8 +59,7 @@
     return name;
   }
 
-  @Nullable
-  public String version() {
+  public @Nullable String version() {
     return version;
   }
 
@@ -97,9 +96,9 @@
 
     private final String moduleName;
     private final int flags;
-    private final String version;
+    private final @Nullable String version;
 
-    public RequireInfo(String moduleName, int flags, String version) {
+    public RequireInfo(String moduleName, int flags, @Nullable String version) {
       this.moduleName = moduleName;
       this.flags = flags;
       this.version = version;
@@ -113,7 +112,7 @@
       return flags;
     }
 
-    public String version() {
+    public @Nullable String version() {
       return version;
     }
   }
diff --git a/java/com/google/turbine/binder/bound/PackageSourceBoundClass.java b/java/com/google/turbine/binder/bound/PackageSourceBoundClass.java
index 2dd2e4e..77832f9 100644
--- a/java/com/google/turbine/binder/bound/PackageSourceBoundClass.java
+++ b/java/com/google/turbine/binder/bound/PackageSourceBoundClass.java
@@ -23,6 +23,7 @@
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.tree.Tree;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link BoundClass} with shared lookup scopes for the current compilation unit and package. */
 public class PackageSourceBoundClass implements BoundClass {
@@ -52,7 +53,7 @@
   }
 
   @Override
-  public ClassSymbol owner() {
+  public @Nullable ClassSymbol owner() {
     return base.owner();
   }
 
diff --git a/java/com/google/turbine/binder/bound/SourceBoundClass.java b/java/com/google/turbine/binder/bound/SourceBoundClass.java
index 9e27ff3..7a6f33f 100644
--- a/java/com/google/turbine/binder/bound/SourceBoundClass.java
+++ b/java/com/google/turbine/binder/bound/SourceBoundClass.java
@@ -20,18 +20,19 @@
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.tree.Tree;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link BoundClass} that corresponds to a source file being compiled. */
 public class SourceBoundClass implements BoundClass {
   private final ClassSymbol sym;
-  private final ClassSymbol owner;
+  private final @Nullable ClassSymbol owner;
   private final ImmutableMap<String, ClassSymbol> children;
   private final int access;
   private final Tree.TyDecl decl;
 
   public SourceBoundClass(
       ClassSymbol sym,
-      ClassSymbol owner,
+      @Nullable ClassSymbol owner,
       ImmutableMap<String, ClassSymbol> children,
       int access,
       Tree.TyDecl decl) {
@@ -52,7 +53,7 @@
   }
 
   @Override
-  public ClassSymbol owner() {
+  public @Nullable ClassSymbol owner() {
     return owner;
   }
 
diff --git a/java/com/google/turbine/binder/bound/SourceHeaderBoundClass.java b/java/com/google/turbine/binder/bound/SourceHeaderBoundClass.java
index c15d0dd..210ff0b 100644
--- a/java/com/google/turbine/binder/bound/SourceHeaderBoundClass.java
+++ b/java/com/google/turbine/binder/bound/SourceHeaderBoundClass.java
@@ -25,18 +25,19 @@
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.tree.Tree;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link HeaderBoundClass} that corresponds to a source file being compiled. */
 public class SourceHeaderBoundClass implements HeaderBoundClass {
 
   private final PackageSourceBoundClass base;
-  private final ClassSymbol superclass;
+  private final @Nullable ClassSymbol superclass;
   private final ImmutableList<ClassSymbol> interfaces;
   private final ImmutableMap<String, TyVarSymbol> typeParameters;
 
   public SourceHeaderBoundClass(
       PackageSourceBoundClass base,
-      ClassSymbol superclass,
+      @Nullable ClassSymbol superclass,
       ImmutableList<ClassSymbol> interfaces,
       ImmutableMap<String, TyVarSymbol> typeParameters) {
     this.base = base;
@@ -46,7 +47,7 @@
   }
 
   @Override
-  public ClassSymbol superclass() {
+  public @Nullable ClassSymbol superclass() {
     return superclass;
   }
 
@@ -66,7 +67,7 @@
   }
 
   @Override
-  public ClassSymbol owner() {
+  public @Nullable ClassSymbol owner() {
     return base.owner();
   }
 
diff --git a/java/com/google/turbine/binder/bound/SourceModuleInfo.java b/java/com/google/turbine/binder/bound/SourceModuleInfo.java
index 1163e9f..66ba0e4 100644
--- a/java/com/google/turbine/binder/bound/SourceModuleInfo.java
+++ b/java/com/google/turbine/binder/bound/SourceModuleInfo.java
@@ -24,7 +24,7 @@
 import com.google.turbine.binder.bound.ModuleInfo.UseInfo;
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.type.AnnoInfo;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link ModuleInfo} that corresponds to a source file being compiled. */
 public class SourceModuleInfo extends ModuleInfo {
diff --git a/java/com/google/turbine/binder/bound/SourceTypeBoundClass.java b/java/com/google/turbine/binder/bound/SourceTypeBoundClass.java
index 69a2593..5e9817e 100644
--- a/java/com/google/turbine/binder/bound/SourceTypeBoundClass.java
+++ b/java/com/google/turbine/binder/bound/SourceTypeBoundClass.java
@@ -29,53 +29,59 @@
 import com.google.turbine.type.Type;
 import com.google.turbine.type.Type.ClassTy;
 import com.google.turbine.type.Type.TyKind;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A HeaderBoundClass for classes compiled from source. */
 public class SourceTypeBoundClass implements TypeBoundClass {
 
   private final TurbineTyKind kind;
-  private final ClassSymbol owner;
+  private final @Nullable ClassSymbol owner;
   private final ImmutableMap<String, ClassSymbol> children;
 
   private final int access;
   private final ImmutableMap<String, TyVarSymbol> typeParameters;
 
   private final ImmutableMap<TyVarSymbol, TyVarInfo> typeParameterTypes;
-  private final Type superClassType;
+  private final @Nullable Type superClassType;
   private final ImmutableList<Type> interfaceTypes;
+  private final ImmutableList<ClassSymbol> permits;
+  private final ImmutableList<RecordComponentInfo> components;
   private final ImmutableList<MethodInfo> methods;
   private final ImmutableList<FieldInfo> fields;
   private final CompoundScope enclosingScope;
   private final CompoundScope scope;
   private final MemberImportIndex memberImports;
-  private final AnnotationMetadata annotationMetadata;
+  private final @Nullable AnnotationMetadata annotationMetadata;
   private final ImmutableList<AnnoInfo> annotations;
   private final Tree.TyDecl decl;
   private final SourceFile source;
 
   public SourceTypeBoundClass(
       ImmutableList<Type> interfaceTypes,
-      Type superClassType,
+      ImmutableList<ClassSymbol> permits,
+      @Nullable Type superClassType,
       ImmutableMap<TyVarSymbol, TyVarInfo> typeParameterTypes,
       int access,
+      ImmutableList<RecordComponentInfo> components,
       ImmutableList<MethodInfo> methods,
       ImmutableList<FieldInfo> fields,
-      ClassSymbol owner,
+      @Nullable ClassSymbol owner,
       TurbineTyKind kind,
       ImmutableMap<String, ClassSymbol> children,
       ImmutableMap<String, TyVarSymbol> typeParameters,
       CompoundScope enclosingScope,
       CompoundScope scope,
       MemberImportIndex memberImports,
-      AnnotationMetadata annotationMetadata,
+      @Nullable AnnotationMetadata annotationMetadata,
       ImmutableList<AnnoInfo> annotations,
       SourceFile source,
       Tree.TyDecl decl) {
     this.interfaceTypes = interfaceTypes;
+    this.permits = permits;
     this.superClassType = superClassType;
     this.typeParameterTypes = typeParameterTypes;
     this.access = access;
+    this.components = components;
     this.methods = methods;
     this.fields = fields;
     this.owner = owner;
@@ -92,7 +98,7 @@
   }
 
   @Override
-  public ClassSymbol superclass() {
+  public @Nullable ClassSymbol superclass() {
     if (superClassType == null) {
       return null;
     }
@@ -114,6 +120,11 @@
   }
 
   @Override
+  public ImmutableList<ClassSymbol> permits() {
+    return permits;
+  }
+
+  @Override
   public int access() {
     return access;
   }
@@ -123,9 +134,8 @@
     return kind;
   }
 
-  @Nullable
   @Override
-  public ClassSymbol owner() {
+  public @Nullable ClassSymbol owner() {
     return owner;
   }
 
@@ -146,10 +156,16 @@
 
   /** The super-class type. */
   @Override
-  public Type superClassType() {
+  public @Nullable Type superClassType() {
     return superClassType;
   }
 
+  /** The record components. */
+  @Override
+  public ImmutableList<RecordComponentInfo> components() {
+    return components;
+  }
+
   /** Declared methods. */
   @Override
   public ImmutableList<MethodInfo> methods() {
@@ -157,7 +173,7 @@
   }
 
   @Override
-  public AnnotationMetadata annotationMetadata() {
+  public @Nullable AnnotationMetadata annotationMetadata() {
     return annotationMetadata;
   }
 
diff --git a/java/com/google/turbine/binder/bound/TurbineAnnotationValue.java b/java/com/google/turbine/binder/bound/TurbineAnnotationValue.java
index 808d603..b6737d6 100644
--- a/java/com/google/turbine/binder/bound/TurbineAnnotationValue.java
+++ b/java/com/google/turbine/binder/bound/TurbineAnnotationValue.java
@@ -20,6 +20,7 @@
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.model.Const;
 import com.google.turbine.type.AnnoInfo;
+import org.jspecify.nullness.Nullable;
 
 /** An annotation literal constant. */
 public class TurbineAnnotationValue extends Const {
@@ -56,7 +57,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     return obj instanceof TurbineAnnotationValue
         && info().equals(((TurbineAnnotationValue) obj).info());
   }
diff --git a/java/com/google/turbine/binder/bound/TurbineClassValue.java b/java/com/google/turbine/binder/bound/TurbineClassValue.java
index df55501..c6ba6ef 100644
--- a/java/com/google/turbine/binder/bound/TurbineClassValue.java
+++ b/java/com/google/turbine/binder/bound/TurbineClassValue.java
@@ -19,6 +19,7 @@
 import com.google.turbine.model.Const;
 import com.google.turbine.type.Type;
 import java.util.Objects;
+import org.jspecify.nullness.Nullable;
 
 /** A class literal constant. */
 public class TurbineClassValue extends Const {
@@ -50,7 +51,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     return obj instanceof TurbineClassValue && type().equals(((TurbineClassValue) obj).type());
   }
 }
diff --git a/java/com/google/turbine/binder/bound/TypeBoundClass.java b/java/com/google/turbine/binder/bound/TypeBoundClass.java
index 99d15bb..8321bde 100644
--- a/java/com/google/turbine/binder/bound/TypeBoundClass.java
+++ b/java/com/google/turbine/binder/bound/TypeBoundClass.java
@@ -16,12 +16,13 @@
 
 package com.google.turbine.binder.bound;
 
-
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.FieldSymbol;
 import com.google.turbine.binder.sym.MethodSymbol;
 import com.google.turbine.binder.sym.ParamSymbol;
+import com.google.turbine.binder.sym.RecordComponentSymbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
 import com.google.turbine.model.Const;
 import com.google.turbine.model.TurbineFlag;
@@ -31,17 +32,21 @@
 import com.google.turbine.type.Type;
 import com.google.turbine.type.Type.IntersectionTy;
 import com.google.turbine.type.Type.MethodTy;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A bound node that augments {@link HeaderBoundClass} with type information. */
 public interface TypeBoundClass extends HeaderBoundClass {
 
   /** The super-class type. */
+  @Nullable
   Type superClassType();
 
   /** Implemented interface types. */
   ImmutableList<Type> interfaceTypes();
 
+  /** The permitted direct subclasses. */
+  ImmutableList<ClassSymbol> permits();
+
   ImmutableMap<TyVarSymbol, TyVarInfo> typeParameterTypes();
 
   /** Declared fields. */
@@ -50,10 +55,14 @@
   /** Declared methods. */
   ImmutableList<MethodInfo> methods();
 
+  /** Record components. */
+  ImmutableList<RecordComponentInfo> components();
+
   /**
    * Annotation metadata, e.g. from {@link java.lang.annotation.Target}, {@link
    * java.lang.annotation.Retention}, and {@link java.lang.annotation.Repeatable}.
    */
+  @Nullable
   AnnotationMetadata annotationMetadata();
 
   /** Declaration annotations. */
@@ -62,7 +71,7 @@
   /** A type parameter declaration. */
   class TyVarInfo {
     private final IntersectionTy upperBound;
-    @Nullable private final Type lowerBound;
+    private final @Nullable Type lowerBound;
     private final ImmutableList<AnnoInfo> annotations;
 
     public TyVarInfo(
@@ -81,8 +90,7 @@
     }
 
     /** The lower bound. */
-    @Nullable
-    public Type lowerBound() {
+    public @Nullable Type lowerBound() {
       return lowerBound;
     }
 
@@ -99,16 +107,16 @@
     private final int access;
     private final ImmutableList<AnnoInfo> annotations;
 
-    private final Tree.VarDecl decl;
-    private final Const.Value value;
+    private final Tree.@Nullable VarDecl decl;
+    private final Const.@Nullable Value value;
 
     public FieldInfo(
         FieldSymbol sym,
         Type type,
         int access,
         ImmutableList<AnnoInfo> annotations,
-        Tree.VarDecl decl,
-        Const.Value value) {
+        Tree.@Nullable VarDecl decl,
+        Const.@Nullable Value value) {
       this.sym = sym;
       this.type = type;
       this.access = access;
@@ -138,12 +146,12 @@
     }
 
     /** The field's declaration. */
-    public Tree.VarDecl decl() {
+    public Tree.@Nullable VarDecl decl() {
       return decl;
     }
 
     /** The constant field value. */
-    public Const.Value value() {
+    public Const.@Nullable Value value() {
       return value;
     }
 
@@ -161,8 +169,8 @@
     private final ImmutableList<ParamInfo> parameters;
     private final ImmutableList<Type> exceptions;
     private final int access;
-    private final Const defaultValue;
-    private final MethDecl decl;
+    private final @Nullable Const defaultValue;
+    private final @Nullable MethDecl decl;
     private final ImmutableList<AnnoInfo> annotations;
     private final @Nullable ParamInfo receiver;
 
@@ -173,8 +181,8 @@
         ImmutableList<ParamInfo> parameters,
         ImmutableList<Type> exceptions,
         int access,
-        Const defaultValue,
-        MethDecl decl,
+        @Nullable Const defaultValue,
+        @Nullable MethDecl decl,
         ImmutableList<AnnoInfo> annotations,
         @Nullable ParamInfo receiver) {
       this.sym = sym;
@@ -225,7 +233,7 @@
     }
 
     /** The default value of an annotation interface method. */
-    public Const defaultValue() {
+    public @Nullable Const defaultValue() {
       return defaultValue;
     }
 
@@ -238,7 +246,7 @@
     }
 
     /** The declaration. */
-    public MethDecl decl() {
+    public @Nullable MethDecl decl() {
       return decl;
     }
 
@@ -319,4 +327,45 @@
       return access;
     }
   }
+
+  /** A record component. */
+  class RecordComponentInfo {
+    private final RecordComponentSymbol sym;
+    private final Type type;
+    private final int access;
+    private final ImmutableList<AnnoInfo> annotations;
+
+    public RecordComponentInfo(
+        RecordComponentSymbol sym, Type type, ImmutableList<AnnoInfo> annotations, int access) {
+      this.sym = sym;
+      this.type = type;
+      this.access = access;
+      this.annotations = annotations;
+    }
+
+    /** The record component's symbol. */
+    public RecordComponentSymbol sym() {
+      return sym;
+    }
+
+    /** The record component type. */
+    public Type type() {
+      return type;
+    }
+
+    /** Record component annotations. */
+    public ImmutableList<AnnoInfo> annotations() {
+      return annotations;
+    }
+
+    /** The Record component's name. */
+    public String name() {
+      return sym.name();
+    }
+
+    /** The Record component's modifiers. */
+    public int access() {
+      return access;
+    }
+  }
 }
diff --git a/java/com/google/turbine/binder/bound/package-info.java b/java/com/google/turbine/binder/bound/package-info.java
new file mode 100644
index 0000000..8839101
--- /dev/null
+++ b/java/com/google/turbine/binder/bound/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.binder.bound;
diff --git a/java/com/google/turbine/binder/bytecode/BytecodeBinder.java b/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
index 0f4bac1..82f8a8c 100644
--- a/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
+++ b/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
@@ -16,6 +16,8 @@
 
 package com.google.turbine.binder.bytecode;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.turbine.binder.bound.EnumConstantValue;
@@ -40,6 +42,7 @@
 import com.google.turbine.bytecode.sig.SigParser;
 import com.google.turbine.model.Const;
 import com.google.turbine.model.Const.ArrayInitValue;
+import com.google.turbine.model.Const.Value;
 import com.google.turbine.type.AnnoInfo;
 import com.google.turbine.type.Type;
 import java.util.ArrayList;
@@ -134,7 +137,7 @@
     for (Map.Entry<String, ElementValue> e : value.elementValuePairs().entrySet()) {
       values.put(e.getKey(), bindValue(e.getValue()));
     }
-    return new TurbineAnnotationValue(new AnnoInfo(null, sym, null, values.build()));
+    return new TurbineAnnotationValue(new AnnoInfo(null, sym, null, values.buildOrThrow()));
   }
 
   static ImmutableList<AnnoInfo> bindAnnotations(List<AnnotationInfo> input) {
@@ -175,19 +178,23 @@
     // TODO(b/32626659): this is not bug-compatible with javac
     switch (((Type.PrimTy) type).primkind()) {
       case CHAR:
-        return new Const.CharValue(value.asChar().value());
+        return new Const.CharValue((char) asInt(value));
       case SHORT:
-        return new Const.ShortValue(value.asShort().value());
+        return new Const.ShortValue((short) asInt(value));
       case BOOLEAN:
         // boolean constants are encoded as integers
-        return new Const.BooleanValue(value.asInteger().value() != 0);
+        return new Const.BooleanValue(asInt(value) != 0);
       case BYTE:
-        return new Const.ByteValue(value.asByte().value());
+        return new Const.ByteValue((byte) asInt(value));
       default:
         return value;
     }
   }
 
+  private static int asInt(Value value) {
+    return ((Const.IntValue) value).value();
+  }
+
   private static Const bindEnumValue(EnumConstValue value) {
     return new EnumConstantValue(
         new FieldSymbol(asClassSymbol(value.typeName()), value.constName()));
@@ -201,6 +208,7 @@
   public static ModuleInfo bindModuleInfo(String path, Supplier<byte[]> bytes) {
     ClassFile classFile = ClassReader.read(path, bytes.get());
     ClassFile.ModuleInfo module = classFile.module();
+    requireNonNull(module, path);
     return new ModuleInfo(
         module.name(),
         module.version(),
diff --git a/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java b/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java
index 82cefc1..cc97dcb 100644
--- a/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java
+++ b/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java
@@ -58,7 +58,7 @@
 import java.lang.annotation.RetentionPolicy;
 import java.util.Map;
 import java.util.function.Function;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A bound class backed by a class file.
@@ -137,9 +137,8 @@
             }
           });
 
-  @Nullable
   @Override
-  public ClassSymbol owner() {
+  public @Nullable ClassSymbol owner() {
     return owner.get();
   }
 
@@ -158,7 +157,7 @@
                   result.put(inner.innerName(), new ClassSymbol(inner.innerClass()));
                 }
               }
-              return result.build();
+              return result.buildOrThrow();
             }
           });
 
@@ -213,7 +212,7 @@
               for (Sig.TyParamSig p : csig.tyParams()) {
                 result.put(p.name(), new TyVarSymbol(sym, p.name()));
               }
-              return result.build();
+              return result.buildOrThrow();
             }
           });
 
@@ -307,6 +306,11 @@
     return interfaceTypes.get();
   }
 
+  @Override
+  public ImmutableList<ClassSymbol> permits() {
+    return ImmutableList.of();
+  }
+
   private final Supplier<ImmutableMap<TyVarSymbol, TyVarInfo>> typeParameterTypes =
       Suppliers.memoize(
           new Supplier<ImmutableMap<TyVarSymbol, TyVarInfo>>() {
@@ -321,7 +325,7 @@
                 // typeParameters() is constructed to guarantee the requireNonNull call is safe.
                 tparams.put(requireNonNull(typeParameters().get(p.name())), bindTyParam(p, scope));
               }
-              return tparams.build();
+              return tparams.buildOrThrow();
             }
           });
 
@@ -402,7 +406,7 @@
       for (Sig.TyParamSig p : sig.tyParams()) {
         result.put(p.name(), new TyVarSymbol(methodSymbol, p.name()));
       }
-      tyParams = result.build();
+      tyParams = result.buildOrThrow();
     }
 
     ImmutableMap<TyVarSymbol, TyVarInfo> tyParamTypes;
@@ -413,15 +417,12 @@
         // tyParams is constructed to guarantee the requireNonNull call is safe.
         tparams.put(requireNonNull(tyParams.get(p.name())), bindTyParam(p, scope));
       }
-      tyParamTypes = tparams.build();
+      tyParamTypes = tparams.buildOrThrow();
     }
 
     Function<String, TyVarSymbol> scope = makeScope(env, sym, tyParams);
 
-    Type ret = null;
-    if (sig.returnType() != null) {
-      ret = BytecodeBinder.bindTy(sig.returnType(), scope);
-    }
+    Type ret = BytecodeBinder.bindTy(sig.returnType(), scope);
 
     ImmutableList.Builder<ParamInfo> formals = ImmutableList.builder();
     int idx = 0;
@@ -490,6 +491,11 @@
     return methods.get();
   }
 
+  @Override
+  public ImmutableList<RecordComponentInfo> components() {
+    return ImmutableList.of();
+  }
+
   private final Supplier<@Nullable AnnotationMetadata> annotationMetadata =
       Suppliers.memoize(
           new Supplier<@Nullable AnnotationMetadata>() {
diff --git a/java/com/google/turbine/binder/bytecode/package-info.java b/java/com/google/turbine/binder/bytecode/package-info.java
index 23c59f0..d2d9708 100644
--- a/java/com/google/turbine/binder/bytecode/package-info.java
+++ b/java/com/google/turbine/binder/bytecode/package-info.java
@@ -14,4 +14,5 @@
  * limitations under the License.
  */
 
+@org.jspecify.nullness.NullMarked
 package com.google.turbine.binder.bytecode;
diff --git a/java/com/google/turbine/binder/env/CompoundEnv.java b/java/com/google/turbine/binder/env/CompoundEnv.java
index 9b216e3..391a2c3 100644
--- a/java/com/google/turbine/binder/env/CompoundEnv.java
+++ b/java/com/google/turbine/binder/env/CompoundEnv.java
@@ -19,12 +19,12 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.turbine.binder.sym.Symbol;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** An {@link Env} that chains two existing envs together. */
 public class CompoundEnv<S extends Symbol, V> implements Env<S, V> {
 
-  private final Env<S, ? extends V> base;
+  private final @Nullable Env<S, ? extends V> base;
   private final Env<S, ? extends V> env;
 
   private CompoundEnv(@Nullable Env<S, ? extends V> base, Env<S, ? extends V> env) {
@@ -33,7 +33,7 @@
   }
 
   @Override
-  public V get(S sym) {
+  public @Nullable V get(S sym) {
     V result = env.get(sym);
     if (result != null) {
       return result;
diff --git a/java/com/google/turbine/binder/env/Env.java b/java/com/google/turbine/binder/env/Env.java
index a78d3e6..463c65d 100644
--- a/java/com/google/turbine/binder/env/Env.java
+++ b/java/com/google/turbine/binder/env/Env.java
@@ -18,6 +18,7 @@
 
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.Symbol;
+import org.jspecify.nullness.Nullable;
 
 /**
  * An environment that maps {@link Symbol}s {@code S} to bound nodes {@code V}.
@@ -34,5 +35,14 @@
  */
 public interface Env<S extends Symbol, V> {
   /** Returns the information associated with the given symbol in this environment. */
+  @Nullable
   V get(S sym);
+
+  default V getNonNull(S sym) {
+    V result = get(sym);
+    if (result == null) {
+      throw new NullPointerException(sym.toString());
+    }
+    return result;
+  }
 }
diff --git a/java/com/google/turbine/binder/env/LazyEnv.java b/java/com/google/turbine/binder/env/LazyEnv.java
index a9c3bd1..0b311f7 100644
--- a/java/com/google/turbine/binder/env/LazyEnv.java
+++ b/java/com/google/turbine/binder/env/LazyEnv.java
@@ -22,6 +22,7 @@
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.Map;
+import org.jspecify.nullness.Nullable;
 
 /**
  * An env that permits an analysis pass to access information about symbols from the current pass,
@@ -48,7 +49,7 @@
   private final ImmutableMap<S, Completer<S, T, V>> completers;
 
   /** Values that have already been computed. */
-  private final Map<S, V> cache = new LinkedHashMap<>();
+  private final Map<S, @Nullable V> cache = new LinkedHashMap<>();
 
   /** An underlying env of already-computed {@code T}s that can be queried during completion. */
   private final Env<S, T> rec;
@@ -59,7 +60,7 @@
   }
 
   @Override
-  public V get(S sym) {
+  public @Nullable V get(S sym) {
     V v = cache.get(sym);
     if (v != null) {
       return v;
@@ -80,6 +81,7 @@
   /** A lazy value provider which is given access to the current environment. */
   public interface Completer<S extends Symbol, T, V extends T> {
     /** Provides the value for the given symbol in the current environment. */
+    @Nullable
     V complete(Env<S, T> env, S k);
   }
 
diff --git a/java/com/google/turbine/binder/env/SimpleEnv.java b/java/com/google/turbine/binder/env/SimpleEnv.java
index b07bf5f..9de5c9f 100644
--- a/java/com/google/turbine/binder/env/SimpleEnv.java
+++ b/java/com/google/turbine/binder/env/SimpleEnv.java
@@ -17,9 +17,11 @@
 package com.google.turbine.binder.env;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.turbine.binder.sym.Symbol;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import org.jspecify.nullness.Nullable;
 
 /** A simple {@link ImmutableMap}-backed {@link Env}. */
 public class SimpleEnv<K extends Symbol, V> implements Env<K, V> {
@@ -42,7 +44,9 @@
   public static class Builder<K extends Symbol, V> {
     private final Map<K, V> map = new LinkedHashMap<>();
 
-    public V put(K sym, V v) {
+    // TODO(cushon): audit the cases where this return value is being ignored
+    @CanIgnoreReturnValue
+    public @Nullable V put(K sym, V v) {
       return map.put(sym, v);
     }
 
@@ -52,7 +56,7 @@
   }
 
   @Override
-  public V get(K sym) {
+  public @Nullable V get(K sym) {
     return map.get(sym);
   }
 }
diff --git a/java/com/google/turbine/binder/env/package-info.java b/java/com/google/turbine/binder/env/package-info.java
new file mode 100644
index 0000000..fa57245
--- /dev/null
+++ b/java/com/google/turbine/binder/env/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.binder.env;
diff --git a/java/com/google/turbine/binder/lookup/CanonicalSymbolResolver.java b/java/com/google/turbine/binder/lookup/CanonicalSymbolResolver.java
index 1e33d5f..d44f4e4 100644
--- a/java/com/google/turbine/binder/lookup/CanonicalSymbolResolver.java
+++ b/java/com/google/turbine/binder/lookup/CanonicalSymbolResolver.java
@@ -18,11 +18,13 @@
 
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.tree.Tree;
+import org.jspecify.nullness.Nullable;
 
 /** Canonical type resolution. Breaks a circular dependency between binding and import handling. */
 public interface CanonicalSymbolResolver extends ImportScope.ResolveFunction {
   /** Resolves a single member type of the given symbol by canonical name. */
   @Override
+  @Nullable
   ClassSymbol resolveOne(ClassSymbol sym, Tree.Ident bit);
 
   /** Returns true if the given symbol is visible from the current package. */
diff --git a/java/com/google/turbine/binder/lookup/CompoundScope.java b/java/com/google/turbine/binder/lookup/CompoundScope.java
index 11309bf..bedf775 100644
--- a/java/com/google/turbine/binder/lookup/CompoundScope.java
+++ b/java/com/google/turbine/binder/lookup/CompoundScope.java
@@ -18,21 +18,21 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link Scope} that chains other scopes together. */
 public class CompoundScope implements Scope {
 
   private final Scope scope;
-  @Nullable private final Scope base;
+  private final @Nullable Scope base;
 
-  private CompoundScope(Scope scope, Scope base) {
+  private CompoundScope(Scope scope, @Nullable Scope base) {
     this.scope = checkNotNull(scope);
     this.base = base;
   }
 
   @Override
-  public LookupResult lookup(LookupKey key) {
+  public @Nullable LookupResult lookup(LookupKey key) {
     LookupResult result = scope.lookup(key);
     if (result != null) {
       return result;
diff --git a/java/com/google/turbine/binder/lookup/CompoundTopLevelIndex.java b/java/com/google/turbine/binder/lookup/CompoundTopLevelIndex.java
index b41edb0..e7fa45f 100644
--- a/java/com/google/turbine/binder/lookup/CompoundTopLevelIndex.java
+++ b/java/com/google/turbine/binder/lookup/CompoundTopLevelIndex.java
@@ -19,7 +19,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.collect.ImmutableList;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link TopLevelIndex} that aggregates multiple indices into one. */
 // Note: this implementation doesn't detect if the indices contain incompatible information,
@@ -42,9 +42,8 @@
 
   private final Scope scope =
       new Scope() {
-        @Nullable
         @Override
-        public LookupResult lookup(LookupKey lookupKey) {
+        public @Nullable LookupResult lookup(LookupKey lookupKey) {
           // Return the first matching symbol.
           for (TopLevelIndex index : indexes) {
             LookupResult result = index.scope().lookup(lookupKey);
@@ -62,14 +61,19 @@
   }
 
   @Override
-  public PackageScope lookupPackage(Iterable<String> packagename) {
+  public @Nullable PackageScope lookupPackage(Iterable<String> packagename) {
     // When returning package scopes, build up a compound scope containing entries from all
     // indices with matching packages.
     PackageScope result = null;
     for (TopLevelIndex index : indexes) {
       PackageScope packageScope = index.lookupPackage(packagename);
-      if (packageScope != null) {
-        result = result == null ? packageScope : result.append(packageScope);
+      if (packageScope == null) {
+        continue;
+      }
+      if (result == null) {
+        result = packageScope;
+      } else {
+        result = result.append(packageScope);
       }
     }
     return result;
diff --git a/java/com/google/turbine/binder/lookup/ImportIndex.java b/java/com/google/turbine/binder/lookup/ImportIndex.java
index fd57223..bcd9366 100644
--- a/java/com/google/turbine/binder/lookup/ImportIndex.java
+++ b/java/com/google/turbine/binder/lookup/ImportIndex.java
@@ -31,6 +31,7 @@
 import com.google.turbine.tree.Tree.ImportDecl;
 import java.util.HashMap;
 import java.util.Map;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A scope that provides entries for the single-type imports in a compilation unit.
@@ -59,7 +60,7 @@
       CanonicalSymbolResolver resolve,
       final TopLevelIndex cpi,
       ImmutableList<ImportDecl> imports) {
-    Map<String, Supplier<ImportScope>> thunks = new HashMap<>();
+    Map<String, Supplier<@Nullable ImportScope>> thunks = new HashMap<>();
     for (final Tree.ImportDecl i : imports) {
       if (i.stat() || i.wild()) {
         continue;
@@ -67,9 +68,9 @@
       thunks.put(
           getLast(i.type()).value(),
           Suppliers.memoize(
-              new Supplier<ImportScope>() {
+              new Supplier<@Nullable ImportScope>() {
                 @Override
-                public ImportScope get() {
+                public @Nullable ImportScope get() {
                   return namedImport(log, cpi, i, resolve);
                 }
               }));
@@ -84,9 +85,9 @@
       thunks.putIfAbsent(
           last,
           Suppliers.memoize(
-              new Supplier<ImportScope>() {
+              new Supplier<@Nullable ImportScope>() {
                 @Override
-                public ImportScope get() {
+                public @Nullable ImportScope get() {
                   return staticNamedImport(log, cpi, i);
                 }
               }));
@@ -95,7 +96,7 @@
   }
 
   /** Fully resolve the canonical name of a non-static named import. */
-  private static ImportScope namedImport(
+  private static @Nullable ImportScope namedImport(
       TurbineLogWithSource log, TopLevelIndex cpi, ImportDecl i, CanonicalSymbolResolver resolve) {
     LookupResult result = cpi.scope().lookup(new LookupKey(i.type()));
     if (result == null) {
@@ -119,7 +120,7 @@
     };
   }
 
-  private static ClassSymbol resolveNext(
+  private static @Nullable ClassSymbol resolveNext(
       TurbineLogWithSource log, CanonicalSymbolResolver resolve, ClassSymbol sym, Ident bit) {
     ClassSymbol next = resolve.resolveOne(sym, bit);
     if (next == null) {
@@ -138,7 +139,7 @@
    * hierarchy analysis is complete, so for now we resolve the base {@code java.util.HashMap} and
    * defer the rest.
    */
-  private static ImportScope staticNamedImport(
+  private static @Nullable ImportScope staticNamedImport(
       TurbineLogWithSource log, TopLevelIndex cpi, ImportDecl i) {
     LookupResult base = cpi.scope().lookup(new LookupKey(i.type()));
     if (base == null) {
@@ -148,7 +149,7 @@
     }
     return new ImportScope() {
       @Override
-      public LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
+      public @Nullable LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
         ClassSymbol sym = (ClassSymbol) base.sym();
         for (Tree.Ident bit : base.remaining()) {
           sym = resolve.resolveOne(sym, bit);
@@ -164,7 +165,7 @@
   }
 
   @Override
-  public LookupResult lookup(LookupKey lookup, ResolveFunction resolve) {
+  public @Nullable LookupResult lookup(LookupKey lookup, ResolveFunction resolve) {
     Supplier<ImportScope> thunk = thunks.get(lookup.first().value());
     if (thunk == null) {
       return null;
diff --git a/java/com/google/turbine/binder/lookup/ImportScope.java b/java/com/google/turbine/binder/lookup/ImportScope.java
index 2e6917e..a33a8e2 100644
--- a/java/com/google/turbine/binder/lookup/ImportScope.java
+++ b/java/com/google/turbine/binder/lookup/ImportScope.java
@@ -18,6 +18,7 @@
 
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.tree.Tree;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A scope for imports. Non-canonical imports depend on hierarchy analysis, so to break the cycle we
@@ -32,17 +33,19 @@
    */
   @FunctionalInterface
   interface ResolveFunction {
+    @Nullable
     ClassSymbol resolveOne(ClassSymbol base, Tree.Ident name);
   }
 
   /** See {@link Scope#lookup(LookupKey)}. */
+  @Nullable
   LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve);
 
   /** Adds a scope to the chain, in the manner of {@link CompoundScope#append(Scope)}. */
   default ImportScope append(ImportScope next) {
     return new ImportScope() {
       @Override
-      public LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
+      public @Nullable LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
         LookupResult result = next.lookup(lookupKey, resolve);
         if (result != null) {
           return result;
@@ -60,7 +63,7 @@
   static ImportScope fromScope(Scope scope) {
     return new ImportScope() {
       @Override
-      public LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
+      public @Nullable LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
         return scope.lookup(lookupKey);
       }
     };
@@ -71,7 +74,7 @@
     return CompoundScope.base(
         new Scope() {
           @Override
-          public LookupResult lookup(LookupKey lookupKey) {
+          public @Nullable LookupResult lookup(LookupKey lookupKey) {
             return ImportScope.this.lookup(lookupKey, resolve);
           }
         });
diff --git a/java/com/google/turbine/binder/lookup/MemberImportIndex.java b/java/com/google/turbine/binder/lookup/MemberImportIndex.java
index a8ecc7a..d825396 100644
--- a/java/com/google/turbine/binder/lookup/MemberImportIndex.java
+++ b/java/com/google/turbine/binder/lookup/MemberImportIndex.java
@@ -30,21 +30,22 @@
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import org.jspecify.nullness.Nullable;
 
 /** An index for statically imported members, in particular constant variables. */
 public class MemberImportIndex {
 
   /** A cache of resolved static imports, keyed by the simple name of the member. */
-  private final Map<String, Supplier<ClassSymbol>> cache = new LinkedHashMap<>();
+  private final Map<String, Supplier<@Nullable ClassSymbol>> cache = new LinkedHashMap<>();
 
-  private final ImmutableList<Supplier<ClassSymbol>> classes;
+  private final ImmutableList<Supplier<@Nullable ClassSymbol>> classes;
 
   public MemberImportIndex(
       SourceFile source,
       CanonicalSymbolResolver resolve,
       TopLevelIndex tli,
       ImmutableList<ImportDecl> imports) {
-    ImmutableList.Builder<Supplier<ClassSymbol>> packageScopes = ImmutableList.builder();
+    ImmutableList.Builder<Supplier<@Nullable ClassSymbol>> packageScopes = ImmutableList.builder();
     for (ImportDecl i : imports) {
       if (!i.stat()) {
         continue;
@@ -52,9 +53,9 @@
       if (i.wild()) {
         packageScopes.add(
             Suppliers.memoize(
-                new Supplier<ClassSymbol>() {
+                new Supplier<@Nullable ClassSymbol>() {
                   @Override
-                  public ClassSymbol get() {
+                  public @Nullable ClassSymbol get() {
                     LookupResult result = tli.scope().lookup(new LookupKey(i.type()));
                     if (result == null) {
                       return null;
@@ -70,15 +71,18 @@
         cache.put(
             getLast(i.type()).value(),
             Suppliers.memoize(
-                new Supplier<ClassSymbol>() {
+                new Supplier<@Nullable ClassSymbol>() {
                   @Override
-                  public ClassSymbol get() {
+                  public @Nullable ClassSymbol get() {
                     LookupResult result = tli.scope().lookup(new LookupKey(i.type()));
                     if (result == null) {
                       return null;
                     }
                     ClassSymbol sym = (ClassSymbol) result.sym();
                     for (int i = 0; i < result.remaining().size() - 1; i++) {
+                      if (sym == null) {
+                        return null;
+                      }
                       sym = resolve.resolveOne(sym, result.remaining().get(i));
                     }
                     return sym;
@@ -107,8 +111,8 @@
   }
 
   /** Resolves the owner of a single-member static import of the given simple name. */
-  public ClassSymbol singleMemberImport(String simpleName) {
-    Supplier<ClassSymbol> cachedResult = cache.get(simpleName);
+  public @Nullable ClassSymbol singleMemberImport(String simpleName) {
+    Supplier<@Nullable ClassSymbol> cachedResult = cache.get(simpleName);
     return cachedResult != null ? cachedResult.get() : null;
   }
 
diff --git a/java/com/google/turbine/binder/lookup/PackageScope.java b/java/com/google/turbine/binder/lookup/PackageScope.java
index 695e802..94e950f 100644
--- a/java/com/google/turbine/binder/lookup/PackageScope.java
+++ b/java/com/google/turbine/binder/lookup/PackageScope.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.turbine.binder.sym.ClassSymbol;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A scope that corresponds to a particular package, which supports iteration over its enclosed
diff --git a/java/com/google/turbine/binder/lookup/Scope.java b/java/com/google/turbine/binder/lookup/Scope.java
index 12466f4..eb9f5cb 100644
--- a/java/com/google/turbine/binder/lookup/Scope.java
+++ b/java/com/google/turbine/binder/lookup/Scope.java
@@ -16,7 +16,7 @@
 
 package com.google.turbine.binder.lookup;
 
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A scope that defines types, and supports qualified name resolution. */
 public interface Scope {
diff --git a/java/com/google/turbine/binder/lookup/SimpleTopLevelIndex.java b/java/com/google/turbine/binder/lookup/SimpleTopLevelIndex.java
index 4ec05bc..179f603 100644
--- a/java/com/google/turbine/binder/lookup/SimpleTopLevelIndex.java
+++ b/java/com/google/turbine/binder/lookup/SimpleTopLevelIndex.java
@@ -23,7 +23,7 @@
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * An index of canonical type names where all members are known statically.
@@ -36,17 +36,17 @@
   /** A class symbol or package. */
   public static class Node {
 
-    public Node lookup(String bit) {
+    public @Nullable Node lookup(String bit) {
       return children.get(bit);
     }
 
-    @Nullable private final ClassSymbol sym;
+    private final @Nullable ClassSymbol sym;
 
     // TODO(cushon): the set of children is typically going to be small, consider optimizing this
     // to use a denser representation where appropriate.
     private final Map<String, Node> children = new HashMap<>();
 
-    Node(ClassSymbol sym) {
+    Node(@Nullable ClassSymbol sym) {
       this.sym = sym;
     }
 
@@ -56,7 +56,7 @@
      *
      * @return {@code null} if an existing symbol with the same name has already been inserted.
      */
-    private Node insert(String name, ClassSymbol sym) {
+    private @Nullable Node insert(String name, @Nullable ClassSymbol sym) {
       Node child = children.get(name);
       if (child != null) {
         if (child.sym != null) {
@@ -83,7 +83,7 @@
     final Node root = new Node(null);
 
     /** Inserts a {@link ClassSymbol} into the index, creating any needed packages. */
-    public boolean insert(ClassSymbol sym) {
+    public void insert(ClassSymbol sym) {
       String binaryName = sym.binaryName();
       int start = 0;
       int end = binaryName.indexOf('/');
@@ -95,7 +95,7 @@
         // symbol), bail out. When inserting elements from the classpath, this results in the
         // expected first-match-wins semantics.
         if (curr == null) {
-          return false;
+          return;
         }
         start = end + 1;
         end = binaryName.indexOf('/', start);
@@ -103,9 +103,9 @@
       String simpleName = binaryName.substring(start);
       curr = curr.insert(simpleName, sym);
       if (curr == null || !Objects.equals(curr.sym, sym)) {
-        return false;
+        return;
       }
-      return true;
+      return;
     }
   }
 
@@ -133,8 +133,7 @@
   final Scope scope =
       new Scope() {
         @Override
-        @Nullable
-        public LookupResult lookup(LookupKey lookupKey) {
+        public @Nullable LookupResult lookup(LookupKey lookupKey) {
           Node curr = root;
           while (true) {
             curr = curr.lookup(lookupKey.first().value());
@@ -159,7 +158,7 @@
 
   /** Returns a {@link Scope} that performs lookups in the given qualified package name. */
   @Override
-  public PackageScope lookupPackage(Iterable<String> packagename) {
+  public @Nullable PackageScope lookupPackage(Iterable<String> packagename) {
     Node curr = root;
     for (String bit : packagename) {
       curr = curr.lookup(bit);
@@ -179,7 +178,7 @@
     }
 
     @Override
-    public LookupResult lookup(LookupKey lookupKey) {
+    public @Nullable LookupResult lookup(LookupKey lookupKey) {
       Node result = node.lookup(lookupKey.first().value());
       if (result != null && result.sym != null) {
         return new LookupResult(result.sym, lookupKey);
diff --git a/java/com/google/turbine/binder/lookup/TopLevelIndex.java b/java/com/google/turbine/binder/lookup/TopLevelIndex.java
index a364119..049ac5c 100644
--- a/java/com/google/turbine/binder/lookup/TopLevelIndex.java
+++ b/java/com/google/turbine/binder/lookup/TopLevelIndex.java
@@ -16,6 +16,7 @@
 
 package com.google.turbine.binder.lookup;
 
+import org.jspecify.nullness.Nullable;
 
 /**
  * An index of canonical type names.
@@ -35,5 +36,6 @@
   Scope scope();
 
   /** Returns a scope to look up members of the given package. */
+  @Nullable
   PackageScope lookupPackage(Iterable<String> packagename);
 }
diff --git a/java/com/google/turbine/binder/lookup/WildImportIndex.java b/java/com/google/turbine/binder/lookup/WildImportIndex.java
index cfe58c7..8b4bab1 100644
--- a/java/com/google/turbine/binder/lookup/WildImportIndex.java
+++ b/java/com/google/turbine/binder/lookup/WildImportIndex.java
@@ -22,6 +22,7 @@
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.tree.Tree;
 import com.google.turbine.tree.Tree.ImportDecl;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A scope that provides best-effort lookup for on-demand imported types in a compilation unit.
@@ -45,14 +46,14 @@
       CanonicalSymbolResolver importResolver,
       final TopLevelIndex cpi,
       ImmutableList<ImportDecl> imports) {
-    ImmutableList.Builder<Supplier<ImportScope>> packageScopes = ImmutableList.builder();
+    ImmutableList.Builder<Supplier<@Nullable ImportScope>> packageScopes = ImmutableList.builder();
     for (final ImportDecl i : imports) {
       if (i.wild()) {
         packageScopes.add(
             Suppliers.memoize(
-                new Supplier<ImportScope>() {
+                new Supplier<@Nullable ImportScope>() {
                   @Override
-                  public ImportScope get() {
+                  public @Nullable ImportScope get() {
                     if (i.stat()) {
                       return staticOnDemandImport(cpi, i, importResolver);
                     } else {
@@ -66,7 +67,7 @@
   }
 
   /** Full resolve the type for a non-static on-demand import. */
-  private static ImportScope onDemandImport(
+  private static @Nullable ImportScope onDemandImport(
       TopLevelIndex cpi, ImportDecl i, final CanonicalSymbolResolver importResolver) {
     ImmutableList.Builder<String> flatNames = ImmutableList.builder();
     for (Tree.Ident ident : i.type()) {
@@ -77,7 +78,7 @@
       // a wildcard import of a package
       return new ImportScope() {
         @Override
-        public LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
+        public @Nullable LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
           return packageIndex.lookup(lookupKey);
         }
       };
@@ -92,7 +93,7 @@
     }
     return new ImportScope() {
       @Override
-      public LookupResult lookup(LookupKey lookupKey, ResolveFunction unused) {
+      public @Nullable LookupResult lookup(LookupKey lookupKey, ResolveFunction unused) {
         return resolveMember(member, importResolver, importResolver, lookupKey);
       }
     };
@@ -103,7 +104,7 @@
    * ImportScope#staticNamedImport} for an explanation of why the possibly non-canonical part is
    * deferred).
    */
-  private static ImportScope staticOnDemandImport(
+  private static @Nullable ImportScope staticOnDemandImport(
       TopLevelIndex cpi, ImportDecl i, final CanonicalSymbolResolver importResolver) {
     LookupResult result = cpi.scope().lookup(new LookupKey(i.type()));
     if (result == null) {
@@ -111,7 +112,7 @@
     }
     return new ImportScope() {
       @Override
-      public LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
+      public @Nullable LookupResult lookup(LookupKey lookupKey, ResolveFunction resolve) {
         ClassSymbol member = resolveImportBase(result, resolve, importResolver);
         if (member == null) {
           return null;
@@ -121,7 +122,7 @@
     };
   }
 
-  private static LookupResult resolveMember(
+  private static @Nullable LookupResult resolveMember(
       ClassSymbol base,
       ResolveFunction resolve,
       CanonicalSymbolResolver importResolver,
@@ -136,7 +137,7 @@
     return new LookupResult(member, lookupKey);
   }
 
-  static ClassSymbol resolveImportBase(
+  static @Nullable ClassSymbol resolveImportBase(
       LookupResult result, ResolveFunction resolve, CanonicalSymbolResolver importResolver) {
     ClassSymbol member = (ClassSymbol) result.sym();
     for (Tree.Ident bit : result.remaining()) {
@@ -152,7 +153,7 @@
   }
 
   @Override
-  public LookupResult lookup(LookupKey lookup, ResolveFunction resolve) {
+  public @Nullable LookupResult lookup(LookupKey lookup, ResolveFunction resolve) {
     for (Supplier<ImportScope> packageScope : packages) {
       ImportScope scope = packageScope.get();
       if (scope == null) {
diff --git a/java/com/google/turbine/binder/lookup/package-info.java b/java/com/google/turbine/binder/lookup/package-info.java
new file mode 100644
index 0000000..7784138
--- /dev/null
+++ b/java/com/google/turbine/binder/lookup/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.binder.lookup;
diff --git a/java/com/google/turbine/binder/package-info.java b/java/com/google/turbine/binder/package-info.java
new file mode 100644
index 0000000..9f550e0
--- /dev/null
+++ b/java/com/google/turbine/binder/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.binder;
diff --git a/java/com/google/turbine/binder/sym/ClassSymbol.java b/java/com/google/turbine/binder/sym/ClassSymbol.java
index 20513e7..9bb556f 100644
--- a/java/com/google/turbine/binder/sym/ClassSymbol.java
+++ b/java/com/google/turbine/binder/sym/ClassSymbol.java
@@ -17,6 +17,7 @@
 package com.google.turbine.binder.sym;
 
 import com.google.errorprone.annotations.Immutable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A class symbol.
@@ -32,6 +33,7 @@
   public static final ClassSymbol OBJECT = new ClassSymbol("java/lang/Object");
   public static final ClassSymbol STRING = new ClassSymbol("java/lang/String");
   public static final ClassSymbol ENUM = new ClassSymbol("java/lang/Enum");
+  public static final ClassSymbol RECORD = new ClassSymbol("java/lang/Record");
   public static final ClassSymbol ANNOTATION = new ClassSymbol("java/lang/annotation/Annotation");
   public static final ClassSymbol INHERITED = new ClassSymbol("java/lang/annotation/Inherited");
   public static final ClassSymbol CLONEABLE = new ClassSymbol("java/lang/Cloneable");
@@ -68,7 +70,7 @@
   }
 
   @Override
-  public boolean equals(Object o) {
+  public boolean equals(@Nullable Object o) {
     return o instanceof ClassSymbol && className.equals(((ClassSymbol) o).className);
   }
 
diff --git a/java/com/google/turbine/binder/sym/FieldSymbol.java b/java/com/google/turbine/binder/sym/FieldSymbol.java
index d6c3cbc..1040500 100644
--- a/java/com/google/turbine/binder/sym/FieldSymbol.java
+++ b/java/com/google/turbine/binder/sym/FieldSymbol.java
@@ -18,6 +18,7 @@
 
 import com.google.errorprone.annotations.Immutable;
 import java.util.Objects;
+import org.jspecify.nullness.Nullable;
 
 /** A field symbol. */
 @Immutable
@@ -51,7 +52,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     if (!(obj instanceof FieldSymbol)) {
       return false;
     }
diff --git a/java/com/google/turbine/binder/sym/MethodSymbol.java b/java/com/google/turbine/binder/sym/MethodSymbol.java
index f4b211d..12c1aa5 100644
--- a/java/com/google/turbine/binder/sym/MethodSymbol.java
+++ b/java/com/google/turbine/binder/sym/MethodSymbol.java
@@ -18,6 +18,7 @@
 
 import com.google.errorprone.annotations.Immutable;
 import java.util.Objects;
+import org.jspecify.nullness.Nullable;
 
 /** A method symbol. */
 @Immutable
@@ -58,7 +59,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     if (!(obj instanceof MethodSymbol)) {
       return false;
     }
diff --git a/java/com/google/turbine/binder/sym/ModuleSymbol.java b/java/com/google/turbine/binder/sym/ModuleSymbol.java
index e442353..4ce5c7a 100644
--- a/java/com/google/turbine/binder/sym/ModuleSymbol.java
+++ b/java/com/google/turbine/binder/sym/ModuleSymbol.java
@@ -17,6 +17,7 @@
 package com.google.turbine.binder.sym;
 
 import com.google.errorprone.annotations.Immutable;
+import org.jspecify.nullness.Nullable;
 
 /** A module symbol. */
 @Immutable
@@ -43,7 +44,7 @@
   }
 
   @Override
-  public boolean equals(Object other) {
+  public boolean equals(@Nullable Object other) {
     return other instanceof ModuleSymbol && name.equals(((ModuleSymbol) other).name);
   }
 
diff --git a/java/com/google/turbine/binder/sym/PackageSymbol.java b/java/com/google/turbine/binder/sym/PackageSymbol.java
index 8354a34..be071e0 100644
--- a/java/com/google/turbine/binder/sym/PackageSymbol.java
+++ b/java/com/google/turbine/binder/sym/PackageSymbol.java
@@ -17,6 +17,7 @@
 package com.google.turbine.binder.sym;
 
 import com.google.errorprone.annotations.Immutable;
+import org.jspecify.nullness.Nullable;
 
 /** A package symbol. */
 @Immutable
@@ -34,7 +35,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     return obj instanceof PackageSymbol && binaryName.equals(((PackageSymbol) obj).binaryName);
   }
 
diff --git a/java/com/google/turbine/binder/sym/ParamSymbol.java b/java/com/google/turbine/binder/sym/ParamSymbol.java
index 328658e..e939223 100644
--- a/java/com/google/turbine/binder/sym/ParamSymbol.java
+++ b/java/com/google/turbine/binder/sym/ParamSymbol.java
@@ -18,6 +18,7 @@
 
 import com.google.errorprone.annotations.Immutable;
 import java.util.Objects;
+import org.jspecify.nullness.Nullable;
 
 /** A parameter symbol. */
 @Immutable
@@ -51,7 +52,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     if (!(obj instanceof ParamSymbol)) {
       return false;
     }
diff --git a/java/com/google/turbine/binder/sym/RecordComponentSymbol.java b/java/com/google/turbine/binder/sym/RecordComponentSymbol.java
new file mode 100644
index 0000000..c3f44f6
--- /dev/null
+++ b/java/com/google/turbine/binder/sym/RecordComponentSymbol.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder.sym;
+
+import com.google.errorprone.annotations.Immutable;
+import java.util.Objects;
+import org.jspecify.nullness.Nullable;
+
+/** A record component symbol. */
+@Immutable
+public class RecordComponentSymbol implements Symbol {
+  private final ClassSymbol owner;
+  private final String name;
+
+  public RecordComponentSymbol(ClassSymbol owner, String name) {
+    this.owner = owner;
+    this.name = name;
+  }
+
+  /** The enclosing class. */
+  public ClassSymbol owner() {
+    return owner;
+  }
+
+  /** The parameter name. */
+  public String name() {
+    return name;
+  }
+
+  @Override
+  public Kind symKind() {
+    return Kind.RECORD_COMPONENT;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, owner);
+  }
+
+  @Override
+  public boolean equals(@Nullable Object obj) {
+    if (!(obj instanceof RecordComponentSymbol)) {
+      return false;
+    }
+    RecordComponentSymbol other = (RecordComponentSymbol) obj;
+    return name().equals(other.name()) && owner().equals(other.owner());
+  }
+
+  @Override
+  public String toString() {
+    return name;
+  }
+}
diff --git a/java/com/google/turbine/binder/sym/Symbol.java b/java/com/google/turbine/binder/sym/Symbol.java
index bc142cb..b1eb8e1 100644
--- a/java/com/google/turbine/binder/sym/Symbol.java
+++ b/java/com/google/turbine/binder/sym/Symbol.java
@@ -28,6 +28,7 @@
     METHOD,
     FIELD,
     PARAMETER,
+    RECORD_COMPONENT,
     MODULE,
     PACKAGE
   }
diff --git a/java/com/google/turbine/binder/sym/TyVarSymbol.java b/java/com/google/turbine/binder/sym/TyVarSymbol.java
index 1ecec11..5ba0788 100644
--- a/java/com/google/turbine/binder/sym/TyVarSymbol.java
+++ b/java/com/google/turbine/binder/sym/TyVarSymbol.java
@@ -18,6 +18,7 @@
 
 import com.google.errorprone.annotations.Immutable;
 import java.util.Objects;
+import org.jspecify.nullness.Nullable;
 
 /** A type variable symbol. */
 @Immutable
@@ -52,7 +53,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     if (!(obj instanceof TyVarSymbol)) {
       return false;
     }
diff --git a/java/com/google/turbine/binder/sym/package-info.java b/java/com/google/turbine/binder/sym/package-info.java
new file mode 100644
index 0000000..96f3a87
--- /dev/null
+++ b/java/com/google/turbine/binder/sym/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.binder.sym;
diff --git a/java/com/google/turbine/bytecode/AnnotationWriter.java b/java/com/google/turbine/bytecode/AnnotationWriter.java
index 34d6262..ccae0f1 100644
--- a/java/com/google/turbine/bytecode/AnnotationWriter.java
+++ b/java/com/google/turbine/bytecode/AnnotationWriter.java
@@ -33,6 +33,7 @@
 import com.google.turbine.bytecode.ClassFile.TypeAnnotationInfo.TypeParameterBoundTarget;
 import com.google.turbine.bytecode.ClassFile.TypeAnnotationInfo.TypeParameterTarget;
 import com.google.turbine.bytecode.ClassFile.TypeAnnotationInfo.TypePath;
+import com.google.turbine.model.Const;
 import com.google.turbine.model.Const.Value;
 import java.util.Map;
 
@@ -79,31 +80,31 @@
   private void writeConstElementValue(Value value) {
     switch (value.constantTypeKind()) {
       case BYTE:
-        writeConst('B', pool.integer(value.asInteger().value()));
+        writeConst('B', pool.integer(((Const.ByteValue) value).value()));
         break;
       case CHAR:
-        writeConst('C', pool.integer(value.asInteger().value()));
+        writeConst('C', pool.integer(((Const.CharValue) value).value()));
         break;
       case SHORT:
-        writeConst('S', pool.integer(value.asInteger().value()));
+        writeConst('S', pool.integer(((Const.ShortValue) value).value()));
         break;
       case DOUBLE:
-        writeConst('D', pool.doubleInfo(value.asDouble().value()));
+        writeConst('D', pool.doubleInfo(((Const.DoubleValue) value).value()));
         break;
       case FLOAT:
-        writeConst('F', pool.floatInfo(value.asFloat().value()));
+        writeConst('F', pool.floatInfo(((Const.FloatValue) value).value()));
         break;
       case INT:
-        writeConst('I', pool.integer(value.asInteger().value()));
+        writeConst('I', pool.integer(((Const.IntValue) value).value()));
         break;
       case LONG:
-        writeConst('J', pool.longInfo(value.asLong().value()));
+        writeConst('J', pool.longInfo(((Const.LongValue) value).value()));
         break;
       case STRING:
-        writeConst('s', pool.utf8(value.asString().value()));
+        writeConst('s', pool.utf8(((Const.StringValue) value).value()));
         break;
       case BOOLEAN:
-        writeConst('Z', pool.integer(value.asBoolean().value() ? 1 : 0));
+        writeConst('Z', pool.integer(((Const.BooleanValue) value).value() ? 1 : 0));
         break;
       default:
         throw new AssertionError(value.constantTypeKind());
diff --git a/java/com/google/turbine/bytecode/Attribute.java b/java/com/google/turbine/bytecode/Attribute.java
index 7b415a7..ad6ffc1 100644
--- a/java/com/google/turbine/bytecode/Attribute.java
+++ b/java/com/google/turbine/bytecode/Attribute.java
@@ -42,7 +42,11 @@
     RUNTIME_INVISIBLE_TYPE_ANNOTATIONS("RuntimeInvisibleTypeAnnotations"),
     METHOD_PARAMETERS("MethodParameters"),
     MODULE("Module"),
-    TURBINE_TRANSITIVE_JAR("TurbineTransitiveJar");
+    NEST_HOST("NestHost"),
+    NEST_MEMBERS("NestMembers"),
+    RECORD("Record"),
+    TURBINE_TRANSITIVE_JAR("TurbineTransitiveJar"),
+    PERMITTED_SUBCLASSES("PermittedSubclasses");
 
     private final String signature;
 
@@ -311,6 +315,102 @@
     }
   }
 
+  /** A JVMS §4.7.28 NestHost attribute. */
+  class NestHost implements Attribute {
+
+    private final String hostClass;
+
+    public NestHost(String hostClass) {
+      this.hostClass = hostClass;
+    }
+
+    String hostClass() {
+      return hostClass;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.NEST_HOST;
+    }
+  }
+
+  /** A JVMS §4.7.29 NestHost attribute. */
+  class NestMembers implements Attribute {
+
+    private final ImmutableList<String> classes;
+
+    public NestMembers(ImmutableList<String> classes) {
+      this.classes = classes;
+    }
+
+    ImmutableList<String> classes() {
+      return classes;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.NEST_MEMBERS;
+    }
+  }
+
+  /** A JVMS §4.7.30 Record attribute. */
+  class Record implements Attribute {
+
+    private final ImmutableList<Component> components;
+
+    public Record(ImmutableList<Component> components) {
+      this.components = components;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.RECORD;
+    }
+
+    ImmutableList<Component> components() {
+      return components;
+    }
+
+    /** A JVMS §4.7.30 Record component info. */
+    static class Component {
+      private final String name;
+      private final String descriptor;
+      private final List<Attribute> attributes;
+
+      Component(String name, String descriptor, List<Attribute> attributes) {
+        this.name = name;
+        this.descriptor = descriptor;
+        this.attributes = attributes;
+      }
+
+      String name() {
+        return name;
+      }
+
+      String descriptor() {
+        return descriptor;
+      }
+
+      List<Attribute> attributes() {
+        return attributes;
+      }
+    }
+  }
+
+  /** A JVMS §4.7.31 PermittedSubclasses attribute. */
+  class PermittedSubclasses implements Attribute {
+    final List<String> permits;
+
+    public PermittedSubclasses(List<String> permits) {
+      this.permits = permits;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.PERMITTED_SUBCLASSES;
+    }
+  }
+
   /** A custom attribute for recording the original jar of repackaged transitive classes. */
   class TurbineTransitiveJar implements Attribute {
 
diff --git a/java/com/google/turbine/bytecode/AttributeWriter.java b/java/com/google/turbine/bytecode/AttributeWriter.java
index 84ca55f..6aac19a 100644
--- a/java/com/google/turbine/bytecode/AttributeWriter.java
+++ b/java/com/google/turbine/bytecode/AttributeWriter.java
@@ -42,59 +42,69 @@
 public class AttributeWriter {
 
   private final ConstantPool pool;
-  private final ByteArrayDataOutput output;
 
-  public AttributeWriter(ConstantPool pool, ByteArrayDataOutput output) {
+  public AttributeWriter(ConstantPool pool) {
     this.pool = pool;
-    this.output = output;
   }
 
   /** Writes a single attribute. */
-  public void write(Attribute attribute) {
+  public void write(ByteArrayDataOutput output, Attribute attribute) {
     switch (attribute.kind()) {
       case SIGNATURE:
-        writeSignatureAttribute((Signature) attribute);
+        writeSignatureAttribute(output, (Signature) attribute);
         break;
       case EXCEPTIONS:
-        writeExceptionsAttribute((ExceptionsAttribute) attribute);
+        writeExceptionsAttribute(output, (ExceptionsAttribute) attribute);
         break;
       case INNER_CLASSES:
-        writeInnerClasses((InnerClasses) attribute);
+        writeInnerClasses(output, (InnerClasses) attribute);
         break;
       case CONSTANT_VALUE:
-        writeConstantValue((ConstantValue) attribute);
+        writeConstantValue(output, (ConstantValue) attribute);
         break;
       case RUNTIME_VISIBLE_ANNOTATIONS:
       case RUNTIME_INVISIBLE_ANNOTATIONS:
-        writeAnnotation((Attribute.Annotations) attribute);
+        writeAnnotation(output, (Attribute.Annotations) attribute);
         break;
       case ANNOTATION_DEFAULT:
-        writeAnnotationDefault((Attribute.AnnotationDefault) attribute);
+        writeAnnotationDefault(output, (Attribute.AnnotationDefault) attribute);
         break;
       case RUNTIME_VISIBLE_PARAMETER_ANNOTATIONS:
       case RUNTIME_INVISIBLE_PARAMETER_ANNOTATIONS:
-        writeParameterAnnotations((Attribute.ParameterAnnotations) attribute);
+        writeParameterAnnotations(output, (Attribute.ParameterAnnotations) attribute);
         break;
       case DEPRECATED:
-        writeDeprecated(attribute);
+        writeDeprecated(output, attribute);
         break;
       case RUNTIME_INVISIBLE_TYPE_ANNOTATIONS:
       case RUNTIME_VISIBLE_TYPE_ANNOTATIONS:
-        writeTypeAnnotation((Attribute.TypeAnnotations) attribute);
+        writeTypeAnnotation(output, (Attribute.TypeAnnotations) attribute);
         break;
       case METHOD_PARAMETERS:
-        writeMethodParameters((Attribute.MethodParameters) attribute);
+        writeMethodParameters(output, (Attribute.MethodParameters) attribute);
         break;
       case MODULE:
-        writeModule((Attribute.Module) attribute);
+        writeModule(output, (Attribute.Module) attribute);
+        break;
+      case NEST_HOST:
+        writeNestHost(output, (Attribute.NestHost) attribute);
+        break;
+      case NEST_MEMBERS:
+        writeNestMembers(output, (Attribute.NestMembers) attribute);
+        break;
+      case RECORD:
+        writeRecord(output, (Attribute.Record) attribute);
+        break;
+      case PERMITTED_SUBCLASSES:
+        writePermittedSubclasses(output, (Attribute.PermittedSubclasses) attribute);
         break;
       case TURBINE_TRANSITIVE_JAR:
-        writeTurbineTransitiveJar((Attribute.TurbineTransitiveJar) attribute);
+        writeTurbineTransitiveJar(output, (Attribute.TurbineTransitiveJar) attribute);
         break;
     }
   }
 
-  private void writeInnerClasses(InnerClasses attribute) {
+  private void writeInnerClasses(ByteArrayDataOutput output, InnerClasses attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     output.writeInt(attribute.inners.size() * 8 + 2);
     output.writeShort(attribute.inners.size());
@@ -106,7 +116,7 @@
     }
   }
 
-  private void writeExceptionsAttribute(ExceptionsAttribute attribute) {
+  private void writeExceptionsAttribute(ByteArrayDataOutput output, ExceptionsAttribute attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     output.writeInt(2 + attribute.exceptions.size() * 2);
     output.writeShort(attribute.exceptions.size());
@@ -115,44 +125,50 @@
     }
   }
 
-  private void writeSignatureAttribute(Signature attribute) {
+  private void writeSignatureAttribute(ByteArrayDataOutput output, Signature attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     output.writeInt(2);
     output.writeShort(pool.utf8(attribute.signature));
   }
 
-  public void writeConstantValue(ConstantValue attribute) {
+  public void writeConstantValue(ByteArrayDataOutput output, ConstantValue attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     output.writeInt(2);
     Const.Value value = attribute.value;
     switch (value.constantTypeKind()) {
       case INT:
+        output.writeShort(pool.integer(((Const.IntValue) value).value()));
+        break;
       case CHAR:
+        output.writeShort(pool.integer(((Const.CharValue) value).value()));
+        break;
       case SHORT:
+        output.writeShort(pool.integer(((Const.ShortValue) value).value()));
+        break;
       case BYTE:
-        output.writeShort(pool.integer(value.asInteger().value()));
+        output.writeShort(pool.integer(((Const.ByteValue) value).value()));
         break;
       case LONG:
-        output.writeShort(pool.longInfo(value.asLong().value()));
+        output.writeShort(pool.longInfo(((Const.LongValue) value).value()));
         break;
       case DOUBLE:
-        output.writeShort(pool.doubleInfo(value.asDouble().value()));
+        output.writeShort(pool.doubleInfo(((Const.DoubleValue) value).value()));
         break;
       case FLOAT:
-        output.writeShort(pool.floatInfo(value.asFloat().value()));
+        output.writeShort(pool.floatInfo(((Const.FloatValue) value).value()));
         break;
       case BOOLEAN:
-        output.writeShort(pool.integer(value.asBoolean().value() ? 1 : 0));
+        output.writeShort(pool.integer(((Const.BooleanValue) value).value() ? 1 : 0));
         break;
       case STRING:
-        output.writeShort(pool.string(value.asString().value()));
+        output.writeShort(pool.string(((Const.StringValue) value).value()));
         break;
       default:
         throw new AssertionError(value.constantTypeKind());
     }
   }
 
-  public void writeAnnotation(Annotations attribute) {
+  public void writeAnnotation(ByteArrayDataOutput output, Annotations attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     ByteArrayDataOutput tmp = ByteStreams.newDataOutput();
     tmp.writeShort(attribute.annotations().size());
@@ -164,7 +180,8 @@
     output.write(data);
   }
 
-  public void writeAnnotationDefault(Attribute.AnnotationDefault attribute) {
+  public void writeAnnotationDefault(
+      ByteArrayDataOutput output, Attribute.AnnotationDefault attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     ByteArrayDataOutput tmp = ByteStreams.newDataOutput();
     new AnnotationWriter(pool, tmp).writeElementValue(attribute.value());
@@ -173,7 +190,8 @@
     output.write(data);
   }
 
-  public void writeParameterAnnotations(Attribute.ParameterAnnotations attribute) {
+  public void writeParameterAnnotations(
+      ByteArrayDataOutput output, Attribute.ParameterAnnotations attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     ByteArrayDataOutput tmp = ByteStreams.newDataOutput();
     tmp.writeByte(attribute.annotations().size());
@@ -188,12 +206,12 @@
     output.write(data);
   }
 
-  private void writeDeprecated(Attribute attribute) {
+  private void writeDeprecated(ByteArrayDataOutput output, Attribute attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     output.writeInt(0);
   }
 
-  private void writeTypeAnnotation(TypeAnnotations attribute) {
+  private void writeTypeAnnotation(ByteArrayDataOutput output, TypeAnnotations attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     ByteArrayDataOutput tmp = ByteStreams.newDataOutput();
     tmp.writeShort(attribute.annotations().size());
@@ -205,7 +223,7 @@
     output.write(data);
   }
 
-  private void writeMethodParameters(MethodParameters attribute) {
+  private void writeMethodParameters(ByteArrayDataOutput output, MethodParameters attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     output.writeInt(attribute.parameters().size() * 4 + 1);
     output.writeByte(attribute.parameters().size());
@@ -215,7 +233,7 @@
     }
   }
 
-  private void writeModule(Attribute.Module attribute) {
+  private void writeModule(ByteArrayDataOutput output, Attribute.Module attribute) {
     ModuleInfo module = attribute.module();
 
     ByteArrayDataOutput tmp = ByteStreams.newDataOutput();
@@ -271,7 +289,50 @@
     output.write(data);
   }
 
-  private void writeTurbineTransitiveJar(TurbineTransitiveJar attribute) {
+  private void writeNestHost(ByteArrayDataOutput output, Attribute.NestHost attribute) {
+    output.writeShort(pool.utf8(attribute.kind().signature()));
+    output.writeInt(2);
+    output.writeShort(pool.classInfo(attribute.hostClass()));
+  }
+
+  private void writeNestMembers(ByteArrayDataOutput output, Attribute.NestMembers attribute) {
+    output.writeShort(pool.utf8(attribute.kind().signature()));
+    output.writeInt(2 + attribute.classes().size() * 2);
+    output.writeShort(attribute.classes().size());
+    for (String classes : attribute.classes()) {
+      output.writeShort(pool.classInfo(classes));
+    }
+  }
+
+  private void writeRecord(ByteArrayDataOutput output, Attribute.Record attribute) {
+    output.writeShort(pool.utf8(attribute.kind().signature()));
+    ByteArrayDataOutput tmp = ByteStreams.newDataOutput();
+    tmp.writeShort(attribute.components().size());
+    for (Attribute.Record.Component c : attribute.components()) {
+      tmp.writeShort(pool.utf8(c.name()));
+      tmp.writeShort(pool.utf8(c.descriptor()));
+      tmp.writeShort(c.attributes().size());
+      for (Attribute a : c.attributes()) {
+        write(tmp, a);
+      }
+    }
+    byte[] data = tmp.toByteArray();
+    output.writeInt(data.length);
+    output.write(data);
+  }
+
+  private void writePermittedSubclasses(
+      ByteArrayDataOutput output, Attribute.PermittedSubclasses attribute) {
+    output.writeShort(pool.utf8(attribute.kind().signature()));
+    output.writeInt(2 + attribute.permits.size() * 2);
+    output.writeShort(attribute.permits.size());
+    for (String permits : attribute.permits) {
+      output.writeShort(pool.classInfo(permits));
+    }
+  }
+
+  private void writeTurbineTransitiveJar(
+      ByteArrayDataOutput output, TurbineTransitiveJar attribute) {
     output.writeShort(pool.utf8(attribute.kind().signature()));
     output.writeInt(2);
     output.writeShort(pool.utf8(attribute.transitiveJar));
diff --git a/java/com/google/turbine/bytecode/ByteReader.java b/java/com/google/turbine/bytecode/ByteReader.java
index 5458b49..a9deff2 100644
--- a/java/com/google/turbine/bytecode/ByteReader.java
+++ b/java/com/google/turbine/bytecode/ByteReader.java
@@ -20,9 +20,11 @@
 
 import com.google.common.io.ByteArrayDataInput;
 import com.google.common.io.ByteStreams;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.io.ByteArrayInputStream;
 
 /** A {@link ByteArrayDataInput} wrapper that tracks the current byte array index. */
+@CanIgnoreReturnValue
 public class ByteReader {
 
   private final byte[] bytes;
diff --git a/java/com/google/turbine/bytecode/ClassFile.java b/java/com/google/turbine/bytecode/ClassFile.java
index e979edc..820f17d 100644
--- a/java/com/google/turbine/bytecode/ClassFile.java
+++ b/java/com/google/turbine/bytecode/ClassFile.java
@@ -26,48 +26,63 @@
 import java.util.Deque;
 import java.util.List;
 import java.util.Map;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A JVMS §4.1 ClassFile. */
 public class ClassFile {
 
   private final int access;
+  private final int majorVersion;
   private final String name;
-  private final String signature;
-  private final String superClass;
+  private final @Nullable String signature;
+  private final @Nullable String superClass;
   private final List<String> interfaces;
+  private final List<String> permits;
   private final List<MethodInfo> methods;
   private final List<FieldInfo> fields;
   private final List<AnnotationInfo> annotations;
   private final List<InnerClass> innerClasses;
   private final ImmutableList<TypeAnnotationInfo> typeAnnotations;
-  @Nullable private final ModuleInfo module;
-  @Nullable private final String transitiveJar;
+  private final @Nullable ModuleInfo module;
+  private final @Nullable String nestHost;
+  private final ImmutableList<String> nestMembers;
+  private final @Nullable RecordInfo record;
+  private final @Nullable String transitiveJar;
 
   public ClassFile(
       int access,
+      int majorVersion,
       String name,
-      String signature,
-      String superClass,
+      @Nullable String signature,
+      @Nullable String superClass,
       List<String> interfaces,
+      List<String> permits,
       List<MethodInfo> methods,
       List<FieldInfo> fields,
       List<AnnotationInfo> annotations,
       List<InnerClass> innerClasses,
       ImmutableList<TypeAnnotationInfo> typeAnnotations,
       @Nullable ModuleInfo module,
+      @Nullable String nestHost,
+      ImmutableList<String> nestMembers,
+      @Nullable RecordInfo record,
       @Nullable String transitiveJar) {
     this.access = access;
+    this.majorVersion = majorVersion;
     this.name = name;
     this.signature = signature;
     this.superClass = superClass;
     this.interfaces = interfaces;
+    this.permits = permits;
     this.methods = methods;
     this.fields = fields;
     this.annotations = annotations;
     this.innerClasses = innerClasses;
     this.typeAnnotations = typeAnnotations;
     this.module = module;
+    this.nestHost = nestHost;
+    this.nestMembers = nestMembers;
+    this.record = record;
     this.transitiveJar = transitiveJar;
   }
 
@@ -76,18 +91,23 @@
     return access;
   }
 
+  /** Class file major version. */
+  public int majorVersion() {
+    return majorVersion;
+  }
+
   /** The name of the class or interface. */
   public String name() {
     return name;
   }
 
   /** The value of the Signature attribute. */
-  public String signature() {
+  public @Nullable String signature() {
     return signature;
   }
 
   /** The super class. */
-  public String superName() {
+  public @Nullable String superName() {
     return superClass;
   }
 
@@ -96,6 +116,11 @@
     return interfaces;
   }
 
+  /** The permitted direct subclasses. */
+  public List<String> permits() {
+    return permits;
+  }
+
   /** Methods declared by this class or interfaces type. */
   public List<MethodInfo> methods() {
     return methods;
@@ -122,14 +147,24 @@
   }
 
   /** A module attribute. */
-  @Nullable
-  public ModuleInfo module() {
+  public @Nullable ModuleInfo module() {
     return module;
   }
 
+  public @Nullable String nestHost() {
+    return nestHost;
+  }
+
+  public ImmutableList<String> nestMembers() {
+    return nestMembers;
+  }
+
+  public @Nullable RecordInfo record() {
+    return record;
+  }
+
   /** The original jar of a repackaged transitive class. */
-  @Nullable
-  public String transitiveJar() {
+  public @Nullable String transitiveJar() {
     return transitiveJar;
   }
 
@@ -139,8 +174,8 @@
     private final int access;
     private final String name;
     private final String descriptor;
-    @Nullable private final String signature;
-    private final Const.@Nullable Value value;
+    private final @Nullable String signature;
+    private final @Nullable Value value;
     private final List<AnnotationInfo> annotations;
     private final ImmutableList<TypeAnnotationInfo> typeAnnotations;
 
@@ -149,7 +184,7 @@
         String name,
         String descriptor,
         @Nullable String signature,
-        Value value,
+        @Nullable Value value,
         List<AnnotationInfo> annotations,
         ImmutableList<TypeAnnotationInfo> typeAnnotations) {
       this.access = access;
@@ -177,8 +212,7 @@
     }
 
     /** The value of Signature attribute. */
-    @Nullable
-    public String signature() {
+    public @Nullable String signature() {
       return signature;
     }
 
@@ -240,7 +274,7 @@
     private final int access;
     private final String name;
     private final String descriptor;
-    @Nullable private final String signature;
+    private final @Nullable String signature;
     private final List<String> exceptions;
     private final AnnotationInfo.@Nullable ElementValue defaultValue;
     private final List<AnnotationInfo> annotations;
@@ -287,8 +321,7 @@
     }
 
     /** The value of Signature attribute. */
-    @Nullable
-    public String signature() {
+    public @Nullable String signature() {
       return signature;
     }
 
@@ -730,16 +763,16 @@
         }
       }
 
-      private final TypePath parent;
-      private final TypePath.Kind kind;
+      private final @Nullable TypePath parent;
+      private final TypePath.@Nullable Kind kind;
       private final int index;
 
-      private TypePath(TypePath.Kind kind, TypePath parent) {
+      private TypePath(TypePath.@Nullable Kind kind, @Nullable TypePath parent) {
         // JVMS 4.7.20.2: type_argument_index is 0 if the bound kind is not TYPE_ARGUMENT
         this(0, kind, parent);
       }
 
-      private TypePath(int index, TypePath.Kind kind, TypePath parent) {
+      private TypePath(int index, TypePath.@Nullable Kind kind, @Nullable TypePath parent) {
         this.index = index;
         this.kind = kind;
         this.parent = parent;
@@ -752,13 +785,13 @@
 
       /** The JVMS 4.7.20.2-A serialized value of the type_path_kind. */
       public byte tag() {
-        return (byte) kind.tag;
+        return (byte) requireNonNull(kind).tag;
       }
 
       /** Returns a flattened view of the type path. */
       public ImmutableList<TypePath> flatten() {
         Deque<TypePath> flat = new ArrayDeque<>();
-        for (TypePath curr = this; curr.kind != null; curr = curr.parent) {
+        for (TypePath curr = this; requireNonNull(curr).kind != null; curr = curr.parent) {
           flat.addFirst(curr);
         }
         return ImmutableList.copyOf(flat);
@@ -770,7 +803,7 @@
   public static class ModuleInfo {
 
     private final String name;
-    private final String version;
+    private final @Nullable String version;
     private final int flags;
     private final ImmutableList<RequireInfo> requires;
     private final ImmutableList<ExportInfo> exports;
@@ -781,7 +814,7 @@
     public ModuleInfo(
         String name,
         int flags,
-        String version,
+        @Nullable String version,
         ImmutableList<RequireInfo> requires,
         ImmutableList<ExportInfo> exports,
         ImmutableList<OpenInfo> opens,
@@ -805,7 +838,7 @@
       return flags;
     }
 
-    public String version() {
+    public @Nullable String version() {
       return version;
     }
 
@@ -834,9 +867,9 @@
 
       private final String moduleName;
       private final int flags;
-      private final String version;
+      private final @Nullable String version;
 
-      public RequireInfo(String moduleName, int flags, String version) {
+      public RequireInfo(String moduleName, int flags, @Nullable String version) {
         this.moduleName = moduleName;
         this.flags = flags;
         this.version = version;
@@ -850,7 +883,7 @@
         return flags;
       }
 
-      public String version() {
+      public @Nullable String version() {
         return version;
       }
     }
@@ -941,4 +974,61 @@
       }
     }
   }
+
+  /** A JVMS §4.7.30 Record attribute. */
+  public static class RecordInfo {
+
+    /** A JVMS §4.7.30 Record component attribute. */
+    public static class RecordComponentInfo {
+
+      private final String name;
+      private final String descriptor;
+      private final @Nullable String signature;
+      private final ImmutableList<AnnotationInfo> annotations;
+      private final ImmutableList<TypeAnnotationInfo> typeAnnotations;
+
+      public RecordComponentInfo(
+          String name,
+          String descriptor,
+          @Nullable String signature,
+          ImmutableList<AnnotationInfo> annotations,
+          ImmutableList<TypeAnnotationInfo> typeAnnotations) {
+        this.name = name;
+        this.descriptor = descriptor;
+        this.signature = signature;
+        this.annotations = annotations;
+        this.typeAnnotations = typeAnnotations;
+      }
+
+      public String name() {
+        return name;
+      }
+
+      public String descriptor() {
+        return descriptor;
+      }
+
+      public @Nullable String signature() {
+        return signature;
+      }
+
+      public ImmutableList<AnnotationInfo> annotations() {
+        return annotations;
+      }
+
+      public ImmutableList<TypeAnnotationInfo> typeAnnotations() {
+        return typeAnnotations;
+      }
+    }
+
+    public RecordInfo(ImmutableList<RecordComponentInfo> recordComponents) {
+      this.recordComponents = recordComponents;
+    }
+
+    private final ImmutableList<RecordComponentInfo> recordComponents;
+
+    public ImmutableList<RecordComponentInfo> recordComponents() {
+      return recordComponents;
+    }
+  }
 }
diff --git a/java/com/google/turbine/bytecode/ClassReader.java b/java/com/google/turbine/bytecode/ClassReader.java
index ac8b1e1..740026a 100644
--- a/java/com/google/turbine/bytecode/ClassReader.java
+++ b/java/com/google/turbine/bytecode/ClassReader.java
@@ -16,6 +16,8 @@
 
 package com.google.turbine.bytecode;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.CheckReturnValue;
@@ -37,7 +39,7 @@
 import com.google.turbine.model.TurbineFlag;
 import java.util.ArrayList;
 import java.util.List;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A JVMS §4 class file reader. */
 public class ClassReader {
@@ -53,7 +55,7 @@
     return new ClassReader(path, bytes).read();
   }
 
-  @Nullable private final String path;
+  private final @Nullable String path;
   private final ByteReader reader;
 
   private ClassReader(@Nullable String path, byte[] bytes) {
@@ -136,16 +138,21 @@
 
     return new ClassFile(
         accessFlags,
+        majorVersion,
         thisClass,
         signature,
         superClass,
         interfaces,
+        /* permits= */ ImmutableList.of(),
         methodinfos,
         fieldinfos,
         annotations.build(),
         innerclasses,
         ImmutableList.of(),
         module,
+        /* nestHost= */ null,
+        /* nestMembers= */ ImmutableList.of(),
+        /* record= */ null,
         transitiveJar);
   }
 
@@ -173,6 +180,7 @@
       String innerName = innerNameIndex != 0 ? constantPool.utf8(innerNameIndex) : null;
       int innerClassAccessFlags = reader.u2();
       if (innerName != null && (thisClass.equals(innerClass) || thisClass.equals(outerClass))) {
+        requireNonNull(outerClass);
         innerclasses.add(
             new ClassFile.InnerClass(innerClass, outerClass, innerName, innerClassAccessFlags));
       }
@@ -327,18 +335,18 @@
         // The runtimeVisible bit in AnnotationInfo is only used during lowering; earlier passes
         // read the meta-annotations.
         /* runtimeVisible= */ false,
-        values.build());
+        values.buildOrThrow());
   }
 
   private ElementValue readElementValue(ConstantPoolReader constantPool) {
     int tag = reader.u1();
     switch (tag) {
       case 'B':
-        return new ConstValue(readConst(constantPool).asByte());
+        return new ConstValue(new Const.ByteValue((byte) readInt(constantPool)));
       case 'C':
-        return new ConstValue(readConst(constantPool).asChar());
+        return new ConstValue(new Const.CharValue((char) readInt(constantPool)));
       case 'S':
-        return new ConstValue(readConst(constantPool).asShort());
+        return new ConstValue(new Const.ShortValue((short) readInt(constantPool)));
       case 'D':
       case 'F':
       case 'I':
@@ -346,11 +354,8 @@
       case 's':
         return new ConstValue(readConst(constantPool));
       case 'Z':
-        {
-          Const.Value value = readConst(constantPool);
-          // boolean constants are encoded as integers
-          return new ConstValue(new Const.BooleanValue(value.asInteger().value() != 0));
-        }
+        // boolean constants are encoded as integers
+        return new ConstValue(new Const.BooleanValue(readInt(constantPool) != 0));
       case 'e':
         {
           int typeNameIndex = reader.u2();
@@ -381,6 +386,10 @@
     throw new AssertionError(String.format("bad tag value %c", tag));
   }
 
+  private int readInt(ConstantPoolReader constantPool) {
+    return ((Const.IntValue) readConst(constantPool)).value();
+  }
+
   private Const.Value readConst(ConstantPoolReader constantPool) {
     int constValueIndex = reader.u2();
     return constantPool.constant(constValueIndex);
diff --git a/java/com/google/turbine/bytecode/ClassWriter.java b/java/com/google/turbine/bytecode/ClassWriter.java
index de975f2..da4afc7 100644
--- a/java/com/google/turbine/bytecode/ClassWriter.java
+++ b/java/com/google/turbine/bytecode/ClassWriter.java
@@ -31,10 +31,6 @@
 
   private static final int MAGIC = 0xcafebabe;
   private static final int MINOR_VERSION = 0;
-  // use the lowest classfile version possible given the class file features
-  // TODO(cushon): is there a reason to support --release?
-  private static final int MAJOR_VERSION = 52;
-  private static final int MODULE_MAJOR_VERSION = 53;
 
   /** Writes a {@link ClassFile} to bytecode. */
   public static byte[] writeClass(ClassFile classfile) {
@@ -79,7 +75,7 @@
       ConstantPool pool, ByteArrayDataOutput body, List<Attribute> attributes) {
     body.writeShort(attributes.size());
     for (Attribute attribute : attributes) {
-      new AttributeWriter(pool, body).write(attribute);
+      new AttributeWriter(pool).write(body, attribute);
     }
   }
 
@@ -119,7 +115,7 @@
     ByteArrayDataOutput result = ByteStreams.newDataOutput();
     result.writeInt(MAGIC);
     result.writeShort(MINOR_VERSION);
-    result.writeShort(classfile.module() != null ? MODULE_MAJOR_VERSION : MAJOR_VERSION);
+    result.writeShort(classfile.majorVersion());
     writeConstantPool(pool, result);
     result.write(body.toByteArray());
     return result.toByteArray();
diff --git a/java/com/google/turbine/bytecode/ConstantPoolReader.java b/java/com/google/turbine/bytecode/ConstantPoolReader.java
index ffcb4c3..d00ee22 100644
--- a/java/com/google/turbine/bytecode/ConstantPoolReader.java
+++ b/java/com/google/turbine/bytecode/ConstantPoolReader.java
@@ -36,6 +36,7 @@
   static final int CONSTANT_UTF8 = 1;
   static final int CONSTANT_METHOD_HANDLE = 15;
   static final int CONSTANT_METHOD_TYPE = 16;
+  static final int CONSTANT_DYNAMIC = 17;
   static final int CONSTANT_INVOKE_DYNAMIC = 18;
   static final int CONSTANT_MODULE = 19;
   static final int CONSTANT_PACKAGE = 20;
@@ -88,6 +89,7 @@
       case CONSTANT_INTEGER:
       case CONSTANT_FLOAT:
       case CONSTANT_NAME_AND_TYPE:
+      case CONSTANT_DYNAMIC:
       case CONSTANT_INVOKE_DYNAMIC:
         reader.skip(4);
         return 1;
diff --git a/java/com/google/turbine/bytecode/LowerAttributes.java b/java/com/google/turbine/bytecode/LowerAttributes.java
index 5ae42af..8952dff 100644
--- a/java/com/google/turbine/bytecode/LowerAttributes.java
+++ b/java/com/google/turbine/bytecode/LowerAttributes.java
@@ -45,12 +45,39 @@
     if (classfile.module() != null) {
       attributes.add(new Attribute.Module(classfile.module()));
     }
+    if (classfile.nestHost() != null) {
+      attributes.add(new Attribute.NestHost(classfile.nestHost()));
+    }
+    if (!classfile.nestMembers().isEmpty()) {
+      attributes.add(new Attribute.NestMembers(classfile.nestMembers()));
+    }
+    if (classfile.record() != null) {
+      attributes.add(recordAttribute(classfile.record()));
+    }
+    if (!classfile.permits().isEmpty()) {
+      attributes.add(new Attribute.PermittedSubclasses(classfile.permits()));
+    }
     if (classfile.transitiveJar() != null) {
       attributes.add(new Attribute.TurbineTransitiveJar(classfile.transitiveJar()));
     }
     return attributes;
   }
 
+  private static Attribute recordAttribute(ClassFile.RecordInfo record) {
+    ImmutableList.Builder<Attribute.Record.Component> components = ImmutableList.builder();
+    for (ClassFile.RecordInfo.RecordComponentInfo component : record.recordComponents()) {
+      List<Attribute> attributes = new ArrayList<>();
+      if (component.signature() != null) {
+        attributes.add(new Attribute.Signature(component.signature()));
+      }
+      addAllAnnotations(attributes, component.annotations());
+      addAllTypeAnnotations(attributes, component.typeAnnotations());
+      components.add(
+          new Attribute.Record.Component(component.name(), component.descriptor(), attributes));
+    }
+    return new Attribute.Record(components.build());
+  }
+
   /** Collects the {@link Attribute}s for a {@link MethodInfo}. */
   static List<Attribute> methodAttributes(ClassFile.MethodInfo method) {
     List<Attribute> attributes = new ArrayList<>();
diff --git a/java/com/google/turbine/bytecode/package-info.java b/java/com/google/turbine/bytecode/package-info.java
new file mode 100644
index 0000000..3f0bb60
--- /dev/null
+++ b/java/com/google/turbine/bytecode/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.bytecode;
diff --git a/java/com/google/turbine/bytecode/sig/Sig.java b/java/com/google/turbine/bytecode/sig/Sig.java
index f759269..99d98cf 100644
--- a/java/com/google/turbine/bytecode/sig/Sig.java
+++ b/java/com/google/turbine/bytecode/sig/Sig.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.turbine.model.TurbineConstantTypeKind;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** JVMS 4.7.9.1 signatures. */
 public final class Sig {
@@ -59,18 +59,18 @@
   public static class TyParamSig {
 
     private final String name;
-    @Nullable private final TySig classBound;
+    private final @Nullable TySig classBound;
     private final ImmutableList<TySig> interfaceBounds;
 
-    public TyParamSig(String name, TySig classBound, ImmutableList<TySig> interfaceBounds) {
+    public TyParamSig(
+        String name, @Nullable TySig classBound, ImmutableList<TySig> interfaceBounds) {
       this.name = name;
       this.classBound = classBound;
       this.interfaceBounds = interfaceBounds;
     }
 
     /** A single class upper-bound, or {@code null}. */
-    @Nullable
-    public TySig classBound() {
+    public @Nullable TySig classBound() {
       return classBound;
     }
 
diff --git a/java/com/google/turbine/bytecode/sig/SigParser.java b/java/com/google/turbine/bytecode/sig/SigParser.java
index 033fa18..1bfd762 100644
--- a/java/com/google/turbine/bytecode/sig/SigParser.java
+++ b/java/com/google/turbine/bytecode/sig/SigParser.java
@@ -17,6 +17,7 @@
 package com.google.turbine.bytecode.sig;
 
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.turbine.bytecode.sig.Sig.ArrayTySig;
 import com.google.turbine.bytecode.sig.Sig.BaseTySig;
 import com.google.turbine.bytecode.sig.Sig.ClassSig;
@@ -46,6 +47,7 @@
   }
 
   /** Returns the next character and advances. */
+  @CanIgnoreReturnValue
   char eat() {
     return sig.charAt(idx++);
   }
diff --git a/java/com/google/turbine/bytecode/sig/package-info.java b/java/com/google/turbine/bytecode/sig/package-info.java
new file mode 100644
index 0000000..c5f449e
--- /dev/null
+++ b/java/com/google/turbine/bytecode/sig/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.bytecode.sig;
diff --git a/java/com/google/turbine/deps/Dependencies.java b/java/com/google/turbine/deps/Dependencies.java
index ef1eea9..3dd008c 100644
--- a/java/com/google/turbine/deps/Dependencies.java
+++ b/java/com/google/turbine/deps/Dependencies.java
@@ -92,7 +92,7 @@
             .append(bound.classPathEnv());
     Set<ClassSymbol> closure = new LinkedHashSet<>(lowered.symbols());
     for (ClassSymbol sym : lowered.symbols()) {
-      TypeBoundClass info = env.get(sym);
+      TypeBoundClass info = env.getNonNull(sym);
       addAnnotations(closure, info.annotations());
       for (MethodInfo method : info.methods()) {
         addAnnotations(closure, method.annotations());
diff --git a/java/com/google/turbine/deps/Transitive.java b/java/com/google/turbine/deps/Transitive.java
index 75d23f6..2b8bd58 100644
--- a/java/com/google/turbine/deps/Transitive.java
+++ b/java/com/google/turbine/deps/Transitive.java
@@ -33,7 +33,7 @@
 import com.google.turbine.model.TurbineFlag;
 import java.util.LinkedHashSet;
 import java.util.Set;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * Collects the minimal compile-time API for symbols in the supertype closure of compiled classes.
@@ -58,7 +58,7 @@
       transitive.put(
           sym.binaryName(), ClassWriter.writeClass(trimClass(info.classFile(), info.jarFile())));
     }
-    return transitive.build();
+    return transitive.buildOrThrow();
   }
 
   /**
@@ -90,10 +90,12 @@
     }
     return new ClassFile(
         cf.access(),
+        cf.majorVersion(),
         cf.name(),
         cf.signature(),
         cf.superName(),
         cf.interfaces(),
+        cf.permits(),
         // drop methods, except for annotations where we need to resolve key/value information
         (cf.access() & TurbineFlag.ACC_ANNOTATION) == TurbineFlag.ACC_ANNOTATION
             ? cf.methods()
@@ -105,6 +107,9 @@
         innerClasses.build(),
         cf.typeAnnotations(),
         /* module= */ null,
+        /* nestHost= */ null,
+        /* nestMembers= */ ImmutableList.of(),
+        /* record= */ null,
         /* transitiveJar = */ transitiveJar);
   }
 
diff --git a/java/com/google/turbine/deps/package-info.java b/java/com/google/turbine/deps/package-info.java
new file mode 100644
index 0000000..28df5a9
--- /dev/null
+++ b/java/com/google/turbine/deps/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.deps;
diff --git a/java/com/google/turbine/diag/SourceFile.java b/java/com/google/turbine/diag/SourceFile.java
index 3868252..a7c3245 100644
--- a/java/com/google/turbine/diag/SourceFile.java
+++ b/java/com/google/turbine/diag/SourceFile.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import java.util.Objects;
+import org.jspecify.nullness.Nullable;
 
 /** A source file. */
 public class SourceFile {
@@ -55,7 +56,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     if (!(obj instanceof SourceFile)) {
       return false;
     }
diff --git a/java/com/google/turbine/diag/TurbineDiagnostic.java b/java/com/google/turbine/diag/TurbineDiagnostic.java
index ed04a5d..1457868 100644
--- a/java/com/google/turbine/diag/TurbineDiagnostic.java
+++ b/java/com/google/turbine/diag/TurbineDiagnostic.java
@@ -27,7 +27,7 @@
 import com.google.turbine.diag.TurbineError.ErrorKind;
 import java.util.Objects;
 import javax.tools.Diagnostic;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A compilation error. */
 public class TurbineDiagnostic {
@@ -77,6 +77,7 @@
     sb.append(": error: ");
     sb.append(message()).append(System.lineSeparator());
     if (line() != -1 && column() != -1) {
+      requireNonNull(source); // line and column imply source is non-null
       sb.append(CharMatcher.breakingWhitespace().trimTrailingFrom(source.lineMap().line(position)))
           .append(System.lineSeparator());
       sb.append(Strings.repeat(" ", column() - 1)).append('^');
@@ -143,7 +144,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     if (!(obj instanceof TurbineDiagnostic)) {
       return false;
     }
@@ -159,10 +160,12 @@
     return source != null && source.path() != null ? source.path() : "<>";
   }
 
+  @SuppressWarnings("nullness") // position != -1 implies source is non-null
   public int line() {
     return position != -1 ? source.lineMap().lineNumber(position) : -1;
   }
 
+  @SuppressWarnings("nullness") // position != -1 implies source is non-null
   public int column() {
     return position != -1 ? source.lineMap().column(position) + 1 : -1;
   }
diff --git a/java/com/google/turbine/diag/TurbineError.java b/java/com/google/turbine/diag/TurbineError.java
index f3ebf08..f839345 100644
--- a/java/com/google/turbine/diag/TurbineError.java
+++ b/java/com/google/turbine/diag/TurbineError.java
@@ -48,8 +48,10 @@
     CANNOT_RESOLVE("could not resolve %s"),
     EXPRESSION_ERROR("could not evaluate constant expression"),
     OPERAND_TYPE("bad operand type %s"),
+    TYPE_CONVERSION("value %s of type %s cannot be converted to %s"),
     CYCLIC_HIERARCHY("cycle in class hierarchy: %s"),
     NOT_AN_ANNOTATION("%s is not an annotation"),
+    ANNOTATION_VALUE_NAME("expected an annotation value of the form name=value"),
     NONREPEATABLE_ANNOTATION("%s is not @Repeatable"),
     DUPLICATE_DECLARATION("duplicate declaration of %s"),
     BAD_MODULE_INFO("unexpected declaration found in module-info"),
diff --git a/java/com/google/turbine/diag/package-info.java b/java/com/google/turbine/diag/package-info.java
new file mode 100644
index 0000000..f19a95c
--- /dev/null
+++ b/java/com/google/turbine/diag/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.diag;
diff --git a/java/com/google/turbine/lower/Lower.java b/java/com/google/turbine/lower/Lower.java
index 971bbe4..362316d 100644
--- a/java/com/google/turbine/lower/Lower.java
+++ b/java/com/google/turbine/lower/Lower.java
@@ -18,6 +18,8 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.turbine.binder.DisambiguateTypeAnnotations.groupRepeated;
+import static java.lang.Math.max;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
@@ -37,6 +39,7 @@
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.ParamInfo;
+import com.google.turbine.binder.bound.TypeBoundClass.RecordComponentInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.TyVarInfo;
 import com.google.turbine.binder.bytecode.BytecodeBoundClass;
 import com.google.turbine.binder.env.CompoundEnv;
@@ -66,6 +69,7 @@
 import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.model.TurbineVisibility;
+import com.google.turbine.options.LanguageVersion;
 import com.google.turbine.type.AnnoInfo;
 import com.google.turbine.type.Type;
 import com.google.turbine.type.Type.ArrayTy;
@@ -83,7 +87,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** Lowering from bound classes to bytecode. */
 public class Lower {
@@ -111,6 +115,7 @@
 
   /** Lowers all given classes to bytecode. */
   public static Lowered lowerAll(
+      LanguageVersion languageVersion,
       ImmutableMap<ClassSymbol, SourceTypeBoundClass> units,
       ImmutableList<SourceModuleInfo> modules,
       Env<ClassSymbol, BytecodeBoundClass> classpath) {
@@ -118,20 +123,24 @@
         CompoundEnv.<ClassSymbol, TypeBoundClass>of(classpath).append(new SimpleEnv<>(units));
     ImmutableMap.Builder<String, byte[]> result = ImmutableMap.builder();
     Set<ClassSymbol> symbols = new LinkedHashSet<>();
+    // Output Java 8 bytecode at minimum, for type annotations
+    int majorVersion = max(languageVersion.majorVersion(), 52);
     for (ClassSymbol sym : units.keySet()) {
-      result.put(sym.binaryName(), lower(units.get(sym), env, sym, symbols));
+      result.put(sym.binaryName(), lower(units.get(sym), env, sym, symbols, majorVersion));
     }
     if (modules.size() == 1) {
       // single module mode: the module-info.class file is at the root
-      result.put("module-info", lower(getOnlyElement(modules), env, symbols));
+      result.put("module-info", lower(getOnlyElement(modules), env, symbols, majorVersion));
     } else {
       // multi-module mode: the output module-info.class are in a directory corresponding to their
       // package
       for (SourceModuleInfo module : modules) {
-        result.put(module.name().replace('.', '/') + "/module-info", lower(module, env, symbols));
+        result.put(
+            module.name().replace('.', '/') + "/module-info",
+            lower(module, env, symbols, majorVersion));
       }
     }
-    return new Lowered(result.build(), ImmutableSet.copyOf(symbols));
+    return new Lowered(result.buildOrThrow(), ImmutableSet.copyOf(symbols));
   }
 
   /** Lowers a class to bytecode. */
@@ -139,15 +148,17 @@
       SourceTypeBoundClass info,
       Env<ClassSymbol, TypeBoundClass> env,
       ClassSymbol sym,
-      Set<ClassSymbol> symbols) {
-    return new Lower(env).lower(info, sym, symbols);
+      Set<ClassSymbol> symbols,
+      int majorVersion) {
+    return new Lower(env).lower(info, sym, symbols, majorVersion);
   }
 
   private static byte[] lower(
       SourceModuleInfo module,
       CompoundEnv<ClassSymbol, TypeBoundClass> env,
-      Set<ClassSymbol> symbols) {
-    return new Lower(env).lower(module, symbols);
+      Set<ClassSymbol> symbols,
+      int majorVersion) {
+    return new Lower(env).lower(module, symbols, majorVersion);
   }
 
   private final LowerSignature sig = new LowerSignature();
@@ -157,7 +168,7 @@
     this.env = env;
   }
 
-  private byte[] lower(SourceModuleInfo module, Set<ClassSymbol> symbols) {
+  private byte[] lower(SourceModuleInfo module, Set<ClassSymbol> symbols, int majorVersion) {
     String name = "module-info";
     ImmutableList<AnnotationInfo> annotations = lowerAnnotations(module.annos());
     ClassFile.ModuleInfo moduleInfo = lowerModule(module);
@@ -176,16 +187,21 @@
     ClassFile classfile =
         new ClassFile(
             /* access= */ TurbineFlag.ACC_MODULE,
+            majorVersion,
             name,
             /* signature= */ null,
             /* superClass= */ null,
             /* interfaces= */ ImmutableList.of(),
+            /* permits= */ ImmutableList.of(),
             /* methods= */ ImmutableList.of(),
             /* fields= */ ImmutableList.of(),
             annotations,
             innerClasses.build(),
             /* typeAnnotations= */ ImmutableList.of(),
             moduleInfo,
+            /* nestHost= */ null,
+            /* nestMembers= */ ImmutableList.of(),
+            /* record= */ null,
             /* transitiveJar= */ null);
     symbols.addAll(sig.classes);
     return ClassWriter.writeClass(classfile);
@@ -234,7 +250,8 @@
         provides.build());
   }
 
-  private byte[] lower(SourceTypeBoundClass info, ClassSymbol sym, Set<ClassSymbol> symbols) {
+  private byte[] lower(
+      SourceTypeBoundClass info, ClassSymbol sym, Set<ClassSymbol> symbols, int majorVersion) {
     int access = classAccess(info);
     String name = sig.descriptor(sym);
     String signature = sig.classSignature(info, env);
@@ -243,6 +260,20 @@
     for (ClassSymbol i : info.interfaces()) {
       interfaces.add(sig.descriptor(i));
     }
+    List<String> permits = new ArrayList<>();
+    for (ClassSymbol i : info.permits()) {
+      permits.add(sig.descriptor(i));
+    }
+
+    ClassFile.RecordInfo record = null;
+    if (info.kind().equals(TurbineTyKind.RECORD)) {
+      ImmutableList.Builder<ClassFile.RecordInfo.RecordComponentInfo> components =
+          ImmutableList.builder();
+      for (RecordComponentInfo component : info.components()) {
+        components.add(lowerComponent(info, component));
+      }
+      record = new ClassFile.RecordInfo(components.build());
+    }
 
     List<ClassFile.MethodInfo> methods = new ArrayList<>();
     for (MethodInfo m : info.methods()) {
@@ -266,21 +297,34 @@
 
     ImmutableList<TypeAnnotationInfo> typeAnnotations = classTypeAnnotations(info);
 
+    String nestHost = null;
+    ImmutableList<String> nestMembers = ImmutableList.of();
+    // nests were added in Java 11, i.e. major version 55
+    if (majorVersion >= 55) {
+      nestHost = collectNestHost(info.source(), info.owner());
+      nestMembers = nestHost == null ? collectNestMembers(info.source(), info) : ImmutableList.of();
+    }
+
     ImmutableList<ClassFile.InnerClass> inners = collectInnerClasses(info.source(), sym, info);
 
     ClassFile classfile =
         new ClassFile(
             access,
+            majorVersion,
             name,
             signature,
             superName,
             interfaces,
+            permits,
             methods,
             fields.build(),
             annotations,
             inners,
             typeAnnotations,
             /* module= */ null,
+            nestHost,
+            nestMembers,
+            record,
             /* transitiveJar= */ null);
 
     symbols.addAll(sig.classes);
@@ -288,6 +332,18 @@
     return ClassWriter.writeClass(classfile);
   }
 
+  private ClassFile.RecordInfo.RecordComponentInfo lowerComponent(
+      SourceTypeBoundClass info, RecordComponentInfo c) {
+    Function<TyVarSymbol, TyVarInfo> tenv = new TyVarEnv(info.typeParameterTypes());
+    String desc = SigWriter.type(sig.signature(Erasure.erase(c.type(), tenv)));
+    String signature = sig.fieldSignature(c.type());
+    ImmutableList.Builder<TypeAnnotationInfo> typeAnnotations = ImmutableList.builder();
+    lowerTypeAnnotations(
+        typeAnnotations, c.type(), TargetType.FIELD, TypeAnnotationInfo.EMPTY_TARGET);
+    return new ClassFile.RecordInfo.RecordComponentInfo(
+        c.name(), desc, signature, lowerAnnotations(c.annotations()), typeAnnotations.build());
+  }
+
   private ClassFile.MethodInfo lowerMethod(final MethodInfo m, final ClassSymbol sym) {
     int access = m.access();
     Function<TyVarSymbol, TyVarInfo> tenv = new TyVarEnv(m.tyParams());
@@ -421,28 +477,74 @@
     if (info == null) {
       throw TurbineError.format(source, ErrorKind.CLASS_FILE_NOT_FOUND, sym);
     }
-    ClassSymbol owner = env.get(sym).owner();
+    ClassSymbol owner = info.owner();
     if (owner != null) {
       addEnclosing(source, env, all, owner);
       all.add(sym);
     }
   }
 
+  private @Nullable String collectNestHost(SourceFile source, @Nullable ClassSymbol sym) {
+    if (sym == null) {
+      return null;
+    }
+    while (true) {
+      TypeBoundClass info = env.get(sym);
+      if (info == null) {
+        throw TurbineError.format(source, ErrorKind.CLASS_FILE_NOT_FOUND, sym);
+      }
+      if (info.owner() == null) {
+        return sig.descriptor(sym);
+      }
+      sym = info.owner();
+    }
+  }
+
+  private ImmutableList<String> collectNestMembers(SourceFile source, SourceTypeBoundClass info) {
+    Set<ClassSymbol> nestMembers = new LinkedHashSet<>();
+    for (ClassSymbol child : info.children().values()) {
+      addNestMembers(source, env, nestMembers, child);
+    }
+    ImmutableList.Builder<String> result = ImmutableList.builder();
+    for (ClassSymbol nestMember : nestMembers) {
+      result.add(sig.descriptor(nestMember));
+    }
+    return result.build();
+  }
+
+  private static void addNestMembers(
+      SourceFile source,
+      Env<ClassSymbol, TypeBoundClass> env,
+      Set<ClassSymbol> nestMembers,
+      ClassSymbol sym) {
+    if (!nestMembers.add(sym)) {
+      return;
+    }
+    TypeBoundClass info = env.get(sym);
+    if (info == null) {
+      throw TurbineError.format(source, ErrorKind.CLASS_FILE_NOT_FOUND, sym);
+    }
+    for (ClassSymbol child : info.children().values()) {
+      addNestMembers(source, env, nestMembers, child);
+    }
+  }
+
   /**
    * Creates an inner class attribute, given an inner class that was referenced somewhere in the
    * class.
    */
   private ClassFile.InnerClass innerClass(
       Env<ClassSymbol, TypeBoundClass> env, ClassSymbol innerSym) {
-    TypeBoundClass inner = env.get(innerSym);
+    TypeBoundClass inner = env.getNonNull(innerSym);
+    // this inner class is known to have an owner
+    ClassSymbol owner = requireNonNull(inner.owner());
 
-    String innerName = innerSym.binaryName().substring(inner.owner().binaryName().length() + 1);
+    String innerName = innerSym.binaryName().substring(owner.binaryName().length() + 1);
 
     int access = inner.access();
     access &= ~(TurbineFlag.ACC_SUPER | TurbineFlag.ACC_STRICT);
 
-    return new ClassFile.InnerClass(
-        innerSym.binaryName(), inner.owner().binaryName(), innerName, access);
+    return new ClassFile.InnerClass(innerSym.binaryName(), owner.binaryName(), innerName, access);
   }
 
   /** Updates visibility, and unsets access bits that can only be set in InnerClass. */
@@ -486,7 +588,7 @@
       // anything that lexically encloses the class being lowered
       // must be in the same compilation unit, so we have source
       // information for it
-      TypeBoundClass owner = env.get((ClassSymbol) ownerSym);
+      TypeBoundClass owner = env.getNonNull((ClassSymbol) ownerSym);
       return owner.typeParameterTypes().get(sym);
     }
   }
@@ -503,7 +605,7 @@
     return lowered.build();
   }
 
-  private AnnotationInfo lowerAnnotation(AnnoInfo annotation) {
+  private @Nullable AnnotationInfo lowerAnnotation(AnnoInfo annotation) {
     Boolean visible = isVisible(annotation.sym());
     if (visible == null) {
       return null;
@@ -516,9 +618,9 @@
    * Returns true if the annotation is visible at runtime, false if it is not visible at runtime,
    * and {@code null} if it should not be retained in bytecode.
    */
-  @Nullable
-  private Boolean isVisible(ClassSymbol sym) {
-    RetentionPolicy retention = env.get(sym).annotationMetadata().retention();
+  private @Nullable Boolean isVisible(ClassSymbol sym) {
+    RetentionPolicy retention =
+        requireNonNull(env.getNonNull(sym).annotationMetadata()).retention();
     switch (retention) {
       case CLASS:
         return false;
@@ -535,7 +637,7 @@
     for (Map.Entry<String, Const> entry : values.entrySet()) {
       result.put(entry.getKey(), annotationValue(entry.getValue()));
     }
-    return result.build();
+    return result.buildOrThrow();
   }
 
   private ElementValue annotationValue(Const value) {
@@ -691,7 +793,7 @@
 
   private boolean isInterface(Type type, Env<ClassSymbol, TypeBoundClass> env) {
     return type.tyKind() == TyKind.CLASS_TY
-        && env.get(((ClassTy) type).sym()).kind() == TurbineTyKind.INTERFACE;
+        && env.getNonNull(((ClassTy) type).sym()).kind() == TurbineTyKind.INTERFACE;
   }
 
   private void lowerTypeAnnotations(
diff --git a/java/com/google/turbine/lower/LowerSignature.java b/java/com/google/turbine/lower/LowerSignature.java
index a08c7e8..1960f8e 100644
--- a/java/com/google/turbine/lower/LowerSignature.java
+++ b/java/com/google/turbine/lower/LowerSignature.java
@@ -46,6 +46,7 @@
 import java.util.LinkedHashSet;
 import java.util.Map;
 import java.util.Set;
+import org.jspecify.nullness.Nullable;
 
 /** Translator from {@link Type}s to {@link Sig}natures. */
 public class LowerSignature {
@@ -127,7 +128,7 @@
    * Produces a method signature attribute for a generic method, or {@code null} if the signature is
    * unnecessary.
    */
-  public String methodSignature(
+  public @Nullable String methodSignature(
       Env<ClassSymbol, TypeBoundClass> env, TypeBoundClass.MethodInfo method, ClassSymbol sym) {
     if (!needsMethodSig(sym, env, method)) {
       return null;
@@ -160,14 +161,11 @@
 
   private boolean needsMethodSig(
       ClassSymbol sym, Env<ClassSymbol, TypeBoundClass> env, TypeBoundClass.MethodInfo m) {
-    if ((env.get(sym).access() & TurbineFlag.ACC_ENUM) == TurbineFlag.ACC_ENUM
+    if ((env.getNonNull(sym).access() & TurbineFlag.ACC_ENUM) == TurbineFlag.ACC_ENUM
         && m.name().equals("<init>")) {
       // JDK-8024694: javac always expects signature attribute for enum constructors
       return true;
     }
-    if ((m.access() & TurbineFlag.ACC_SYNTH_CTOR) == TurbineFlag.ACC_SYNTH_CTOR) {
-      return false;
-    }
     if (!m.tyParams().isEmpty()) {
       return true;
     }
@@ -194,16 +192,13 @@
    * Produces a class signature attribute for a generic class, or {@code null} if the signature is
    * unnecessary.
    */
-  public String classSignature(SourceTypeBoundClass info, Env<ClassSymbol, TypeBoundClass> env) {
+  public @Nullable String classSignature(
+      SourceTypeBoundClass info, Env<ClassSymbol, TypeBoundClass> env) {
     if (!classNeedsSig(info)) {
       return null;
     }
     ImmutableList<Sig.TyParamSig> typarams = tyParamSig(info.typeParameterTypes(), env);
-
-    ClassTySig xtnd = null;
-    if (info.superClassType() != null) {
-      xtnd = classTySig((ClassTy) info.superClassType());
-    }
+    ClassTySig xtnd = classTySig((ClassTy) info.superClassType());
     ImmutableList.Builder<ClassTySig> impl = ImmutableList.builder();
     for (Type i : info.interfaceTypes()) {
       impl.add(classTySig((ClassTy) i));
@@ -215,7 +210,7 @@
   /**
    * A field signature, or {@code null} if the descriptor provides all necessary type information.
    */
-  public String fieldSignature(Type type) {
+  public @Nullable String fieldSignature(Type type) {
     return needsSig(type) ? SigWriter.type(signature(type)) : null;
   }
 
@@ -295,7 +290,7 @@
 
   private boolean isInterface(Type type, Env<ClassSymbol, TypeBoundClass> env) {
     return type.tyKind() == TyKind.CLASS_TY
-        && env.get(((ClassTy) type).sym()).kind() == TurbineTyKind.INTERFACE;
+        && env.getNonNull(((ClassTy) type).sym()).kind() == TurbineTyKind.INTERFACE;
   }
 
   public String descriptor(ClassSymbol sym) {
diff --git a/java/com/google/turbine/lower/package-info.java b/java/com/google/turbine/lower/package-info.java
new file mode 100644
index 0000000..f5c54fc
--- /dev/null
+++ b/java/com/google/turbine/lower/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.lower;
diff --git a/java/com/google/turbine/main/Main.java b/java/com/google/turbine/main/Main.java
index 59563b6..da97bcd 100644
--- a/java/com/google/turbine/main/Main.java
+++ b/java/com/google/turbine/main/Main.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.hash.Hashing;
 import com.google.common.io.MoreFiles;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.turbine.binder.Binder;
 import com.google.turbine.binder.Binder.BindingResult;
 import com.google.turbine.binder.Binder.Statistics;
@@ -62,6 +63,7 @@
 import java.util.Collection;
 import java.util.Map;
 import java.util.Optional;
+import java.util.OptionalInt;
 import java.util.jar.Attributes;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
@@ -126,10 +128,12 @@
     }
   }
 
-  public static void compile(String[] args) throws IOException {
-    compile(TurbineOptionsParser.parse(Arrays.asList(args)));
+  @CanIgnoreReturnValue
+  public static Result compile(String[] args) throws IOException {
+    return compile(TurbineOptionsParser.parse(Arrays.asList(args)));
   }
 
+  @CanIgnoreReturnValue
   public static Result compile(TurbineOptions options) throws IOException {
     usage(options);
 
@@ -190,14 +194,16 @@
         || options.output().isPresent()
         || options.outputManifest().isPresent()) {
       // TODO(cushon): parallelize
-      Lowered lowered = Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv());
+      Lowered lowered =
+          Lower.lowerAll(
+              options.languageVersion(), bound.units(), bound.modules(), bound.classPathEnv());
 
       if (options.outputDeps().isPresent()) {
         DepsProto.Dependencies deps =
             Dependencies.collectDeps(options.targetLabel(), bootclasspath, bound, lowered);
-        try (OutputStream os =
-            new BufferedOutputStream(
-                Files.newOutputStream(Paths.get(options.outputDeps().get())))) {
+        Path path = Paths.get(options.outputDeps().get());
+        Files.createDirectories(path.getParent());
+        try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(path))) {
           deps.writeTo(os);
         }
       }
@@ -255,6 +261,7 @@
         units,
         ClassPathBinder.bindClasspath(toPaths(classpath)),
         Processing.initializeProcessors(
+            /* sourceVersion= */ options.languageVersion().sourceVersion(),
             /* javacopts= */ options.javacOpts(),
             /* processorNames= */ options.processors(),
             Processing.processorLoader(
@@ -278,18 +285,18 @@
 
   private static ClassPath bootclasspath(TurbineOptions options) throws IOException {
     // if both --release and --bootclasspath are specified, --release wins
-    if (options.release().isPresent() && options.system().isPresent()) {
+    OptionalInt release = options.languageVersion().release();
+    if (release.isPresent() && options.system().isPresent()) {
       throw new UsageException("expected at most one of --release and --system");
     }
 
-    if (options.release().isPresent()) {
-      String release = options.release().get();
-      if (release.equals(JAVA_SPECIFICATION_VERSION.value())) {
+    if (release.isPresent()) {
+      if (release.getAsInt() == Integer.parseInt(JAVA_SPECIFICATION_VERSION.value())) {
         // if --release matches the host JDK, use its jimage instead of ct.sym
         return JimageClassBinder.bindDefault();
       }
       // ... otherwise, search ct.sym for a matching release
-      ClassPath bootclasspath = CtSymClassBinder.bind(release);
+      ClassPath bootclasspath = CtSymClassBinder.bind(release.getAsInt());
       if (bootclasspath == null) {
         throw new UsageException("not a supported release: " + release);
       }
@@ -337,7 +344,7 @@
       for (SourceFile source : generatedSources.values()) {
         Path to = path.resolve(source.path());
         Files.createDirectories(to.getParent());
-        Files.write(to, source.source().getBytes(UTF_8));
+        Files.writeString(to, source.source());
       }
       return;
     }
diff --git a/java/com/google/turbine/main/package-info.java b/java/com/google/turbine/main/package-info.java
new file mode 100644
index 0000000..71735f2
--- /dev/null
+++ b/java/com/google/turbine/main/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.main;
diff --git a/java/com/google/turbine/model/Const.java b/java/com/google/turbine/model/Const.java
index ed4b072..bd90f59 100644
--- a/java/com/google/turbine/model/Const.java
+++ b/java/com/google/turbine/model/Const.java
@@ -21,6 +21,7 @@
 import com.google.common.escape.SourceCodeEscapers;
 import javax.lang.model.element.AnnotationValue;
 import javax.lang.model.element.AnnotationValueVisitor;
+import org.jspecify.nullness.Nullable;
 
 /**
  * Compile-time constant expressions, including literals of primitive or String type, class
@@ -32,7 +33,7 @@
   public abstract int hashCode();
 
   @Override
-  public abstract boolean equals(Object obj);
+  public abstract boolean equals(@Nullable Object obj);
 
   @Override
   public abstract String toString();
@@ -64,42 +65,6 @@
     public Kind kind() {
       return Kind.PRIMITIVE;
     }
-
-    public IntValue asInteger() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.INT);
-    }
-
-    public FloatValue asFloat() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.FLOAT);
-    }
-
-    public DoubleValue asDouble() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.DOUBLE);
-    }
-
-    public LongValue asLong() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.LONG);
-    }
-
-    public BooleanValue asBoolean() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.BOOLEAN);
-    }
-
-    public StringValue asString() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.STRING);
-    }
-
-    public CharValue asChar() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.CHAR);
-    }
-
-    public ShortValue asShort() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.SHORT);
-    }
-
-    public ByteValue asByte() {
-      throw new ConstCastError(constantTypeKind(), TurbineConstantTypeKind.BYTE);
-    }
   }
 
   /** A boolean literal value. */
@@ -135,22 +100,12 @@
     }
 
     @Override
-    public BooleanValue asBoolean() {
-      return this;
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Boolean.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof BooleanValue && value == ((BooleanValue) obj).value();
     }
   }
@@ -189,52 +144,12 @@
     }
 
     @Override
-    public IntValue asInteger() {
-      return this;
-    }
-
-    @Override
-    public ByteValue asByte() {
-      return new ByteValue((byte) value);
-    }
-
-    @Override
-    public LongValue asLong() {
-      return new LongValue((long) value);
-    }
-
-    @Override
-    public CharValue asChar() {
-      return new CharValue((char) value);
-    }
-
-    @Override
-    public ShortValue asShort() {
-      return new ShortValue((short) value);
-    }
-
-    @Override
-    public DoubleValue asDouble() {
-      return new DoubleValue((double) value);
-    }
-
-    @Override
-    public FloatValue asFloat() {
-      return new FloatValue((float) value);
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Integer.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof IntValue && value == ((IntValue) obj).value;
     }
   }
@@ -272,52 +187,12 @@
     }
 
     @Override
-    public IntValue asInteger() {
-      return new IntValue((int) value);
-    }
-
-    @Override
-    public ByteValue asByte() {
-      return new ByteValue((byte) value);
-    }
-
-    @Override
-    public LongValue asLong() {
-      return this;
-    }
-
-    @Override
-    public CharValue asChar() {
-      return new CharValue((char) value);
-    }
-
-    @Override
-    public ShortValue asShort() {
-      return new ShortValue((short) value);
-    }
-
-    @Override
-    public DoubleValue asDouble() {
-      return new DoubleValue((double) value);
-    }
-
-    @Override
-    public FloatValue asFloat() {
-      return new FloatValue((float) value);
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Long.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof LongValue && value == ((LongValue) obj).value;
     }
   }
@@ -355,52 +230,12 @@
     }
 
     @Override
-    public IntValue asInteger() {
-      return new IntValue((int) value);
-    }
-
-    @Override
-    public ByteValue asByte() {
-      return new ByteValue((byte) value);
-    }
-
-    @Override
-    public LongValue asLong() {
-      return new LongValue((long) value);
-    }
-
-    @Override
-    public CharValue asChar() {
-      return this;
-    }
-
-    @Override
-    public ShortValue asShort() {
-      return new ShortValue((short) value);
-    }
-
-    @Override
-    public DoubleValue asDouble() {
-      return new DoubleValue((double) value);
-    }
-
-    @Override
-    public FloatValue asFloat() {
-      return new FloatValue((float) value);
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Character.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof CharValue && value == ((CharValue) obj).value;
     }
   }
@@ -441,52 +276,12 @@
     }
 
     @Override
-    public IntValue asInteger() {
-      return new IntValue((int) value);
-    }
-
-    @Override
-    public ByteValue asByte() {
-      return new ByteValue((byte) value);
-    }
-
-    @Override
-    public LongValue asLong() {
-      return new LongValue((long) value);
-    }
-
-    @Override
-    public CharValue asChar() {
-      return new CharValue((char) value);
-    }
-
-    @Override
-    public ShortValue asShort() {
-      return new ShortValue((short) value);
-    }
-
-    @Override
-    public DoubleValue asDouble() {
-      return new DoubleValue((double) value);
-    }
-
-    @Override
-    public FloatValue asFloat() {
-      return this;
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Float.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof FloatValue && value == ((FloatValue) obj).value;
     }
   }
@@ -533,52 +328,12 @@
     }
 
     @Override
-    public IntValue asInteger() {
-      return new IntValue((int) value);
-    }
-
-    @Override
-    public ByteValue asByte() {
-      return new ByteValue((byte) value);
-    }
-
-    @Override
-    public LongValue asLong() {
-      return new LongValue((long) value);
-    }
-
-    @Override
-    public CharValue asChar() {
-      return new CharValue((char) value);
-    }
-
-    @Override
-    public ShortValue asShort() {
-      return new ShortValue((short) value);
-    }
-
-    @Override
-    public DoubleValue asDouble() {
-      return this;
-    }
-
-    @Override
-    public FloatValue asFloat() {
-      return new FloatValue((float) value);
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Double.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof DoubleValue && value == ((DoubleValue) obj).value;
     }
   }
@@ -616,17 +371,12 @@
     }
 
     @Override
-    public StringValue asString() {
-      return this;
-    }
-
-    @Override
     public int hashCode() {
       return value.hashCode();
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof StringValue && value.equals(((StringValue) obj).value);
     }
   }
@@ -664,52 +414,12 @@
     }
 
     @Override
-    public IntValue asInteger() {
-      return new IntValue((int) value);
-    }
-
-    @Override
-    public ByteValue asByte() {
-      return new ByteValue((byte) value);
-    }
-
-    @Override
-    public LongValue asLong() {
-      return new LongValue((long) value);
-    }
-
-    @Override
-    public CharValue asChar() {
-      return new CharValue((char) value);
-    }
-
-    @Override
-    public ShortValue asShort() {
-      return this;
-    }
-
-    @Override
-    public DoubleValue asDouble() {
-      return new DoubleValue((double) value);
-    }
-
-    @Override
-    public FloatValue asFloat() {
-      return new FloatValue((float) value);
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Short.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof ShortValue && value == ((ShortValue) obj).value;
     }
   }
@@ -738,52 +448,12 @@
     }
 
     @Override
-    public IntValue asInteger() {
-      return new IntValue((int) value);
-    }
-
-    @Override
-    public ByteValue asByte() {
-      return this;
-    }
-
-    @Override
-    public LongValue asLong() {
-      return new LongValue((long) value);
-    }
-
-    @Override
-    public CharValue asChar() {
-      return new CharValue((char) value);
-    }
-
-    @Override
-    public ShortValue asShort() {
-      return new ShortValue((short) value);
-    }
-
-    @Override
-    public DoubleValue asDouble() {
-      return new DoubleValue((double) value);
-    }
-
-    @Override
-    public FloatValue asFloat() {
-      return new FloatValue((float) value);
-    }
-
-    @Override
-    public StringValue asString() {
-      return new StringValue(String.valueOf(value));
-    }
-
-    @Override
     public int hashCode() {
       return Byte.hashCode(value);
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof ByteValue && value == ((ByteValue) obj).value;
     }
 
@@ -822,7 +492,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof ArrayInitValue && elements.equals(((ArrayInitValue) obj).elements);
     }
 
diff --git a/java/com/google/turbine/model/TurbineElementType.java b/java/com/google/turbine/model/TurbineElementType.java
index a68df3a..a7debf3 100644
--- a/java/com/google/turbine/model/TurbineElementType.java
+++ b/java/com/google/turbine/model/TurbineElementType.java
@@ -28,5 +28,6 @@
   PARAMETER,
   TYPE,
   TYPE_PARAMETER,
-  TYPE_USE
+  TYPE_USE,
+  RECORD_COMPONENT
 }
diff --git a/java/com/google/turbine/model/TurbineFlag.java b/java/com/google/turbine/model/TurbineFlag.java
index c138d46..3e68a5e 100644
--- a/java/com/google/turbine/model/TurbineFlag.java
+++ b/java/com/google/turbine/model/TurbineFlag.java
@@ -55,5 +55,11 @@
   /** Synthetic constructors (e.g. of inner classes and enums). */
   public static final int ACC_SYNTH_CTOR = 1 << 18;
 
+  public static final int ACC_SEALED = 1 << 19;
+  public static final int ACC_NON_SEALED = 1 << 20;
+
+  /** Compact record constructor. */
+  public static final int ACC_COMPACT_CTOR = 1 << 21;
+
   private TurbineFlag() {}
 }
diff --git a/java/com/google/turbine/model/TurbineTyKind.java b/java/com/google/turbine/model/TurbineTyKind.java
index 6b49f50..b61d6c9 100644
--- a/java/com/google/turbine/model/TurbineTyKind.java
+++ b/java/com/google/turbine/model/TurbineTyKind.java
@@ -21,5 +21,6 @@
   CLASS,
   INTERFACE,
   ENUM,
-  ANNOTATION
+  ANNOTATION,
+  RECORD
 }
diff --git a/java/com/google/turbine/model/package-info.java b/java/com/google/turbine/model/package-info.java
new file mode 100644
index 0000000..a1e3873
--- /dev/null
+++ b/java/com/google/turbine/model/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.model;
diff --git a/java/com/google/turbine/options/LanguageVersion.java b/java/com/google/turbine/options/LanguageVersion.java
new file mode 100644
index 0000000..e2b0ea7
--- /dev/null
+++ b/java/com/google/turbine/options/LanguageVersion.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.options;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import java.util.Iterator;
+import java.util.OptionalInt;
+import javax.lang.model.SourceVersion;
+
+/**
+ * The language version being compiled, corresponding to javac's {@code -source}, {@code -target},
+ * and {@code --release} flags.
+ */
+@AutoValue
+public abstract class LanguageVersion {
+
+  /** The source version. */
+  public abstract int source();
+
+  /** The target version. */
+  public abstract int target();
+
+  /**
+   * The release version.
+   *
+   * <p>If set, system APIs will be resolved from the host JDK's ct.sym instead of using the
+   * provided {@code --bootclasspath}.
+   */
+  public abstract OptionalInt release();
+
+  /** The class file major version corresponding to the {@link #target}. */
+  public int majorVersion() {
+    return target() + 44;
+  }
+
+  public SourceVersion sourceVersion() {
+    try {
+      return SourceVersion.valueOf("RELEASE_" + source());
+    } catch (IllegalArgumentException unused) {
+      throw new IllegalArgumentException("invalid -source version: " + source());
+    }
+  }
+
+  private static LanguageVersion create(int source, int target, OptionalInt release) {
+    return new AutoValue_LanguageVersion(source, target, release);
+  }
+
+  /** The default language version. Currently Java 8. */
+  public static LanguageVersion createDefault() {
+    return create(DEFAULT, DEFAULT, OptionalInt.empty());
+  }
+
+  private static final int DEFAULT = 8;
+
+  /** Returns the effective {@code LanguageVersion} for the given list of javac options. */
+  public static LanguageVersion fromJavacopts(ImmutableList<String> javacopts) {
+    int sourceVersion = DEFAULT;
+    int targetVersion = DEFAULT;
+    OptionalInt release = OptionalInt.empty();
+    Iterator<String> it = javacopts.iterator();
+    while (it.hasNext()) {
+      String option = it.next();
+      switch (option) {
+        case "-source":
+        case "--source":
+          if (!it.hasNext()) {
+            throw new IllegalArgumentException(option + " requires an argument");
+          }
+          sourceVersion = parseVersion(it.next());
+          release = OptionalInt.empty();
+          break;
+        case "-target":
+        case "--target":
+          if (!it.hasNext()) {
+            throw new IllegalArgumentException(option + " requires an argument");
+          }
+          targetVersion = parseVersion(it.next());
+          release = OptionalInt.empty();
+          break;
+        case "--release":
+          if (!it.hasNext()) {
+            throw new IllegalArgumentException(option + " requires an argument");
+          }
+          String value = it.next();
+          Integer n = Ints.tryParse(value);
+          if (n == null) {
+            throw new IllegalArgumentException("invalid --release version: " + value);
+          }
+          release = OptionalInt.of(n);
+          sourceVersion = n;
+          targetVersion = n;
+          break;
+        default:
+          break;
+      }
+    }
+    return create(sourceVersion, targetVersion, release);
+  }
+
+  private static int parseVersion(String value) {
+    boolean hasPrefix = value.startsWith("1.");
+    Integer version = Ints.tryParse(hasPrefix ? value.substring("1.".length()) : value);
+    if (version == null || !isValidVersion(version, hasPrefix)) {
+      throw new IllegalArgumentException("invalid -source version: " + value);
+    }
+    return version;
+  }
+
+  private static boolean isValidVersion(int version, boolean hasPrefix) {
+    if (version < 5) {
+      // the earliest source version supported by JDK 8 is Java 5
+      return false;
+    }
+    if (hasPrefix && version > 10) {
+      // javac supports legacy `1.*` version numbers for source versions up to Java 10
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/java/com/google/turbine/options/TurbineOptions.java b/java/com/google/turbine/options/TurbineOptions.java
index c104c54..5cd9a61 100644
--- a/java/com/google/turbine/options/TurbineOptions.java
+++ b/java/com/google/turbine/options/TurbineOptions.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import java.util.Optional;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** Header compilation options. */
 @AutoValue
@@ -61,8 +61,8 @@
   /** Paths to compilation bootclasspath artifacts. */
   public abstract ImmutableSet<String> bootClassPath();
 
-  /** The target platform version. */
-  public abstract Optional<String> release();
+  /** The language version. */
+  public abstract LanguageVersion languageVersion();
 
   /** The target platform's system modules. */
   public abstract Optional<String> system();
@@ -138,6 +138,7 @@
         .setDirectJars(ImmutableList.of())
         .setDepsArtifacts(ImmutableList.of())
         .addAllJavacOpts(ImmutableList.of())
+        .setLanguageVersion(LanguageVersion.createDefault())
         .setReducedClasspathMode(ReducedClasspathMode.NONE)
         .setHelp(false)
         .setFullClasspathLength(0)
@@ -153,7 +154,7 @@
 
     public abstract Builder setBootClassPath(ImmutableList<String> bootClassPath);
 
-    public abstract Builder setRelease(String release);
+    public abstract Builder setLanguageVersion(LanguageVersion languageVersion);
 
     public abstract Builder setSystem(String system);
 
diff --git a/java/com/google/turbine/options/TurbineOptionsParser.java b/java/com/google/turbine/options/TurbineOptionsParser.java
index 4a8ff16..e68a546 100644
--- a/java/com/google/turbine/options/TurbineOptionsParser.java
+++ b/java/com/google/turbine/options/TurbineOptionsParser.java
@@ -17,7 +17,6 @@
 package com.google.turbine.options;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
@@ -29,7 +28,6 @@
 import java.nio.file.Paths;
 import java.util.ArrayDeque;
 import java.util.Deque;
-import java.util.Iterator;
 
 /** A command line options parser for {@link TurbineOptions}. */
 public final class TurbineOptionsParser {
@@ -83,19 +81,14 @@
         case "--bootclasspath":
           builder.setBootClassPath(readList(argumentDeque));
           break;
-        case "--release":
-          builder.setRelease(readOne(next, argumentDeque));
-          break;
         case "--system":
           builder.setSystem(readOne(next, argumentDeque));
           break;
         case "--javacopts":
-          {
-            ImmutableList<String> javacopts = readJavacopts(argumentDeque);
-            setReleaseFromJavacopts(builder, javacopts);
-            builder.addAllJavacOpts(javacopts);
-            break;
-          }
+          ImmutableList<String> javacOpts = readJavacopts(argumentDeque);
+          builder.setLanguageVersion(LanguageVersion.fromJavacopts(javacOpts));
+          builder.addAllJavacOpts(javacOpts);
+          break;
         case "--sources":
           builder.setSources(readList(argumentDeque));
           break;
@@ -193,8 +186,7 @@
         if (!Files.exists(paramsPath)) {
           throw new AssertionError("params file does not exist: " + paramsPath);
         }
-        expandParamsFiles(
-            argumentDeque, ARG_SPLITTER.split(new String(Files.readAllBytes(paramsPath), UTF_8)));
+        expandParamsFiles(argumentDeque, ARG_SPLITTER.split(Files.readString(paramsPath)));
       } else {
         argumentDeque.addLast(arg);
       }
@@ -237,19 +229,5 @@
     throw new IllegalArgumentException("javacopts should be terminated by `--`");
   }
 
-  /**
-   * Parses the given javacopts for {@code --release}, and if found sets turbine's {@code --release}
-   * flag.
-   */
-  private static void setReleaseFromJavacopts(
-      TurbineOptions.Builder builder, ImmutableList<String> javacopts) {
-    Iterator<String> it = javacopts.iterator();
-    while (it.hasNext()) {
-      if (it.next().equals("--release") && it.hasNext()) {
-        builder.setRelease(it.next());
-      }
-    }
-  }
-
   private TurbineOptionsParser() {}
 }
diff --git a/java/com/google/turbine/options/package-info.java b/java/com/google/turbine/options/package-info.java
index 9c12bf8..45bad5e 100644
--- a/java/com/google/turbine/options/package-info.java
+++ b/java/com/google/turbine/options/package-info.java
@@ -14,4 +14,5 @@
  * limitations under the License.
  */
 
+@org.jspecify.nullness.NullMarked
 package com.google.turbine.options;
diff --git a/java/com/google/turbine/parse/ConstExpressionParser.java b/java/com/google/turbine/parse/ConstExpressionParser.java
index ba51814..8b7466f 100644
--- a/java/com/google/turbine/parse/ConstExpressionParser.java
+++ b/java/com/google/turbine/parse/ConstExpressionParser.java
@@ -25,13 +25,14 @@
 import com.google.turbine.model.Const;
 import com.google.turbine.model.TurbineConstantTypeKind;
 import com.google.turbine.tree.Tree;
+import com.google.turbine.tree.Tree.AnnoExpr;
 import com.google.turbine.tree.Tree.ClassLiteral;
 import com.google.turbine.tree.Tree.ClassTy;
 import com.google.turbine.tree.Tree.Expression;
 import com.google.turbine.tree.Tree.Ident;
 import com.google.turbine.tree.TurbineOperatorKind;
 import java.util.Optional;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** A parser for compile-time constant expressions. */
 public class ConstExpressionParser {
@@ -40,13 +41,13 @@
   private int position;
   private final Lexer lexer;
 
-  public ConstExpressionParser(Lexer lexer, Token token) {
+  public ConstExpressionParser(Lexer lexer, Token token, int position) {
     this.lexer = lexer;
     this.token = token;
-    this.position = lexer.position();
+    this.position = position;
   }
 
-  private static TurbineOperatorKind operator(Token token) {
+  private static @Nullable TurbineOperatorKind operator(Token token) {
     switch (token) {
       case ASSIGN:
         // TODO(cushon): only allow in annotations?
@@ -96,7 +97,7 @@
     }
   }
 
-  private Tree.@Nullable Expression primary(boolean negate) {
+  private @Nullable Expression primary(boolean negate) {
     switch (token) {
       case INT_LITERAL:
         return finishLiteral(TurbineConstantTypeKind.INT, negate);
@@ -107,13 +108,19 @@
       case FLOAT_LITERAL:
         return finishLiteral(TurbineConstantTypeKind.FLOAT, negate);
       case TRUE:
-        eat();
-        return new Tree.Literal(
-            position, TurbineConstantTypeKind.BOOLEAN, new Const.BooleanValue(true));
+        {
+          int pos = position;
+          eat();
+          return new Tree.Literal(
+              pos, TurbineConstantTypeKind.BOOLEAN, new Const.BooleanValue(true));
+        }
       case FALSE:
-        eat();
-        return new Tree.Literal(
-            position, TurbineConstantTypeKind.BOOLEAN, new Const.BooleanValue(false));
+        {
+          int pos = position;
+          eat();
+          return new Tree.Literal(
+              pos, TurbineConstantTypeKind.BOOLEAN, new Const.BooleanValue(false));
+        }
       case CHAR_LITERAL:
         return finishLiteral(TurbineConstantTypeKind.CHAR, negate);
       case STRING_LITERAL:
@@ -169,7 +176,7 @@
     return finishClassLiteral(position, new Tree.PrimTy(position, ImmutableList.of(), type));
   }
 
-  private Tree.Expression maybeCast() {
+  private Expression maybeCast() {
     eat();
     switch (token) {
       case BOOLEAN:
@@ -201,8 +208,8 @@
     }
   }
 
-  private Tree.Expression notCast() {
-    Tree.Expression expr = expression(null);
+  private @Nullable Expression notCast() {
+    Expression expr = expression(null);
     if (expr == null) {
       return null;
     }
@@ -222,13 +229,16 @@
         case NOT:
         case TILDE:
         case IDENT:
-          return new Tree.TypeCast(
-              position, asClassTy(cvar.position(), cvar.name()), primary(false));
+          Expression expression = primary(false);
+          if (expression == null) {
+            throw error(ErrorKind.EXPRESSION_ERROR);
+          }
+          return new Tree.TypeCast(position, asClassTy(cvar.position(), cvar.name()), expression);
         default:
-          return expr;
+          return new Tree.Paren(position, expr);
       }
     } else {
-      return expr;
+      return new Tree.Paren(position, expr);
     }
   }
 
@@ -245,7 +255,7 @@
     position = lexer.position();
   }
 
-  private Tree.Expression arrayInitializer(int pos) {
+  private @Nullable Expression arrayInitializer(int pos) {
     if (token == Token.RBRACE) {
       eat();
       return new Tree.ArrayInit(pos, ImmutableList.<Tree.Expression>of());
@@ -258,7 +268,7 @@
         eat();
         break OUTER;
       }
-      Tree.Expression item = expression(null);
+      Expression item = expression(null);
       if (item == null) {
         return null;
       }
@@ -278,7 +288,7 @@
   }
 
   /** Finish hex, decimal, octal, and binary integer literals (see JLS 3.10.1). */
-  private Tree.Expression finishLiteral(TurbineConstantTypeKind kind, boolean negate) {
+  private Expression finishLiteral(TurbineConstantTypeKind kind, boolean negate) {
     int pos = position;
     String text = ident().value();
     Const.Value value;
@@ -381,7 +391,8 @@
     if (neg) {
       text = text.substring(1);
     }
-    for (char c : text.toCharArray()) {
+    for (int i = 0; i < text.length(); i++) {
+      char c = text.charAt(i);
       int digit;
       if ('0' <= c && c <= '9') {
         digit = c - '0';
@@ -402,9 +413,9 @@
     return r;
   }
 
-  private Tree.Expression unaryRest(TurbineOperatorKind op) {
+  private @Nullable Expression unaryRest(TurbineOperatorKind op) {
     boolean negate = op == TurbineOperatorKind.NEG;
-    Tree.Expression expr = primary(negate);
+    Expression expr = primary(negate);
     if (expr == null) {
       return null;
     }
@@ -421,14 +432,11 @@
     return new Tree.Unary(position, expr, op);
   }
 
-  private Tree.@Nullable Expression qualIdent() {
+  private @Nullable Expression qualIdent() {
     int pos = position;
     ImmutableList.Builder<Ident> bits = ImmutableList.builder();
     bits.add(ident());
     eat();
-    if (token == Token.LBRACK) {
-      return finishClassLiteral(pos, asClassTy(pos, bits.build()));
-    }
     while (token == Token.DOT) {
       eat();
       switch (token) {
@@ -444,6 +452,9 @@
       }
       eat();
     }
+    if (token == Token.LBRACK) {
+      return finishClassLiteral(pos, asClassTy(pos, bits.build()));
+    }
     return new Tree.ConstVarName(pos, bits.build());
   }
 
@@ -451,7 +462,7 @@
     return new Ident(lexer.position(), lexer.stringValue());
   }
 
-  private Expression finishClassLiteral(int pos, Tree.Type type) {
+  private @Nullable Expression finishClassLiteral(int pos, Tree.Type type) {
     while (token == Token.LBRACK) {
       eat();
       if (token != Token.RBRACK) {
@@ -471,8 +482,8 @@
     return new ClassLiteral(pos, type);
   }
 
-  public Tree.Expression expression() {
-    Tree.Expression result = expression(null);
+  public @Nullable Expression expression() {
+    Expression result = expression(null);
     switch (token) {
       case EOF:
       case SEMI:
@@ -485,15 +496,15 @@
     }
   }
 
-  private Tree.Expression expression(TurbineOperatorKind.Precedence prec) {
-    Tree.Expression term1 = primary(false);
+  private @Nullable Expression expression(TurbineOperatorKind.Precedence prec) {
+    Expression term1 = primary(false);
     if (term1 == null) {
       return null;
     }
     return expression(term1, prec);
   }
 
-  private Tree.Expression expression(Tree.Expression term1, TurbineOperatorKind.Precedence prec) {
+  private @Nullable Expression expression(Expression term1, TurbineOperatorKind.Precedence prec) {
     while (true) {
       if (token == Token.EOF) {
         return term1;
@@ -514,7 +525,12 @@
           term1 = assign(term1, op);
           break;
         default:
-          term1 = new Tree.Binary(position, term1, expression(op.prec()), op);
+          int pos = position;
+          Expression term2 = expression(op.prec());
+          if (term2 == null) {
+            return null;
+          }
+          term1 = new Tree.Binary(pos, term1, term2, op);
       }
       if (term1 == null) {
         return null;
@@ -522,7 +538,7 @@
     }
   }
 
-  private Tree.Expression assign(Tree.Expression term1, TurbineOperatorKind op) {
+  private @Nullable Expression assign(Expression term1, TurbineOperatorKind op) {
     if (!(term1 instanceof Tree.ConstVarName)) {
       return null;
     }
@@ -531,15 +547,15 @@
       return null;
     }
     Ident name = getOnlyElement(names);
-    Tree.Expression rhs = expression(op.prec());
+    Expression rhs = expression(op.prec());
     if (rhs == null) {
       return null;
     }
     return new Tree.Assign(term1.position(), name, rhs);
   }
 
-  private Tree.Expression ternary(Tree.Expression term1) {
-    Tree.Expression thenExpr = expression(TurbineOperatorKind.Precedence.TERNARY);
+  private @Nullable Expression ternary(Expression term1) {
+    Expression thenExpr = expression(TurbineOperatorKind.Precedence.TERNARY);
     if (thenExpr == null) {
       return null;
     }
@@ -547,26 +563,26 @@
       return null;
     }
     eat();
-    Tree.Expression elseExpr = expression();
+    Expression elseExpr = expression();
     if (elseExpr == null) {
       return null;
     }
     return new Tree.Conditional(position, term1, thenExpr, elseExpr);
   }
 
-  private Tree.Expression castTail(TurbineConstantTypeKind ty) {
+  private @Nullable Expression castTail(TurbineConstantTypeKind ty) {
     if (token != Token.RPAREN) {
       return null;
     }
     eat();
-    Tree.Expression rhs = primary(false);
+    Expression rhs = primary(false);
     if (rhs == null) {
       return null;
     }
     return new Tree.TypeCast(position, new Tree.PrimTy(position, ImmutableList.of(), ty), rhs);
   }
 
-  private Tree.@Nullable AnnoExpr annotation() {
+  private @Nullable AnnoExpr annotation() {
     if (token != Token.AT) {
       throw new AssertionError();
     }
@@ -582,7 +598,7 @@
       eat();
       while (token != Token.RPAREN) {
         int argPos = position;
-        Tree.Expression expression = expression();
+        Expression expression = expression();
         if (expression == null) {
           throw TurbineError.format(lexer.source(), argPos, ErrorKind.INVALID_ANNOTATION_ARGUMENT);
         }
diff --git a/java/com/google/turbine/parse/Parser.java b/java/com/google/turbine/parse/Parser.java
index af1eabf..c370ad8 100644
--- a/java/com/google/turbine/parse/Parser.java
+++ b/java/com/google/turbine/parse/Parser.java
@@ -17,8 +17,10 @@
 package com.google.turbine.parse;
 
 import static com.google.turbine.parse.Token.COMMA;
+import static com.google.turbine.parse.Token.IDENT;
 import static com.google.turbine.parse.Token.INTERFACE;
 import static com.google.turbine.parse.Token.LPAREN;
+import static com.google.turbine.parse.Token.MINUS;
 import static com.google.turbine.parse.Token.RPAREN;
 import static com.google.turbine.parse.Token.SEMI;
 import static com.google.turbine.tree.TurbineModifier.PROTECTED;
@@ -27,7 +29,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.diag.TurbineError;
 import com.google.turbine.diag.TurbineError.ErrorKind;
@@ -63,7 +65,7 @@
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * A parser for the subset of Java required for header compilation.
@@ -186,6 +188,26 @@
         case IDENT:
           {
             Ident ident = ident();
+            if (ident.value().equals("record")) {
+              next();
+              decls.add(recordDeclaration(access, annos.build()));
+              access = EnumSet.noneOf(TurbineModifier.class);
+              annos = ImmutableList.builder();
+              break;
+            }
+            if (ident.value().equals("sealed")) {
+              next();
+              access.add(TurbineModifier.SEALED);
+              break;
+            }
+            if (ident.value().equals("non")) {
+              int start = position;
+              next();
+              eatNonSealed(start);
+              next();
+              access.add(TurbineModifier.NON_SEALED);
+              break;
+            }
             if (access.isEmpty()
                 && (ident.value().equals("module") || ident.value().equals("open"))) {
               boolean open = false;
@@ -209,11 +231,68 @@
     }
   }
 
+  // Handle the hypenated pseudo-keyword 'non-sealed'.
+  //
+  // This will need to be updated to handle other hyphenated keywords if when/they are introduced.
+  private void eatNonSealed(int start) {
+    eat(Token.MINUS);
+    if (token != IDENT) {
+      throw error(token);
+    }
+    if (!ident().value().equals("sealed")) {
+      throw error(token);
+    }
+    if (position != start + "non-".length()) {
+      throw error(token);
+    }
+  }
+
   private void next() {
     token = lexer.next();
     position = lexer.position();
   }
 
+  private TyDecl recordDeclaration(EnumSet<TurbineModifier> access, ImmutableList<Anno> annos) {
+    String javadoc = lexer.javadoc();
+    int pos = position;
+    Ident name = eatIdent();
+    ImmutableList<TyParam> typarams;
+    if (token == Token.LT) {
+      typarams = typarams();
+    } else {
+      typarams = ImmutableList.of();
+    }
+    ImmutableList.Builder<VarDecl> formals = ImmutableList.builder();
+    if (token == Token.LPAREN) {
+      next();
+      formalParams(formals, EnumSet.noneOf(TurbineModifier.class));
+      eat(Token.RPAREN);
+    }
+    ImmutableList.Builder<ClassTy> interfaces = ImmutableList.builder();
+    if (token == Token.IMPLEMENTS) {
+      next();
+      do {
+        interfaces.add(classty());
+      } while (maybe(Token.COMMA));
+    }
+    eat(Token.LBRACE);
+    ImmutableList<Tree> members = classMembers();
+    eat(Token.RBRACE);
+    return new TyDecl(
+        pos,
+        access,
+        annos,
+        name,
+        typarams,
+        Optional.<ClassTy>empty(),
+        interfaces.build(),
+        /* permits= */ ImmutableList.of(),
+        members,
+        formals.build(),
+        TurbineTyKind.RECORD,
+        javadoc);
+  }
+
   private TyDecl interfaceDeclaration(EnumSet<TurbineModifier> access, ImmutableList<Anno> annos) {
     String javadoc = lexer.javadoc();
     eat(Token.INTERFACE);
@@ -232,6 +311,15 @@
         interfaces.add(classty());
       } while (maybe(Token.COMMA));
     }
+    ImmutableList.Builder<ClassTy> permits = ImmutableList.builder();
+    if (token == Token.IDENT) {
+      if (ident().value().equals("permits")) {
+        eat(Token.IDENT);
+        do {
+          permits.add(classty());
+        } while (maybe(Token.COMMA));
+      }
+    }
     eat(Token.LBRACE);
     ImmutableList<Tree> members = classMembers();
     eat(Token.RBRACE);
@@ -243,7 +331,9 @@
         typarams,
         Optional.<ClassTy>empty(),
         interfaces.build(),
+        permits.build(),
         members,
+        ImmutableList.of(),
         TurbineTyKind.INTERFACE,
         javadoc);
   }
@@ -264,7 +354,9 @@
         ImmutableList.<TyParam>of(),
         Optional.<ClassTy>empty(),
         ImmutableList.<ClassTy>of(),
+        ImmutableList.of(),
         members,
+        ImmutableList.of(),
         TurbineTyKind.ANNOTATION,
         javadoc);
   }
@@ -293,7 +385,9 @@
         ImmutableList.<TyParam>of(),
         Optional.<ClassTy>empty(),
         interfaces.build(),
+        ImmutableList.of(),
         members,
+        ImmutableList.of(),
         TurbineTyKind.ENUM,
         javadoc);
   }
@@ -519,6 +613,15 @@
         interfaces.add(classty());
       } while (maybe(Token.COMMA));
     }
+    ImmutableList.Builder<ClassTy> permits = ImmutableList.builder();
+    if (token == Token.IDENT) {
+      if (ident().value().equals("permits")) {
+        eat(Token.IDENT);
+        do {
+          permits.add(classty());
+        } while (maybe(Token.COMMA));
+      }
+    }
     switch (token) {
       case LBRACE:
         next();
@@ -538,7 +641,9 @@
         tyParams,
         Optional.ofNullable(xtnds),
         interfaces.build(),
+        permits.build(),
         members,
+        ImmutableList.of(),
         TurbineTyKind.CLASS,
         javadoc);
   }
@@ -613,6 +718,29 @@
           }
 
         case IDENT:
+          Ident ident = ident();
+          if (ident.value().equals("non")) {
+            int pos = position;
+            next();
+            if (token != MINUS) {
+              acc.addAll(member(access, annos.build(), ImmutableList.of(), pos, ident));
+              access = EnumSet.noneOf(TurbineModifier.class);
+              annos = ImmutableList.builder();
+            } else {
+              eatNonSealed(pos);
+              next();
+              access.add(TurbineModifier.NON_SEALED);
+            }
+            break;
+          }
+          if (ident.value().equals("record")) {
+            eat(IDENT);
+            acc.add(recordDeclaration(access, annos.build()));
+            access = EnumSet.noneOf(TurbineModifier.class);
+            annos = ImmutableList.builder();
+            break;
+          }
+          // fall through
         case BOOLEAN:
         case BYTE:
         case SHORT:
@@ -696,90 +824,118 @@
           return memberRest(pos, access, annos, typaram, result, name);
         }
       case IDENT:
+        int pos = position;
+        Ident ident = eatIdent();
+        return member(access, annos, typaram, pos, ident);
+      default:
+        throw error(token);
+    }
+  }
+
+  private ImmutableList<Tree> member(
+      EnumSet<TurbineModifier> access,
+      ImmutableList<Anno> annos,
+      ImmutableList<TyParam> typaram,
+      int pos,
+      Ident ident) {
+    Type result;
+    Ident name;
+    switch (token) {
+      case LPAREN:
         {
-          int pos = position;
-          Ident ident = eatIdent();
-          switch (token) {
-            case LPAREN:
-              {
-                name = ident;
-                return ImmutableList.of(methodRest(pos, access, annos, typaram, null, name));
-              }
-            case IDENT:
-              {
-                result =
-                    new ClassTy(
-                        position,
-                        Optional.<ClassTy>empty(),
-                        ident,
-                        ImmutableList.<Type>of(),
-                        ImmutableList.of());
-                pos = position;
-                name = eatIdent();
-                return memberRest(pos, access, annos, typaram, result, name);
-              }
-            case AT:
-            case LBRACK:
-              {
-                result =
-                    new ClassTy(
-                        position,
-                        Optional.<ClassTy>empty(),
-                        ident,
-                        ImmutableList.<Type>of(),
-                        ImmutableList.of());
-                result = maybeDims(maybeAnnos(), result);
-                break;
-              }
-            case LT:
-              {
-                result =
-                    new ClassTy(
-                        position, Optional.<ClassTy>empty(), ident, tyargs(), ImmutableList.of());
-                result = maybeDims(maybeAnnos(), result);
-                break;
-              }
-            case DOT:
-              result =
-                  new ClassTy(
-                      position,
-                      Optional.<ClassTy>empty(),
-                      ident,
-                      ImmutableList.<Type>of(),
-                      ImmutableList.of());
-              break;
-            default:
-              throw error(token);
-          }
-          if (result == null) {
-            throw error(token);
-          }
-          if (token == Token.DOT) {
-            next();
-            if (!result.kind().equals(Kind.CLASS_TY)) {
-              throw error(token);
-            }
-            result = classty((ClassTy) result);
-          }
-          result = maybeDims(maybeAnnos(), result);
+          name = ident;
+          return ImmutableList.of(methodRest(pos, access, annos, typaram, null, name));
+        }
+      case LBRACE:
+        {
+          dropBlocks();
+          name = new Ident(position, CTOR_NAME);
+          String javadoc = lexer.javadoc();
+          access.add(TurbineModifier.COMPACT_CTOR);
+          return ImmutableList.<Tree>of(
+              new MethDecl(
+                  pos,
+                  access,
+                  annos,
+                  typaram,
+                  /* ret= */ Optional.empty(),
+                  name,
+                  /* params= */ ImmutableList.of(),
+                  /* exntys= */ ImmutableList.of(),
+                  /* defaultValue= */ Optional.empty(),
+                  javadoc));
+        }
+      case IDENT:
+        {
+          result =
+              new ClassTy(
+                  position,
+                  Optional.<ClassTy>empty(),
+                  ident,
+                  ImmutableList.<Type>of(),
+                  ImmutableList.of());
           pos = position;
           name = eatIdent();
-          switch (token) {
-            case LPAREN:
-              return ImmutableList.of(methodRest(pos, access, annos, typaram, result, name));
-            case LBRACK:
-            case SEMI:
-            case ASSIGN:
-            case COMMA:
-              {
-                if (!typaram.isEmpty()) {
-                  throw error(ErrorKind.UNEXPECTED_TYPE_PARAMETER, typaram);
-                }
-                return fieldRest(pos, access, annos, result, name);
-              }
-            default:
-              throw error(token);
+          return memberRest(pos, access, annos, typaram, result, name);
+        }
+      case AT:
+      case LBRACK:
+        {
+          result =
+              new ClassTy(
+                  position,
+                  Optional.<ClassTy>empty(),
+                  ident,
+                  ImmutableList.<Type>of(),
+                  ImmutableList.of());
+          result = maybeDims(maybeAnnos(), result);
+          break;
+        }
+      case LT:
+        {
+          result =
+              new ClassTy(position, Optional.<ClassTy>empty(), ident, tyargs(), ImmutableList.of());
+          result = maybeDims(maybeAnnos(), result);
+          break;
+        }
+      case DOT:
+        result =
+            new ClassTy(
+                position,
+                Optional.<ClassTy>empty(),
+                ident,
+                ImmutableList.<Type>of(),
+                ImmutableList.of());
+        break;
+
+      default:
+        throw error(token);
+    }
+    if (result == null) {
+      throw error(token);
+    }
+    if (token == Token.DOT) {
+      next();
+      if (!result.kind().equals(Kind.CLASS_TY)) {
+        throw error(token);
+      }
+      result = classty((ClassTy) result);
+    }
+    result = maybeDims(maybeAnnos(), result);
+    pos = position;
+    name = eatIdent();
+    switch (token) {
+      case LPAREN:
+        return ImmutableList.of(methodRest(pos, access, annos, typaram, result, name));
+      case LBRACK:
+      case SEMI:
+      case ASSIGN:
+      case COMMA:
+        {
+          if (!typaram.isEmpty()) {
+            throw error(ErrorKind.UNEXPECTED_TYPE_PARAMETER, typaram);
           }
+          return fieldRest(pos, access, annos, result, name);
         }
       default:
         throw error(token);
@@ -850,7 +1006,8 @@
       Type ty = baseTy;
       ty = parser.extraDims(ty);
       // TODO(cushon): skip more fields that are definitely non-const
-      ConstExpressionParser constExpressionParser = new ConstExpressionParser(lexer, lexer.next());
+      ConstExpressionParser constExpressionParser =
+          new ConstExpressionParser(lexer, lexer.next(), lexer.position());
       expressionStart = lexer.position();
       Expression init = constExpressionParser.expression();
       if (init != null && init.kind() == Tree.Kind.ARRAY_INIT) {
@@ -895,7 +1052,8 @@
         break;
       case DEFAULT:
         {
-          ConstExpressionParser cparser = new ConstExpressionParser(lexer, lexer.next());
+          ConstExpressionParser cparser =
+              new ConstExpressionParser(lexer, lexer.next(), lexer.position());
           Tree expr = cparser.expression();
           token = cparser.token;
           if (expr == null && token == Token.AT) {
@@ -1369,7 +1527,7 @@
     if (token == Token.LPAREN) {
       eat(LPAREN);
       while (token != RPAREN) {
-        ConstExpressionParser cparser = new ConstExpressionParser(lexer, token);
+        ConstExpressionParser cparser = new ConstExpressionParser(lexer, token, position);
         Expression arg = cparser.expression();
         if (arg == null) {
           throw error(ErrorKind.INVALID_ANNOTATION_ARGUMENT);
@@ -1405,6 +1563,7 @@
     next();
   }
 
+  @CanIgnoreReturnValue
   private boolean maybe(Token kind) {
     if (token == kind) {
       next();
@@ -1413,7 +1572,6 @@
     return false;
   }
 
-  @CheckReturnValue
   TurbineError error(Token token) {
     switch (token) {
       case IDENT:
@@ -1425,7 +1583,6 @@
     }
   }
 
-  @CheckReturnValue
   private TurbineError error(ErrorKind kind, Object... args) {
     return TurbineError.format(
         lexer.source(),
diff --git a/java/com/google/turbine/parse/StreamLexer.java b/java/com/google/turbine/parse/StreamLexer.java
index 991b5fd..2348385 100644
--- a/java/com/google/turbine/parse/StreamLexer.java
+++ b/java/com/google/turbine/parse/StreamLexer.java
@@ -22,6 +22,7 @@
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.diag.TurbineError;
 import com.google.turbine.diag.TurbineError.ErrorKind;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link Lexer} that streams input from a {@link UnicodeEscapePreprocessor}. */
 public class StreamLexer implements Lexer {
@@ -65,7 +66,7 @@
   }
 
   @Override
-  public String javadoc() {
+  public @Nullable String javadoc() {
     String result = javadoc;
     javadoc = null;
     if (result == null) {
diff --git a/java/com/google/turbine/parse/Token.java b/java/com/google/turbine/parse/Token.java
index 7d20beb..ec214a5 100644
--- a/java/com/google/turbine/parse/Token.java
+++ b/java/com/google/turbine/parse/Token.java
@@ -23,8 +23,8 @@
   RPAREN(")"),
   LBRACE("{"),
   RBRACE("}"),
-  LBRACK("<"),
-  RBRACK(">"),
+  LBRACK("["),
+  RBRACK("]"),
   EOF("<eof>"),
   SEMI(";"),
   COMMA(","),
diff --git a/java/com/google/turbine/parse/package-info.java b/java/com/google/turbine/parse/package-info.java
new file mode 100644
index 0000000..ace7dcf
--- /dev/null
+++ b/java/com/google/turbine/parse/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.parse;
diff --git a/java/com/google/turbine/processing/ModelFactory.java b/java/com/google/turbine/processing/ModelFactory.java
index 9b782cd..160d5ae 100644
--- a/java/com/google/turbine/processing/ModelFactory.java
+++ b/java/com/google/turbine/processing/ModelFactory.java
@@ -29,6 +29,7 @@
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.ParamInfo;
+import com.google.turbine.binder.bound.TypeBoundClass.RecordComponentInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.TyVarInfo;
 import com.google.turbine.binder.env.CompoundEnv;
 import com.google.turbine.binder.env.Env;
@@ -40,6 +41,7 @@
 import com.google.turbine.binder.sym.MethodSymbol;
 import com.google.turbine.binder.sym.PackageSymbol;
 import com.google.turbine.binder.sym.ParamSymbol;
+import com.google.turbine.binder.sym.RecordComponentSymbol;
 import com.google.turbine.binder.sym.Symbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
 import com.google.turbine.model.TurbineConstantTypeKind;
@@ -48,6 +50,7 @@
 import com.google.turbine.processing.TurbineElement.TurbineNoTypeElement;
 import com.google.turbine.processing.TurbineElement.TurbinePackageElement;
 import com.google.turbine.processing.TurbineElement.TurbineParameterElement;
+import com.google.turbine.processing.TurbineElement.TurbineRecordComponentElement;
 import com.google.turbine.processing.TurbineElement.TurbineTypeElement;
 import com.google.turbine.processing.TurbineElement.TurbineTypeParameterElement;
 import com.google.turbine.processing.TurbineTypeMirror.TurbineArrayType;
@@ -110,6 +113,8 @@
   private final Map<MethodSymbol, TurbineExecutableElement> methodCache = new HashMap<>();
   private final Map<ClassSymbol, TurbineTypeElement> classCache = new HashMap<>();
   private final Map<ParamSymbol, TurbineParameterElement> paramCache = new HashMap<>();
+  private final Map<RecordComponentSymbol, TurbineRecordComponentElement> recordComponentCache =
+      new HashMap<>();
   private final Map<TyVarSymbol, TurbineTypeParameterElement> tyParamCache = new HashMap<>();
   private final Map<PackageSymbol, TurbinePackageElement> packageCache = new HashMap<>();
 
@@ -230,6 +235,8 @@
         return fieldElement((FieldSymbol) symbol);
       case PARAMETER:
         return parameterElement((ParamSymbol) symbol);
+      case RECORD_COMPONENT:
+        return recordComponentElement((RecordComponentSymbol) symbol);
       case PACKAGE:
         return packageElement((PackageSymbol) symbol);
       case MODULE:
@@ -263,6 +270,11 @@
     return paramCache.computeIfAbsent(sym, k -> new TurbineParameterElement(this, sym));
   }
 
+  VariableElement recordComponentElement(RecordComponentSymbol sym) {
+    return recordComponentCache.computeIfAbsent(
+        sym, k -> new TurbineRecordComponentElement(this, sym));
+  }
+
   TurbineTypeParameterElement typeParameterElement(TyVarSymbol sym) {
     return tyParamCache.computeIfAbsent(sym, k -> new TurbineTypeParameterElement(this, sym));
   }
@@ -330,6 +342,16 @@
     return null;
   }
 
+  RecordComponentInfo getRecordComponentInfo(RecordComponentSymbol sym) {
+    TypeBoundClass info = getSymbol(sym.owner());
+    for (RecordComponentInfo component : info.components()) {
+      if (component.sym().equals(sym)) {
+        return component;
+      }
+    }
+    return null;
+  }
+
   FieldInfo getFieldInfo(FieldSymbol symbol) {
     TypeBoundClass info = getSymbol(symbol.owner());
     requireNonNull(info, symbol.owner().toString());
@@ -370,6 +392,8 @@
         return ((FieldSymbol) sym).owner();
       case PARAMETER:
         return ((ParamSymbol) sym).owner().owner();
+      case RECORD_COMPONENT:
+        return ((RecordComponentSymbol) sym).owner();
       case PACKAGE:
       case MODULE:
         throw new IllegalArgumentException(sym.toString());
diff --git a/java/com/google/turbine/processing/TurbineAnnotationMirror.java b/java/com/google/turbine/processing/TurbineAnnotationMirror.java
index df3bd19..f99d211 100644
--- a/java/com/google/turbine/processing/TurbineAnnotationMirror.java
+++ b/java/com/google/turbine/processing/TurbineAnnotationMirror.java
@@ -45,6 +45,7 @@
 import javax.lang.model.type.DeclaredType;
 import javax.lang.model.type.ErrorType;
 import javax.lang.model.type.TypeMirror;
+import org.jspecify.nullness.Nullable;
 
 /**
  * An implementation of {@link AnnotationMirror} and {@link AnnotationValue} backed by {@link
@@ -105,7 +106,7 @@
                   checkState(m.parameters().isEmpty());
                   result.put(m.name(), m);
                 }
-                return result.build();
+                return result.buildOrThrow();
               }
             });
     this.elementValues =
@@ -125,7 +126,7 @@
                       factory.executableElement(methodInfo.sym()),
                       annotationValue(factory, value.getValue()));
                 }
-                return result.build();
+                return result.buildOrThrow();
               }
             });
     this.elementValuesWithDefaults =
@@ -156,7 +157,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     return obj instanceof TurbineAnnotationMirror
         && anno.equals(((TurbineAnnotationMirror) obj).anno);
   }
@@ -342,7 +343,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbinePrimitiveConstant
           && value.equals(((TurbinePrimitiveConstant) obj).value);
     }
diff --git a/java/com/google/turbine/processing/TurbineElement.java b/java/com/google/turbine/processing/TurbineElement.java
index f4f1675..95f0f42 100644
--- a/java/com/google/turbine/processing/TurbineElement.java
+++ b/java/com/google/turbine/processing/TurbineElement.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -32,6 +33,7 @@
 import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.MethodInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.ParamInfo;
+import com.google.turbine.binder.bound.TypeBoundClass.RecordComponentInfo;
 import com.google.turbine.binder.bound.TypeBoundClass.TyVarInfo;
 import com.google.turbine.binder.lookup.PackageScope;
 import com.google.turbine.binder.sym.ClassSymbol;
@@ -39,6 +41,7 @@
 import com.google.turbine.binder.sym.MethodSymbol;
 import com.google.turbine.binder.sym.PackageSymbol;
 import com.google.turbine.binder.sym.ParamSymbol;
+import com.google.turbine.binder.sym.RecordComponentSymbol;
 import com.google.turbine.binder.sym.Symbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
 import com.google.turbine.diag.TurbineError;
@@ -79,9 +82,10 @@
 import javax.lang.model.element.TypeParameterElement;
 import javax.lang.model.element.VariableElement;
 import javax.lang.model.type.TypeMirror;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** An {@link Element} implementation backed by a {@link Symbol}. */
+@SuppressWarnings("nullness") // TODO(cushon): Address nullness diagnostics.
 public abstract class TurbineElement implements Element {
 
   public abstract Symbol sym();
@@ -92,7 +96,7 @@
   public abstract int hashCode();
 
   @Override
-  public abstract boolean equals(Object obj);
+  public abstract boolean equals(@Nullable Object obj);
 
   protected final ModelFactory factory;
   private final Supplier<ImmutableList<AnnotationMirror>> annotationMirrors;
@@ -259,6 +263,7 @@
                 switch (info.kind()) {
                   case CLASS:
                   case ENUM:
+                  case RECORD:
                     if (info.superclass() != null) {
                       return factory.asTypeMirror(info.superClassType());
                     }
@@ -375,10 +380,21 @@
           return ElementKind.ENUM;
         case ANNOTATION:
           return ElementKind.ANNOTATION_TYPE;
+        case RECORD:
+          return RECORD.get();
       }
       throw new AssertionError(info.kind());
     }
 
+    private static final Supplier<ElementKind> RECORD =
+        Suppliers.memoize(
+            new Supplier<ElementKind>() {
+              @Override
+              public ElementKind get() {
+                return ElementKind.valueOf("RECORD");
+              }
+            });
+
     @Override
     public Set<Modifier> getModifiers() {
       return asModifierSet(ModifierOwner.TYPE, infoNonNull().access() & ~TurbineFlag.ACC_SUPER);
@@ -426,6 +442,9 @@
               public ImmutableList<Element> get() {
                 TypeBoundClass info = infoNonNull();
                 ImmutableList.Builder<Element> result = ImmutableList.builder();
+                for (RecordComponentInfo component : info.components()) {
+                  result.add(factory.recordComponentElement(component.sym()));
+                }
                 for (FieldInfo field : info.fields()) {
                   result.add(factory.fieldElement(field.sym()));
                 }
@@ -464,7 +483,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineTypeElement && sym.equals(((TurbineTypeElement) obj).sym);
     }
 
@@ -552,7 +571,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineTypeParameterElement
           && sym.equals(((TurbineTypeParameterElement) obj).sym);
     }
@@ -573,8 +592,7 @@
               }
             });
 
-    @Nullable
-    private TyVarInfo info() {
+    private @Nullable TyVarInfo info() {
       return info.get();
     }
 
@@ -686,7 +704,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineExecutableElement
           && sym.equals(((TurbineExecutableElement) obj).sym);
     }
@@ -834,7 +852,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineFieldElement && sym.equals(((TurbineFieldElement) obj).sym);
     }
 
@@ -1032,6 +1050,7 @@
     public List<TurbineTypeElement> getEnclosedElements() {
       ImmutableSet.Builder<TurbineTypeElement> result = ImmutableSet.builder();
       PackageScope scope = factory.tli().lookupPackage(Splitter.on('/').split(sym.binaryName()));
+      requireNonNull(scope); // the current package exists
       for (ClassSymbol key : scope.classes()) {
         if (key.binaryName().contains("$") && factory.getSymbol(key).owner() != null) {
           // Skip member classes: only top-level classes are enclosed by the package.
@@ -1067,7 +1086,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbinePackageElement && sym.equals(((TurbinePackageElement) obj).sym);
     }
 
@@ -1112,7 +1131,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineParameterElement
           && sym.equals(((TurbineParameterElement) obj).sym);
     }
@@ -1198,6 +1217,120 @@
     }
   }
 
+  /** A {@link VariableElement} implementation for a record info. */
+  static class TurbineRecordComponentElement extends TurbineElement implements VariableElement {
+
+    @Override
+    public RecordComponentSymbol sym() {
+      return sym;
+    }
+
+    @Override
+    public String javadoc() {
+      return null;
+    }
+
+    @Override
+    public int hashCode() {
+      return sym.hashCode();
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+      return obj instanceof TurbineRecordComponentElement
+          && sym.equals(((TurbineRecordComponentElement) obj).sym);
+    }
+
+    private final RecordComponentSymbol sym;
+
+    private final Supplier<RecordComponentInfo> info =
+        memoize(
+            new Supplier<RecordComponentInfo>() {
+              @Override
+              public RecordComponentInfo get() {
+                return factory.getRecordComponentInfo(sym);
+              }
+            });
+
+    @Nullable
+    RecordComponentInfo info() {
+      return info.get();
+    }
+
+    public TurbineRecordComponentElement(ModelFactory factory, RecordComponentSymbol sym) {
+      super(factory);
+      this.sym = sym;
+    }
+
+    @Override
+    public Object getConstantValue() {
+      return null;
+    }
+
+    private final Supplier<TypeMirror> type =
+        memoize(
+            new Supplier<TypeMirror>() {
+              @Override
+              public TypeMirror get() {
+                return factory.asTypeMirror(info().type());
+              }
+            });
+
+    @Override
+    public TypeMirror asType() {
+      return type.get();
+    }
+
+    @Override
+    public ElementKind getKind() {
+      return RECORD_COMPONENT.get();
+    }
+
+    private static final Supplier<ElementKind> RECORD_COMPONENT =
+        Suppliers.memoize(
+            new Supplier<ElementKind>() {
+              @Override
+              public ElementKind get() {
+                return ElementKind.valueOf("RECORD_COMPONENT");
+              }
+            });
+
+    @Override
+    public Set<Modifier> getModifiers() {
+      return asModifierSet(ModifierOwner.PARAMETER, info().access());
+    }
+
+    @Override
+    public Name getSimpleName() {
+      return new TurbineName(sym.name());
+    }
+
+    @Override
+    public Element getEnclosingElement() {
+      return factory.typeElement(sym.owner());
+    }
+
+    @Override
+    public List<? extends Element> getEnclosedElements() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public <R, P> R accept(ElementVisitor<R, P> v, P p) {
+      return v.visitVariable(this, p);
+    }
+
+    @Override
+    public String toString() {
+      return String.valueOf(sym.name());
+    }
+
+    @Override
+    protected ImmutableList<AnnoInfo> annos() {
+      return info().annotations();
+    }
+  }
+
   static class TurbineNoTypeElement implements TypeElement {
 
     private final ModelFactory factory;
diff --git a/java/com/google/turbine/processing/TurbineElements.java b/java/com/google/turbine/processing/TurbineElements.java
index 7ede6e3..b5fd7f4 100644
--- a/java/com/google/turbine/processing/TurbineElements.java
+++ b/java/com/google/turbine/processing/TurbineElements.java
@@ -29,6 +29,7 @@
 import com.google.turbine.binder.sym.PackageSymbol;
 import com.google.turbine.binder.sym.Symbol;
 import com.google.turbine.model.Const;
+import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.model.TurbineVisibility;
 import com.google.turbine.processing.TurbineElement.TurbineExecutableElement;
 import com.google.turbine.processing.TurbineElement.TurbineFieldElement;
@@ -52,8 +53,10 @@
 import javax.lang.model.type.DeclaredType;
 import javax.lang.model.type.TypeMirror;
 import javax.lang.model.util.Elements;
+import org.jspecify.nullness.Nullable;
 
 /** An implementation of {@link Elements} backed by turbine's {@link Element}. */
+@SuppressWarnings("nullness") // TODO(cushon): Address nullness diagnostics.
 public class TurbineElements implements Elements {
 
   private final ModelFactory factory;
@@ -289,7 +292,89 @@
 
   @Override
   public boolean hides(Element hider, Element hidden) {
-    throw new UnsupportedOperationException();
+    if (!(hider instanceof TurbineElement)) {
+      throw new IllegalArgumentException(hider.toString());
+    }
+    if (!(hidden instanceof TurbineElement)) {
+      throw new IllegalArgumentException(hidden.toString());
+    }
+    return hides((TurbineElement) hider, (TurbineElement) hidden);
+  }
+
+  private boolean hides(TurbineElement hider, TurbineElement hidden) {
+    if (!hider.sym().symKind().equals(hidden.sym().symKind())) {
+      return false;
+    }
+    if (!hider.getSimpleName().equals(hidden.getSimpleName())) {
+      return false;
+    }
+    if (hider.sym().equals(hidden.sym())) {
+      return false;
+    }
+    if (!isVisibleForHiding(hider, hidden)) {
+      return false;
+    }
+    if (hider.sym().symKind().equals(Symbol.Kind.METHOD)) {
+      int access = ((TurbineExecutableElement) hider).info().access();
+      if ((access & TurbineFlag.ACC_STATIC) != TurbineFlag.ACC_STATIC) {
+        return false;
+      }
+      // Static interface methods shouldn't be able to hide static methods in super-interfaces,
+      // but include them anyways for bug-compatibility with javac, see:
+      // https://bugs.openjdk.java.net/browse/JDK-8275746
+      if (!types.isSubsignature(
+          (TurbineExecutableType) hider.asType(), (TurbineExecutableType) hidden.asType())) {
+        return false;
+      }
+    }
+    Element containingHider = containingClass(hider);
+    Element containingHidden = containingClass(hidden);
+    if (containingHider == null || containingHidden == null) {
+      return false;
+    }
+    if (!types.isSubtype(containingHider.asType(), containingHidden.asType())) {
+      return false;
+    }
+    return true;
+  }
+
+  private static @Nullable Element containingClass(TurbineElement element) {
+    Element enclosing = element.getEnclosingElement();
+    if (enclosing == null) {
+      return null;
+    }
+    if (!isClassOrInterface(enclosing.getKind())) {
+      // The immediately enclosing element of a field or method is a class. For classes, annotation
+      // processing only deals with top-level and nested (but not local or anonymous) classes,
+      // so the immediately enclosing element is either an enclosing class or a package symbol.
+      return null;
+    }
+    return enclosing;
+  }
+
+  private static boolean isClassOrInterface(ElementKind kind) {
+    return kind.isClass() || kind.isInterface();
+  }
+
+  private static boolean isVisibleForHiding(TurbineElement hider, TurbineElement hidden) {
+    int access;
+    switch (hidden.sym().symKind()) {
+      case CLASS:
+        access = ((TurbineTypeElement) hidden).info().access();
+        break;
+      case FIELD:
+        access = ((TurbineFieldElement) hidden).info().access();
+        break;
+      case METHOD:
+        access = ((TurbineExecutableElement) hidden).info().access();
+        break;
+      default:
+        return false;
+    }
+    return isVisible(
+        packageSymbol(asSymbol(hider)),
+        packageSymbol(asSymbol(hidden)),
+        TurbineVisibility.fromAccess(access));
   }
 
   @Override
diff --git a/java/com/google/turbine/processing/TurbineMessager.java b/java/com/google/turbine/processing/TurbineMessager.java
index 9c333b2..8e78b8b 100644
--- a/java/com/google/turbine/processing/TurbineMessager.java
+++ b/java/com/google/turbine/processing/TurbineMessager.java
@@ -42,7 +42,7 @@
 import javax.lang.model.element.AnnotationValue;
 import javax.lang.model.element.Element;
 import javax.tools.Diagnostic;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** Turbine's implementation of {@link Messager}. */
 public class TurbineMessager implements Messager {
@@ -103,8 +103,7 @@
    * Returns the {@link SourceFile} that contains the declaration of the given {@link Symbol}, or
    * {@code null} if the symbol was not compiled from source.
    */
-  @Nullable
-  private SourceFile getSource(Symbol sym) {
+  private @Nullable SourceFile getSource(Symbol sym) {
     ClassSymbol encl = ModelFactory.enclosingClass(sym);
     TypeBoundClass info = factory.getSymbol(encl);
     if (!(info instanceof SourceTypeBoundClass)) {
@@ -129,6 +128,10 @@
         return fieldPosition((FieldSymbol) sym);
       case PARAMETER:
         return paramPosition((ParamSymbol) sym);
+      case RECORD_COMPONENT:
+        // javac doesn't seem to provide diagnostic positions for record components, so we don't
+        // either
+        return -1;
       case MODULE:
       case PACKAGE:
         break;
diff --git a/java/com/google/turbine/processing/TurbineName.java b/java/com/google/turbine/processing/TurbineName.java
index 584b1b1..5232491 100644
--- a/java/com/google/turbine/processing/TurbineName.java
+++ b/java/com/google/turbine/processing/TurbineName.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import javax.lang.model.element.Name;
+import org.jspecify.nullness.Nullable;
 
 /** An implementation of {@link Name} backed by a {@link CharSequence}. */
 public class TurbineName implements Name {
@@ -61,7 +62,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     return obj instanceof TurbineName && contentEquals(((TurbineName) obj).name);
   }
 }
diff --git a/java/com/google/turbine/processing/TurbineProcessingEnvironment.java b/java/com/google/turbine/processing/TurbineProcessingEnvironment.java
index 8b44e75..4f32033 100644
--- a/java/com/google/turbine/processing/TurbineProcessingEnvironment.java
+++ b/java/com/google/turbine/processing/TurbineProcessingEnvironment.java
@@ -24,7 +24,7 @@
 import javax.lang.model.SourceVersion;
 import javax.lang.model.util.Elements;
 import javax.lang.model.util.Types;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** Turbine's {@link ProcessingEnvironment}. */
 public class TurbineProcessingEnvironment implements ProcessingEnvironment {
diff --git a/java/com/google/turbine/processing/TurbineTypeMirror.java b/java/com/google/turbine/processing/TurbineTypeMirror.java
index e94672c..4cd8ba1 100644
--- a/java/com/google/turbine/processing/TurbineTypeMirror.java
+++ b/java/com/google/turbine/processing/TurbineTypeMirror.java
@@ -58,6 +58,7 @@
 import javax.lang.model.type.TypeVariable;
 import javax.lang.model.type.TypeVisitor;
 import javax.lang.model.type.WildcardType;
+import org.jspecify.nullness.Nullable;
 
 /** A {@link TypeMirror} implementation backed by a {@link Type}. */
 public abstract class TurbineTypeMirror implements TypeMirror {
@@ -165,7 +166,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineDeclaredType && type.equals(((TurbineDeclaredType) obj).type);
     }
 
@@ -377,7 +378,7 @@
     }
 
     @Override
-    public boolean equals(Object other) {
+    public boolean equals(@Nullable Object other) {
       return other instanceof TurbinePackageType
           && symbol.equals(((TurbinePackageType) other).symbol);
     }
@@ -421,7 +422,7 @@
     }
 
     @Override
-    public boolean equals(Object other) {
+    public boolean equals(@Nullable Object other) {
       return other instanceof TurbineNoType;
     }
 
@@ -473,7 +474,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineTypeVariable && type.equals(((TurbineTypeVariable) obj).type);
     }
 
@@ -566,7 +567,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineWildcardType && type.equals(((TurbineWildcardType) obj).type);
     }
 
@@ -607,7 +608,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineIntersectionType
           && type.equals(((TurbineIntersectionType) obj).type);
     }
@@ -670,7 +671,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof NullType;
     }
 
@@ -711,7 +712,7 @@
     }
 
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(@Nullable Object obj) {
       return obj instanceof TurbineExecutableType
           && type.equals(((TurbineExecutableType) obj).type);
     }
diff --git a/java/com/google/turbine/processing/TurbineTypes.java b/java/com/google/turbine/processing/TurbineTypes.java
index 7d2e6c0..d2068dd 100644
--- a/java/com/google/turbine/processing/TurbineTypes.java
+++ b/java/com/google/turbine/processing/TurbineTypes.java
@@ -29,6 +29,7 @@
 import com.google.turbine.binder.sym.FieldSymbol;
 import com.google.turbine.binder.sym.MethodSymbol;
 import com.google.turbine.binder.sym.ParamSymbol;
+import com.google.turbine.binder.sym.RecordComponentSymbol;
 import com.google.turbine.binder.sym.Symbol;
 import com.google.turbine.binder.sym.TyVarSymbol;
 import com.google.turbine.model.TurbineConstantTypeKind;
@@ -68,9 +69,10 @@
 import javax.lang.model.type.TypeMirror;
 import javax.lang.model.type.WildcardType;
 import javax.lang.model.util.Types;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** An implementation of {@link Types} backed by turbine's {@link TypeMirror}. */
+@SuppressWarnings("nullness") // TODO(cushon): Address nullness diagnostics.
 public class TurbineTypes implements Types {
 
   private final ModelFactory factory;
@@ -217,8 +219,15 @@
     if (bounds.isEmpty()) {
       return true;
     }
-    ClassTy first = (ClassTy) bounds.get(0);
-    return factory.getSymbol(first.sym()).kind().equals(TurbineTyKind.INTERFACE);
+    Type bound = bounds.get(0);
+    switch (bound.tyKind()) {
+      case TY_VAR:
+        return false;
+      case CLASS_TY:
+        return factory.getSymbol(((ClassTy) bound).sym()).kind().equals(TurbineTyKind.INTERFACE);
+      default:
+        throw new AssertionError(bound.tyKind());
+    }
   }
 
   private boolean isSameWildType(WildTy a, Type other) {
@@ -364,8 +373,8 @@
   }
 
   private boolean isTyVarSubtype(TyVar a, Type b, boolean strict) {
-    if (b.tyKind() == TyKind.TY_VAR) {
-      return a.sym().equals(((TyVar) b).sym());
+    if (b.tyKind() == TyKind.TY_VAR && a.sym().equals(((TyVar) b).sym())) {
+      return true;
     }
     TyVarInfo tyVarInfo = factory.getTyVarInfo(a.sym());
     return isSubtype(tyVarInfo.upperBound(), b, strict);
@@ -520,11 +529,12 @@
   }
 
   /**
-   * Given two parameterizations of the same {@link SimpleClassTy}, {@code a} and {@code b}, teturns
+   * Given two parameterizations of the same {@link SimpleClassTy}, {@code a} and {@code b}, returns
    * true if the type arguments of {@code a} are pairwise contained by the type arguments of {@code
    * b}.
    *
-   * @see {@link #contains} and JLS 4.5.1.
+   * @see #contains
+   * @see "JLS 4.5.1"
    */
   private boolean tyArgsContains(SimpleClassTy a, SimpleClassTy b, boolean strict) {
     verify(a.sym().equals(b.sym()));
@@ -624,8 +634,7 @@
    * Returns a mapping that can be used to adapt the signature 'b' to the type parameters of 'a', or
    * {@code null} if no such mapping exists.
    */
-  @Nullable
-  private static ImmutableMap<TyVarSymbol, Type> getMapping(MethodTy a, MethodTy b) {
+  private static @Nullable ImmutableMap<TyVarSymbol, Type> getMapping(MethodTy a, MethodTy b) {
     if (a.tyParams().size() != b.tyParams().size()) {
       return null;
     }
@@ -637,15 +646,14 @@
       TyVarSymbol t = bx.next();
       mapping.put(t, TyVar.create(s, ImmutableList.of()));
     }
-    return mapping.build();
+    return mapping.buildOrThrow();
   }
 
   /**
    * Returns a map from formal type parameters to their arguments for a given class type, or an
    * empty map for non-parameterized types, or {@code null} for raw types.
    */
-  @Nullable
-  private ImmutableMap<TyVarSymbol, Type> getMapping(ClassTy ty) {
+  private @Nullable ImmutableMap<TyVarSymbol, Type> getMapping(ClassTy ty) {
     ImmutableMap.Builder<TyVarSymbol, Type> mapping = ImmutableMap.builder();
     for (SimpleClassTy s : ty.classes()) {
       TypeBoundClass info = factory.getSymbol(s.sym());
@@ -659,7 +667,7 @@
       }
       verify(!bx.hasNext());
     }
-    return mapping.build();
+    return mapping.buildOrThrow();
   }
 
   @Override
@@ -1131,6 +1139,8 @@
         return ((FieldSymbol) symbol).owner();
       case PARAMETER:
         return ((ParamSymbol) symbol).owner().owner();
+      case RECORD_COMPONENT:
+        return ((RecordComponentSymbol) symbol).owner();
       case MODULE:
       case PACKAGE:
         throw new IllegalArgumentException(symbol.symKind().toString());
diff --git a/java/com/google/turbine/processing/package-info.java b/java/com/google/turbine/processing/package-info.java
new file mode 100644
index 0000000..abf6732
--- /dev/null
+++ b/java/com/google/turbine/processing/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.processing;
diff --git a/java/com/google/turbine/tree/Pretty.java b/java/com/google/turbine/tree/Pretty.java
index b693a42..4ebc04f 100644
--- a/java/com/google/turbine/tree/Pretty.java
+++ b/java/com/google/turbine/tree/Pretty.java
@@ -20,6 +20,8 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.tree.Tree.Anno;
 import com.google.turbine.tree.Tree.ClassLiteral;
 import com.google.turbine.tree.Tree.Ident;
@@ -33,9 +35,10 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import org.jspecify.nullness.Nullable;
 
 /** A pretty-printer for {@link Tree}s. */
-public class Pretty implements Tree.Visitor<Void, Void> {
+public class Pretty implements Tree.Visitor<@Nullable Void, @Nullable Void> {
 
   static String pretty(Tree tree) {
     Pretty pretty = new Pretty();
@@ -60,6 +63,7 @@
     newLine = true;
   }
 
+  @CanIgnoreReturnValue
   Pretty append(char c) {
     if (c == '\n') {
       newLine = true;
@@ -71,6 +75,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   Pretty append(String s) {
     if (newLine) {
       sb.append(Strings.repeat(" ", indent * 2));
@@ -81,13 +86,13 @@
   }
 
   @Override
-  public Void visitIdent(Ident ident, Void input) {
+  public @Nullable Void visitIdent(Ident ident, @Nullable Void input) {
     sb.append(ident.value());
     return null;
   }
 
   @Override
-  public Void visitWildTy(Tree.WildTy wildTy, Void input) {
+  public @Nullable Void visitWildTy(Tree.WildTy wildTy, @Nullable Void input) {
     printAnnos(wildTy.annos());
     append('?');
     if (wildTy.lower().isPresent()) {
@@ -102,7 +107,7 @@
   }
 
   @Override
-  public Void visitArrTy(Tree.ArrTy arrTy, Void input) {
+  public @Nullable Void visitArrTy(Tree.ArrTy arrTy, @Nullable Void input) {
     arrTy.elem().accept(this, null);
     if (!arrTy.annos().isEmpty()) {
       append(' ');
@@ -113,19 +118,19 @@
   }
 
   @Override
-  public Void visitPrimTy(Tree.PrimTy primTy, Void input) {
+  public @Nullable Void visitPrimTy(Tree.PrimTy primTy, @Nullable Void input) {
     append(primTy.tykind().toString());
     return null;
   }
 
   @Override
-  public Void visitVoidTy(Tree.VoidTy primTy, Void input) {
+  public @Nullable Void visitVoidTy(Tree.VoidTy voidTy, @Nullable Void input) {
     append("void");
     return null;
   }
 
   @Override
-  public Void visitClassTy(Tree.ClassTy classTy, Void input) {
+  public @Nullable Void visitClassTy(Tree.ClassTy classTy, @Nullable Void input) {
     if (classTy.base().isPresent()) {
       classTy.base().get().accept(this, null);
       append('.');
@@ -148,13 +153,19 @@
   }
 
   @Override
-  public Void visitLiteral(Tree.Literal literal, Void input) {
+  public @Nullable Void visitLiteral(Tree.Literal literal, @Nullable Void input) {
     append(literal.value().toString());
     return null;
   }
 
   @Override
-  public Void visitTypeCast(Tree.TypeCast typeCast, Void input) {
+  public @Nullable Void visitParen(Tree.Paren paren, @Nullable Void input) {
+    paren.expr().accept(this, null);
+    return null;
+  }
+
+  @Override
+  public @Nullable Void visitTypeCast(Tree.TypeCast typeCast, @Nullable Void input) {
     append('(');
     typeCast.ty().accept(this, null);
     append(") ");
@@ -163,7 +174,7 @@
   }
 
   @Override
-  public Void visitUnary(Tree.Unary unary, Void input) {
+  public @Nullable Void visitUnary(Tree.Unary unary, @Nullable Void input) {
     switch (unary.op()) {
       case POST_INCR:
       case POST_DECR:
@@ -186,37 +197,42 @@
   }
 
   @Override
-  public Void visitBinary(Tree.Binary binary, Void input) {
+  public @Nullable Void visitBinary(Tree.Binary binary, @Nullable Void input) {
     append('(');
-    binary.lhs().accept(this, null);
-    append(" " + binary.op() + " ");
-    binary.rhs().accept(this, null);
+    boolean first = true;
+    for (Tree child : binary.children()) {
+      if (!first) {
+        append(" ").append(binary.op().toString()).append(" ");
+      }
+      child.accept(this, null);
+      first = false;
+    }
     append(')');
     return null;
   }
 
   @Override
-  public Void visitConstVarName(Tree.ConstVarName constVarName, Void input) {
+  public @Nullable Void visitConstVarName(Tree.ConstVarName constVarName, @Nullable Void input) {
     append(Joiner.on('.').join(constVarName.name()));
     return null;
   }
 
   @Override
-  public Void visitClassLiteral(ClassLiteral classLiteral, Void input) {
-    classLiteral.accept(this, input);
+  public @Nullable Void visitClassLiteral(ClassLiteral classLiteral, @Nullable Void input) {
+    classLiteral.type().accept(this, input);
     append(".class");
     return null;
   }
 
   @Override
-  public Void visitAssign(Tree.Assign assign, Void input) {
+  public @Nullable Void visitAssign(Tree.Assign assign, @Nullable Void input) {
     append(assign.name().value()).append(" = ");
     assign.expr().accept(this, null);
     return null;
   }
 
   @Override
-  public Void visitConditional(Tree.Conditional conditional, Void input) {
+  public @Nullable Void visitConditional(Tree.Conditional conditional, @Nullable Void input) {
     append("(");
     conditional.cond().accept(this, null);
     append(" ? ");
@@ -228,7 +244,7 @@
   }
 
   @Override
-  public Void visitArrayInit(Tree.ArrayInit arrayInit, Void input) {
+  public @Nullable Void visitArrayInit(Tree.ArrayInit arrayInit, @Nullable Void input) {
     append('{');
     boolean first = true;
     for (Tree.Expression e : arrayInit.exprs()) {
@@ -243,7 +259,7 @@
   }
 
   @Override
-  public Void visitCompUnit(Tree.CompUnit compUnit, Void input) {
+  public @Nullable Void visitCompUnit(Tree.CompUnit compUnit, @Nullable Void input) {
     if (compUnit.pkg().isPresent()) {
       compUnit.pkg().get().accept(this, null);
       printLine();
@@ -263,7 +279,7 @@
   }
 
   @Override
-  public Void visitImportDecl(Tree.ImportDecl importDecl, Void input) {
+  public @Nullable Void visitImportDecl(Tree.ImportDecl importDecl, @Nullable Void input) {
     append("import ");
     if (importDecl.stat()) {
       append("static ");
@@ -277,7 +293,7 @@
   }
 
   @Override
-  public Void visitVarDecl(Tree.VarDecl varDecl, Void input) {
+  public @Nullable Void visitVarDecl(Tree.VarDecl varDecl, @Nullable Void input) {
     printVarDecl(varDecl);
     append(';');
     return null;
@@ -302,7 +318,7 @@
   }
 
   @Override
-  public Void visitMethDecl(Tree.MethDecl methDecl, Void input) {
+  public @Nullable Void visitMethDecl(Tree.MethDecl methDecl, @Nullable Void input) {
     for (Tree.Anno anno : methDecl.annos()) {
       anno.accept(this, null);
       printLine();
@@ -361,7 +377,7 @@
   }
 
   @Override
-  public Void visitAnno(Tree.Anno anno, Void input) {
+  public @Nullable Void visitAnno(Tree.Anno anno, @Nullable Void input) {
     append('@');
     append(Joiner.on('.').join(anno.name()));
     if (!anno.args().isEmpty()) {
@@ -380,7 +396,7 @@
   }
 
   @Override
-  public Void visitTyDecl(Tree.TyDecl tyDecl, Void input) {
+  public @Nullable Void visitTyDecl(Tree.TyDecl tyDecl, @Nullable Void input) {
     for (Tree.Anno anno : tyDecl.annos()) {
       anno.accept(this, null);
       printLine();
@@ -399,6 +415,9 @@
       case ANNOTATION:
         append("@interface");
         break;
+      case RECORD:
+        append("record");
+        break;
     }
     append(' ').append(tyDecl.name().value());
     if (!tyDecl.typarams().isEmpty()) {
@@ -413,6 +432,18 @@
       }
       append('>');
     }
+    if (tyDecl.tykind().equals(TurbineTyKind.RECORD)) {
+      append("(");
+      boolean first = true;
+      for (Tree.VarDecl c : tyDecl.components()) {
+        if (!first) {
+          append(", ");
+        }
+        printVarDecl(c);
+        first = false;
+      }
+      append(")");
+    }
     if (tyDecl.xtnds().isPresent()) {
       append(" extends ");
       tyDecl.xtnds().get().accept(this, null);
@@ -428,6 +459,17 @@
         first = false;
       }
     }
+    if (!tyDecl.permits().isEmpty()) {
+      append(" permits ");
+      boolean first = true;
+      for (Tree.ClassTy t : tyDecl.permits()) {
+        if (!first) {
+          append(", ");
+        }
+        t.accept(this, null);
+        first = false;
+      }
+    }
     append(" {").append('\n');
     indent++;
     switch (tyDecl.tykind()) {
@@ -491,6 +533,8 @@
         case TRANSIENT:
         case DEFAULT:
         case TRANSITIVE:
+        case SEALED:
+        case NON_SEALED:
           append(mod.toString()).append(' ');
           break;
         case ACC_SUPER:
@@ -500,13 +544,14 @@
         case ACC_ANNOTATION:
         case ACC_SYNTHETIC:
         case ACC_BRIDGE:
+        case COMPACT_CTOR:
           break;
       }
     }
   }
 
   @Override
-  public Void visitTyParam(Tree.TyParam tyParam, Void input) {
+  public @Nullable Void visitTyParam(Tree.TyParam tyParam, @Nullable Void input) {
     printAnnos(tyParam.annos());
     append(tyParam.name().value());
     if (!tyParam.bounds().isEmpty()) {
@@ -524,7 +569,7 @@
   }
 
   @Override
-  public Void visitPkgDecl(Tree.PkgDecl pkgDecl, Void input) {
+  public @Nullable Void visitPkgDecl(Tree.PkgDecl pkgDecl, @Nullable Void input) {
     for (Tree.Anno anno : pkgDecl.annos()) {
       anno.accept(this, null);
       printLine();
@@ -534,7 +579,7 @@
   }
 
   @Override
-  public Void visitModDecl(ModDecl modDecl, Void input) {
+  public @Nullable Void visitModDecl(ModDecl modDecl, @Nullable Void input) {
     for (Tree.Anno anno : modDecl.annos()) {
       anno.accept(this, null);
       printLine();
@@ -554,7 +599,7 @@
   }
 
   @Override
-  public Void visitModRequires(ModRequires modRequires, Void input) {
+  public @Nullable Void visitModRequires(ModRequires modRequires, @Nullable Void input) {
     append("requires ");
     printModifiers(modRequires.mods());
     append(modRequires.moduleName());
@@ -564,7 +609,7 @@
   }
 
   @Override
-  public Void visitModExports(ModExports modExports, Void input) {
+  public @Nullable Void visitModExports(ModExports modExports, @Nullable Void input) {
     append("exports ");
     append(modExports.packageName().replace('/', '.'));
     if (!modExports.moduleNames().isEmpty()) {
@@ -586,7 +631,7 @@
   }
 
   @Override
-  public Void visitModOpens(ModOpens modOpens, Void input) {
+  public @Nullable Void visitModOpens(ModOpens modOpens, @Nullable Void input) {
     append("opens ");
     append(modOpens.packageName().replace('/', '.'));
     if (!modOpens.moduleNames().isEmpty()) {
@@ -608,7 +653,7 @@
   }
 
   @Override
-  public Void visitModUses(ModUses modUses, Void input) {
+  public @Nullable Void visitModUses(ModUses modUses, @Nullable Void input) {
     append("uses ");
     append(Joiner.on('.').join(modUses.typeName()));
     append(";");
@@ -617,7 +662,7 @@
   }
 
   @Override
-  public Void visitModProvides(ModProvides modProvides, Void input) {
+  public @Nullable Void visitModProvides(ModProvides modProvides, @Nullable Void input) {
     append("provides ");
     append(Joiner.on('.').join(modProvides.typeName()));
     if (!modProvides.implNames().isEmpty()) {
diff --git a/java/com/google/turbine/tree/Tree.java b/java/com/google/turbine/tree/Tree.java
index d36c3ab..f7917b9 100644
--- a/java/com/google/turbine/tree/Tree.java
+++ b/java/com/google/turbine/tree/Tree.java
@@ -25,15 +25,19 @@
 import com.google.turbine.model.Const;
 import com.google.turbine.model.TurbineConstantTypeKind;
 import com.google.turbine.model.TurbineTyKind;
+import java.util.ArrayDeque;
+import java.util.Deque;
 import java.util.Optional;
 import java.util.Set;
+import org.jspecify.nullness.Nullable;
 
 /** An AST node. */
 public abstract class Tree {
 
   public abstract Kind kind();
 
-  public abstract <I, O> O accept(Visitor<I, O> visitor, I input);
+  public abstract <I extends @Nullable Object, O extends @Nullable Object> O accept(
+      Visitor<I, O> visitor, I input);
 
   private final int position;
 
@@ -59,6 +63,7 @@
     VOID_TY,
     CLASS_TY,
     LITERAL,
+    PAREN,
     TYPE_CAST,
     UNARY,
     BINARY,
@@ -101,7 +106,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitIdent(this, input);
     }
 
@@ -154,7 +160,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitWildTy(this, input);
     }
 
@@ -192,7 +199,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitArrTy(this, input);
     }
 
@@ -221,7 +229,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitPrimTy(this, input);
     }
 
@@ -240,7 +249,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitVoidTy(this, input);
     }
 
@@ -273,7 +283,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitClassTy(this, input);
     }
 
@@ -314,7 +325,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitLiteral(this, input);
     }
 
@@ -327,6 +339,31 @@
     }
   }
 
+  /** A JLS 15.8.5 parenthesized expression. */
+  public static class Paren extends Expression {
+    private final Expression expr;
+
+    public Paren(int position, Expression expr) {
+      super(position);
+      this.expr = expr;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.PAREN;
+    }
+
+    @Override
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
+      return visitor.visitParen(this, input);
+    }
+
+    public Expression expr() {
+      return expr;
+    }
+  }
+
   /** A JLS 15.16 cast expression. */
   public static class TypeCast extends Expression {
     private final Type ty;
@@ -344,7 +381,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitTypeCast(this, input);
     }
 
@@ -374,7 +412,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitUnary(this, input);
     }
 
@@ -406,16 +445,29 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitBinary(this, input);
     }
 
-    public Expression lhs() {
-      return lhs;
-    }
-
-    public Expression rhs() {
-      return rhs;
+    public Iterable<Expression> children() {
+      ImmutableList.Builder<Expression> children = ImmutableList.builder();
+      Deque<Expression> stack = new ArrayDeque<>();
+      stack.addFirst(rhs);
+      stack.addFirst(lhs);
+      while (!stack.isEmpty()) {
+        Expression curr = stack.removeFirst();
+        if (curr.kind().equals(Kind.BINARY)) {
+          Binary b = ((Binary) curr);
+          if (b.op().equals(op())) {
+            stack.addFirst(b.rhs);
+            stack.addFirst(b.lhs);
+            continue;
+          }
+        }
+        children.add(curr);
+      }
+      return children.build();
     }
 
     public TurbineOperatorKind op() {
@@ -438,7 +490,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitConstVarName(this, input);
     }
 
@@ -463,7 +516,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitClassLiteral(this, input);
     }
 
@@ -489,7 +543,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitAssign(this, input);
     }
 
@@ -521,7 +576,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitConditional(this, input);
     }
 
@@ -553,7 +609,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitArrayInit(this, input);
     }
 
@@ -591,7 +648,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitCompUnit(this, input);
     }
 
@@ -635,7 +693,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitImportDecl(this, input);
     }
 
@@ -661,7 +720,7 @@
     private final Tree ty;
     private final Ident name;
     private final Optional<Expression> init;
-    private final String javadoc;
+    private final @Nullable String javadoc;
 
     public VarDecl(
         int position,
@@ -670,7 +729,7 @@
         Tree ty,
         Ident name,
         Optional<Expression> init,
-        String javadoc) {
+        @Nullable String javadoc) {
       super(position);
       this.mods = ImmutableSet.copyOf(mods);
       this.annos = annos;
@@ -686,7 +745,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitVarDecl(this, input);
     }
 
@@ -714,7 +774,7 @@
      * A javadoc comment, excluding the opening and closing delimiters but including all interior
      * characters and whitespace.
      */
-    public String javadoc() {
+    public @Nullable String javadoc() {
       return javadoc;
     }
   }
@@ -760,7 +820,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitMethDecl(this, input);
     }
 
@@ -821,7 +882,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitAnno(this, input);
     }
 
@@ -858,7 +920,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitAnno(value, input);
     }
   }
@@ -871,9 +934,11 @@
     private final ImmutableList<TyParam> typarams;
     private final Optional<ClassTy> xtnds;
     private final ImmutableList<ClassTy> impls;
+    private final ImmutableList<ClassTy> permits;
     private final ImmutableList<Tree> members;
+    private final ImmutableList<VarDecl> components;
     private final TurbineTyKind tykind;
-    private final String javadoc;
+    private final @Nullable String javadoc;
 
     public TyDecl(
         int position,
@@ -883,9 +948,11 @@
         ImmutableList<TyParam> typarams,
         Optional<ClassTy> xtnds,
         ImmutableList<ClassTy> impls,
+        ImmutableList<ClassTy> permits,
         ImmutableList<Tree> members,
+        ImmutableList<VarDecl> components,
         TurbineTyKind tykind,
-        String javadoc) {
+        @Nullable String javadoc) {
       super(position);
       this.mods = ImmutableSet.copyOf(mods);
       this.annos = annos;
@@ -893,7 +960,9 @@
       this.typarams = typarams;
       this.xtnds = xtnds;
       this.impls = impls;
+      this.permits = permits;
       this.members = members;
+      this.components = components;
       this.tykind = tykind;
       this.javadoc = javadoc;
     }
@@ -904,7 +973,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitTyDecl(this, input);
     }
 
@@ -932,10 +1002,18 @@
       return impls;
     }
 
+    public ImmutableList<ClassTy> permits() {
+      return permits;
+    }
+
     public ImmutableList<Tree> members() {
       return members;
     }
 
+    public ImmutableList<VarDecl> components() {
+      return components;
+    }
+
     public TurbineTyKind tykind() {
       return tykind;
     }
@@ -943,7 +1021,7 @@
      * A javadoc comment, excluding the opening and closing delimiters but including all interior
      * characters and whitespace.
      */
-    public String javadoc() {
+    public @Nullable String javadoc() {
       return javadoc;
     }
   }
@@ -968,7 +1046,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitTyParam(this, input);
     }
 
@@ -1002,7 +1081,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitPkgDecl(this, input);
     }
 
@@ -1058,7 +1138,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitModDecl(this, input);
     }
   }
@@ -1094,7 +1175,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitModRequires(this, input);
     }
 
@@ -1130,7 +1212,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitModExports(this, input);
     }
 
@@ -1180,7 +1263,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitModOpens(this, input);
     }
 
@@ -1210,7 +1294,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitModUses(this, input);
     }
 
@@ -1249,7 +1334,8 @@
     }
 
     @Override
-    public <I, O> O accept(Visitor<I, O> visitor, I input) {
+    public <I extends @Nullable Object, O extends @Nullable Object> O accept(
+        Visitor<I, O> visitor, I input) {
       return visitor.visitModProvides(this, input);
     }
 
@@ -1260,7 +1346,7 @@
   }
 
   /** A visitor for {@link Tree}s. */
-  public interface Visitor<I, O> {
+  public interface Visitor<I extends @Nullable Object, O extends @Nullable Object> {
     O visitIdent(Ident ident, I input);
 
     O visitWildTy(WildTy visitor, I input);
@@ -1275,6 +1361,8 @@
 
     O visitLiteral(Literal literal, I input);
 
+    O visitParen(Paren unary, I input);
+
     O visitTypeCast(TypeCast typeCast, I input);
 
     O visitUnary(Unary unary, I input);
diff --git a/java/com/google/turbine/tree/TurbineModifier.java b/java/com/google/turbine/tree/TurbineModifier.java
index 35dc11c..2bfe53e 100644
--- a/java/com/google/turbine/tree/TurbineModifier.java
+++ b/java/com/google/turbine/tree/TurbineModifier.java
@@ -45,7 +45,10 @@
   ACC_SYNTHETIC(TurbineFlag.ACC_SYNTHETIC),
   ACC_BRIDGE(TurbineFlag.ACC_BRIDGE),
   DEFAULT(TurbineFlag.ACC_DEFAULT),
-  TRANSITIVE(TurbineFlag.ACC_TRANSITIVE);
+  TRANSITIVE(TurbineFlag.ACC_TRANSITIVE),
+  SEALED(TurbineFlag.ACC_SEALED),
+  NON_SEALED(TurbineFlag.ACC_NON_SEALED),
+  COMPACT_CTOR(TurbineFlag.ACC_COMPACT_CTOR);
 
   private final int flag;
 
@@ -59,6 +62,6 @@
 
   @Override
   public String toString() {
-    return name().toLowerCase(ENGLISH);
+    return name().replace('_', '-').toLowerCase(ENGLISH);
   }
 }
diff --git a/java/com/google/turbine/tree/package-info.java b/java/com/google/turbine/tree/package-info.java
new file mode 100644
index 0000000..2803c67
--- /dev/null
+++ b/java/com/google/turbine/tree/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+@org.jspecify.nullness.NullMarked
+package com.google.turbine.tree;
diff --git a/java/com/google/turbine/type/AnnoInfo.java b/java/com/google/turbine/type/AnnoInfo.java
index ff902b3..d42af5c 100644
--- a/java/com/google/turbine/type/AnnoInfo.java
+++ b/java/com/google/turbine/type/AnnoInfo.java
@@ -29,6 +29,7 @@
 import com.google.turbine.tree.Tree.Expression;
 import java.util.Map;
 import java.util.Objects;
+import org.jspecify.nullness.Nullable;
 
 /** An annotation use. */
 public class AnnoInfo {
@@ -84,7 +85,7 @@
   }
 
   @Override
-  public boolean equals(Object obj) {
+  public boolean equals(@Nullable Object obj) {
     if (!(obj instanceof AnnoInfo)) {
       return false;
     }
diff --git a/java/com/google/turbine/type/Type.java b/java/com/google/turbine/type/Type.java
index bdddc6c..085346a 100644
--- a/java/com/google/turbine/type/Type.java
+++ b/java/com/google/turbine/type/Type.java
@@ -32,7 +32,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /** JLS 4 types. */
 public interface Type {
@@ -194,7 +194,7 @@
     }
 
     @Override
-    public final boolean equals(Object obj) {
+    public final boolean equals(@Nullable Object obj) {
       if (!(obj instanceof ClassTy)) {
         return false;
       }
@@ -491,8 +491,7 @@
     public abstract Type returnType();
 
     /** The type of the receiver parameter (see JLS 8.4.1). */
-    @Nullable
-    public abstract Type receiverType();
+    public abstract @Nullable Type receiverType();
 
     public abstract ImmutableList<Type> parameters();
 
@@ -577,7 +576,7 @@
     }
 
     @Override
-    public final boolean equals(Object other) {
+    public final boolean equals(@Nullable Object other) {
       // The name associated with an error type is context for use in diagnostics or by annotations
       // processors. Two error types with the same name don't necessarily represent the same type.
 
diff --git a/java/com/google/turbine/type/package-info.java b/java/com/google/turbine/type/package-info.java
new file mode 100644
index 0000000..2329130
--- /dev/null
+++ b/java/com/google/turbine/type/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.type;
diff --git a/java/com/google/turbine/types/Canonicalize.java b/java/com/google/turbine/types/Canonicalize.java
index 22df069..f944bb5 100644
--- a/java/com/google/turbine/types/Canonicalize.java
+++ b/java/com/google/turbine/types/Canonicalize.java
@@ -16,6 +16,8 @@
 
 package com.google.turbine.types;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.base.Verify;
 import com.google.common.collect.ImmutableList;
 import com.google.turbine.binder.bound.TypeBoundClass;
@@ -44,7 +46,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 
 /**
  * Canonicalizes qualified type names so qualifiers are always the declaring class of the qualified
@@ -208,7 +210,8 @@
       return ClassTy.create(ImmutableList.of(ty));
     }
     ImmutableList.Builder<ClassTy.SimpleClassTy> simples = ImmutableList.builder();
-    ClassSymbol owner = getInfo(ty.sym()).owner();
+    // this inner class is known to have an owner
+    ClassSymbol owner = requireNonNull(getInfo(ty.sym()).owner());
     if (owner.equals(base.sym())) {
       // if the canonical prefix is the owner the next symbol in the qualified name,
       // the type is already in canonical form
@@ -281,7 +284,7 @@
   }
 
   /** Instantiates a type argument using the given mapping. */
-  private static Type instantiate(Map<TyVarSymbol, Type> mapping, Type type) {
+  private static @Nullable Type instantiate(Map<TyVarSymbol, Type> mapping, Type type) {
     if (type == null) {
       return null;
     }
@@ -328,7 +331,8 @@
     for (SimpleClassTy simple : type.classes()) {
       ImmutableList.Builder<Type> args = ImmutableList.builder();
       for (Type arg : simple.targs()) {
-        args.add(instantiate(mapping, arg));
+        // result is non-null if arg is
+        args.add(requireNonNull(instantiate(mapping, arg)));
       }
       simples.add(SimpleClassTy.create(simple.sym(), args.build(), simple.annos()));
     }
@@ -339,8 +343,7 @@
    * Returns the type variable symbol for a concrete type argument whose type is a type variable
    * reference, or else {@code null}.
    */
-  @Nullable
-  private static TyVarSymbol tyVarSym(Type type) {
+  private static @Nullable TyVarSymbol tyVarSym(Type type) {
     if (type.tyKind() == TyKind.TY_VAR) {
       return ((TyVar) type).sym();
     }
diff --git a/java/com/google/turbine/types/package-info.java b/java/com/google/turbine/types/package-info.java
new file mode 100644
index 0000000..fd541d7
--- /dev/null
+++ b/java/com/google/turbine/types/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.types;
diff --git a/java/com/google/turbine/zip/package-info.java b/java/com/google/turbine/zip/package-info.java
new file mode 100644
index 0000000..069e5e1
--- /dev/null
+++ b/java/com/google/turbine/zip/package-info.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@com.google.errorprone.annotations.CheckReturnValue
+package com.google.turbine.zip;
diff --git a/javatests/com/google/turbine/binder/BinderErrorTest.java b/javatests/com/google/turbine/binder/BinderErrorTest.java
index e6e30cb..6766470 100644
--- a/javatests/com/google/turbine/binder/BinderErrorTest.java
+++ b/javatests/com/google/turbine/binder/BinderErrorTest.java
@@ -771,12 +771,6 @@
           "<>:3: error: missing required annotation argument: value",
           "@Retention",
           "^",
-          "<>:4: error: missing required annotation argument: value",
-          "@Retention",
-          "^",
-          "<>:3: error: java.lang.annotation.Retention is not @Repeatable",
-          "@Retention",
-          "^",
         },
       },
       {
@@ -812,6 +806,158 @@
           "            ^",
         },
       },
+      {
+        {
+          "@interface A {",
+          "  boolean x();",
+          "  boolean value();",
+          "}",
+          "@A(x = true, false)",
+          "class T {}",
+        },
+        {
+          "<>:5: error: expected an annotation value of the form name=value",
+          "@A(x = true, false)",
+          "             ^",
+        },
+      },
+      {
+        {
+          "@interface A {",
+          "  boolean value();",
+          "}",
+          "class B {",
+          "  static final String X = \"hello\";",
+          "}",
+          "@A(B.X)",
+          "class T {}",
+        },
+        {
+          "<>:7: error: value \"hello\" of type String cannot be converted to boolean",
+          "@A(B.X)",
+          "   ^",
+        },
+      },
+      {
+        {
+          "class T {", //
+          "  public static final boolean b = true == 42;",
+          "}",
+        },
+        {
+          "<>:2: error: value 42 of type int cannot be converted to boolean",
+          "  public static final boolean b = true == 42;",
+          "                                          ^",
+        },
+      },
+      {
+        {
+          "class T {", //
+          "  public static final byte b = (byte) \"hello\";",
+          "}",
+        },
+        {
+          "<>:2: error: value \"hello\" of type String cannot be converted to byte",
+          "  public static final byte b = (byte) \"hello\";",
+          "                                      ^",
+        }
+      },
+      {
+        {
+          "class T {", //
+          "  public static final char c = (char) \"hello\";",
+          "}",
+        },
+        {
+          "<>:2: error: value \"hello\" of type String cannot be converted to char",
+          "  public static final char c = (char) \"hello\";",
+          "                                      ^",
+        }
+      },
+      {
+        {
+          "class T {", //
+          "  public static final short s = (short) \"hello\";",
+          "}",
+        },
+        {
+          "<>:2: error: value \"hello\" of type String cannot be converted to short",
+          "  public static final short s = (short) \"hello\";",
+          "                                        ^",
+        }
+      },
+      {
+        {
+          "class T {", //
+          "  public static final int i = (int) \"hello\";",
+          "}",
+        },
+        {
+          "<>:2: error: value \"hello\" of type String cannot be converted to int",
+          "  public static final int i = (int) \"hello\";",
+          "                                    ^",
+        }
+      },
+      {
+        {
+          "class T {", //
+          "  public static final long l = (long) \"hello\";",
+          "}",
+        },
+        {
+          "<>:2: error: value \"hello\" of type String cannot be converted to long",
+          "  public static final long l = (long) \"hello\";",
+          "                                      ^",
+        }
+      },
+      {
+        {
+          "class T {", //
+          "  public static final float f = (float) \"hello\";",
+          "}",
+        },
+        {
+          "<>:2: error: value \"hello\" of type String cannot be converted to float",
+          "  public static final float f = (float) \"hello\";",
+          "                                        ^",
+        }
+      },
+      {
+        {
+          "class T {", //
+          "  public static final double d = (double) \"hello\";",
+          "}",
+        },
+        {
+          "<>:2: error: value \"hello\" of type String cannot be converted to double",
+          "  public static final double d = (double) \"hello\";",
+          "                                          ^",
+        },
+      },
+      {
+        {
+          "class T {", //
+          "  public static final boolean X = \"1\" == 2;",
+          "}",
+        },
+        {
+          "<>:2: error: value 2 of type int cannot be converted to String",
+          "  public static final boolean X = \"1\" == 2;",
+          "                                         ^",
+        },
+      },
+      {
+        {
+          "class T {", //
+          "  public static final boolean X = \"1\" != 2;",
+          "}",
+        },
+        {
+          "<>:2: error: value 2 of type int cannot be converted to String",
+          "  public static final boolean X = \"1\" != 2;",
+          "                                         ^",
+        },
+      },
     };
     return Arrays.asList((Object[][]) testCases);
   }
diff --git a/javatests/com/google/turbine/binder/BinderTest.java b/javatests/com/google/turbine/binder/BinderTest.java
index 820fe22..40387ac 100644
--- a/javatests/com/google/turbine/binder/BinderTest.java
+++ b/javatests/com/google/turbine/binder/BinderTest.java
@@ -16,7 +16,6 @@
 
 package com.google.turbine.binder;
 
-import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
 import static java.util.Objects.requireNonNull;
@@ -26,7 +25,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
-import com.google.turbine.binder.bound.TypeBoundClass.FieldInfo;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.diag.TurbineError;
 import com.google.turbine.lower.IntegrationTestSupport;
@@ -284,34 +282,6 @@
     assertThat(a.annotationMetadata().target()).containsExactly(TurbineElementType.TYPE_USE);
   }
 
-  // Test that we don't crash on invalid constant field initializers.
-  // (Error reporting is deferred to javac.)
-  @Test
-  public void invalidConst() throws Exception {
-    ImmutableList<Tree.CompUnit> units =
-        ImmutableList.of(
-            parseLines(
-                "package a;", //
-                "public class A {",
-                "  public static final boolean b = true == 42;",
-                "}"));
-
-    ImmutableMap<ClassSymbol, SourceTypeBoundClass> bound =
-        Binder.bind(
-                units,
-                ClassPathBinder.bindClasspath(ImmutableList.of()),
-                TURBINE_BOOTCLASSPATH,
-                /* moduleVersion=*/ Optional.empty())
-            .units();
-
-    assertThat(bound.keySet()).containsExactly(new ClassSymbol("a/A"));
-
-    SourceTypeBoundClass a = getBoundClass(bound, "a/A");
-    FieldInfo f = getOnlyElement(a.fields());
-    assertThat(f.name()).isEqualTo("b");
-    assertThat(f.value()).isNull();
-  }
-
   private Tree.CompUnit parseLines(String... lines) {
     return Parser.parse(Joiner.on('\n').join(lines));
   }
diff --git a/javatests/com/google/turbine/binder/CtSymClassBinderTest.java b/javatests/com/google/turbine/binder/CtSymClassBinderTest.java
index 2da9f4c..d3a2c0e 100644
--- a/javatests/com/google/turbine/binder/CtSymClassBinderTest.java
+++ b/javatests/com/google/turbine/binder/CtSymClassBinderTest.java
@@ -29,19 +29,20 @@
 public class CtSymClassBinderTest {
   @Test
   public void formatReleaseVersion() {
-    ImmutableList.of("5", "6", "7", "8", "9")
-        .forEach(x -> assertThat(CtSymClassBinder.formatReleaseVersion(x)).isEqualTo(x));
+    ImmutableList.of(5, 6, 7, 8, 9)
+        .forEach(
+            x -> assertThat(CtSymClassBinder.formatReleaseVersion(x)).isEqualTo(String.valueOf(x)));
     ImmutableMap.of(
-            "10", "A",
-            "11", "B",
-            "12", "C",
-            "35", "Z")
+            10, "A",
+            11, "B",
+            12, "C",
+            35, "Z")
         .forEach((k, v) -> assertThat(CtSymClassBinder.formatReleaseVersion(k)).isEqualTo(v));
-    ImmutableList.of("4", "36")
+    ImmutableList.of(4, 36)
         .forEach(
             x ->
                 assertThrows(
-                    x,
+                    Integer.toString(x),
                     IllegalArgumentException.class,
                     () -> CtSymClassBinder.formatReleaseVersion(x)));
   }
diff --git a/javatests/com/google/turbine/binder/ProcessingTest.java b/javatests/com/google/turbine/binder/ProcessingTest.java
deleted file mode 100644
index b7091e8..0000000
--- a/javatests/com/google/turbine/binder/ProcessingTest.java
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright 2020 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.turbine.binder;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
-import com.google.common.collect.ImmutableList;
-import javax.lang.model.SourceVersion;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-@RunWith(JUnit4.class)
-public class ProcessingTest {
-  @Test
-  public void parseSourceVersion() {
-    assertThat(Processing.parseSourceVersion(ImmutableList.of()))
-        .isEqualTo(SourceVersion.latestSupported());
-    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "8", "-target", "11")))
-        .isEqualTo(SourceVersion.RELEASE_8);
-    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "8", "-source", "7")))
-        .isEqualTo(SourceVersion.RELEASE_7);
-  }
-
-  @Test
-  public void withPrefix() {
-    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "1.7")))
-        .isEqualTo(SourceVersion.RELEASE_7);
-    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "1.8")))
-        .isEqualTo(SourceVersion.RELEASE_8);
-  }
-
-  @Test
-  public void invalidPrefix() {
-    IllegalArgumentException expected =
-        assertThrows(
-            IllegalArgumentException.class,
-            () -> Processing.parseSourceVersion(ImmutableList.of("-source", "1.11")));
-    assertThat(expected).hasMessageThat().contains("invalid -source version: 1.11");
-  }
-
-  @Test
-  public void latestSupported() {
-    String latest = SourceVersion.latestSupported().toString();
-    assertThat(latest).startsWith("RELEASE_");
-    latest = latest.substring("RELEASE_".length());
-    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", latest)))
-        .isEqualTo(SourceVersion.latestSupported());
-  }
-
-  @Test
-  public void missingArgument() {
-    IllegalArgumentException expected =
-        assertThrows(
-            IllegalArgumentException.class,
-            () -> Processing.parseSourceVersion(ImmutableList.of("-source")));
-    assertThat(expected).hasMessageThat().contains("-source requires an argument");
-  }
-
-  @Test
-  public void invalidSourceVersion() {
-    IllegalArgumentException expected =
-        assertThrows(
-            IllegalArgumentException.class,
-            () -> Processing.parseSourceVersion(ImmutableList.of("-source", "NOSUCH")));
-    assertThat(expected).hasMessageThat().contains("invalid -source version: NOSUCH");
-  }
-}
diff --git a/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java b/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java
index ec2ebbf..65d973d 100644
--- a/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java
+++ b/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java
@@ -42,7 +42,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
diff --git a/javatests/com/google/turbine/bytecode/ClassReaderTest.java b/javatests/com/google/turbine/bytecode/ClassReaderTest.java
index 9a9fdb1..ad5b90d 100644
--- a/javatests/com/google/turbine/bytecode/ClassReaderTest.java
+++ b/javatests/com/google/turbine/bytecode/ClassReaderTest.java
@@ -39,6 +39,7 @@
 import org.objectweb.asm.ByteVector;
 import org.objectweb.asm.ClassWriter;
 import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Handle;
 import org.objectweb.asm.ModuleVisitor;
 import org.objectweb.asm.Opcodes;
 
@@ -236,6 +237,18 @@
   }
 
   @Test
+  public void condy() {
+    ClassWriter cw = new ClassWriter(0);
+    cw.visit(52, Opcodes.ACC_SUPER, "Test", null, "java/lang/Object", null);
+    cw.newConstantDynamic(
+        "f", "Ljava/lang/String;", new Handle(Opcodes.H_INVOKESTATIC, "A", "f", "()V", false));
+    byte[] bytes = cw.toByteArray();
+
+    ClassFile cf = ClassReader.read(null, bytes);
+    assertThat(cf.name()).isEqualTo("Test");
+  }
+
+  @Test
   public void v53() {
     ClassWriter cw = new ClassWriter(0);
     cw.visitAnnotation("Ljava/lang/Deprecated;", true);
diff --git a/javatests/com/google/turbine/bytecode/ClassWriterTest.java b/javatests/com/google/turbine/bytecode/ClassWriterTest.java
index f488bbe..a6f9234 100644
--- a/javatests/com/google/turbine/bytecode/ClassWriterTest.java
+++ b/javatests/com/google/turbine/bytecode/ClassWriterTest.java
@@ -21,6 +21,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.io.ByteArrayDataOutput;
 import com.google.common.io.ByteStreams;
 import com.google.common.jimfs.Configuration;
@@ -46,6 +47,7 @@
 import org.junit.runners.JUnit4;
 import org.objectweb.asm.ModuleVisitor;
 import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.RecordComponentVisitor;
 
 @RunWith(JUnit4.class)
 public class ClassWriterTest {
@@ -154,4 +156,106 @@
     assertThat(AsmUtils.textify(inputBytes, /* skipDebug= */ true))
         .isEqualTo(AsmUtils.textify(outputBytes, /* skipDebug= */ true));
   }
+
+  @Test
+  public void record() {
+
+    org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0);
+
+    cw.visit(
+        Opcodes.V16,
+        Opcodes.ACC_FINAL | Opcodes.ACC_SUPER | Opcodes.ACC_RECORD,
+        "R",
+        /* signature= */ null,
+        "java/lang/Record",
+        /* interfaces= */ null);
+
+    RecordComponentVisitor rv =
+        cw.visitRecordComponent("x", "Ljava/util/List;", "Ljava/util/List<Ljava/lang/Integer;>;");
+    rv.visitAnnotation("LA;", true);
+    rv.visitTypeAnnotation(318767104, null, "LA;", true);
+    cw.visitRecordComponent("y", "I", null);
+
+    byte[] expectedBytes = cw.toByteArray();
+
+    ClassFile classFile =
+        new ClassFile(
+            /* access= */ Opcodes.ACC_FINAL | Opcodes.ACC_SUPER | Opcodes.ACC_RECORD,
+            /* majorVersion= */ 60,
+            /* name= */ "R",
+            /* signature= */ null,
+            /* superClass= */ "java/lang/Record",
+            /* interfaces= */ ImmutableList.of(),
+            /* permits= */ ImmutableList.of(),
+            /* methods= */ ImmutableList.of(),
+            /* fields= */ ImmutableList.of(),
+            /* annotations= */ ImmutableList.of(),
+            /* innerClasses= */ ImmutableList.of(),
+            /* typeAnnotations= */ ImmutableList.of(),
+            /* module= */ null,
+            /* nestHost= */ null,
+            /* nestMembers= */ ImmutableList.of(),
+            /* record= */ new ClassFile.RecordInfo(
+                ImmutableList.of(
+                    new ClassFile.RecordInfo.RecordComponentInfo(
+                        "x",
+                        "Ljava/util/List;",
+                        "Ljava/util/List<Ljava/lang/Integer;>;",
+                        ImmutableList.of(
+                            new ClassFile.AnnotationInfo("LA;", true, ImmutableMap.of())),
+                        ImmutableList.of(
+                            new ClassFile.TypeAnnotationInfo(
+                                ClassFile.TypeAnnotationInfo.TargetType.FIELD,
+                                ClassFile.TypeAnnotationInfo.EMPTY_TARGET,
+                                ClassFile.TypeAnnotationInfo.TypePath.root(),
+                                new ClassFile.AnnotationInfo("LA;", true, ImmutableMap.of())))),
+                    new ClassFile.RecordInfo.RecordComponentInfo(
+                        "y", "I", null, ImmutableList.of(), ImmutableList.of()))),
+            /* transitiveJar= */ null);
+
+    byte[] actualBytes = ClassWriter.writeClass(classFile);
+
+    assertThat(AsmUtils.textify(actualBytes, /* skipDebug= */ true))
+        .isEqualTo(AsmUtils.textify(expectedBytes, /* skipDebug= */ true));
+  }
+
+  @Test
+  public void nestHost() {
+
+    org.objectweb.asm.ClassWriter cw = new org.objectweb.asm.ClassWriter(0);
+
+    cw.visit(Opcodes.V16, Opcodes.ACC_SUPER, "N", null, null, null);
+
+    cw.visitNestHost("H");
+    cw.visitNestMember("A");
+    cw.visitNestMember("B");
+    cw.visitNestMember("C");
+
+    byte[] expectedBytes = cw.toByteArray();
+
+    ClassFile classFile =
+        new ClassFile(
+            /* access= */ Opcodes.ACC_SUPER,
+            /* majorVersion= */ 60,
+            /* name= */ "N",
+            /* signature= */ null,
+            /* superClass= */ null,
+            /* interfaces= */ ImmutableList.of(),
+            /* permits= */ ImmutableList.of(),
+            /* methods= */ ImmutableList.of(),
+            /* fields= */ ImmutableList.of(),
+            /* annotations= */ ImmutableList.of(),
+            /* innerClasses= */ ImmutableList.of(),
+            /* typeAnnotations= */ ImmutableList.of(),
+            /* module= */ null,
+            /* nestHost= */ "H",
+            /* nestMembers= */ ImmutableList.of("A", "B", "C"),
+            /* record= */ null,
+            /* transitiveJar= */ null);
+
+    byte[] actualBytes = ClassWriter.writeClass(classFile);
+
+    assertThat(AsmUtils.textify(actualBytes, /* skipDebug= */ true))
+        .isEqualTo(AsmUtils.textify(expectedBytes, /* skipDebug= */ true));
+  }
 }
diff --git a/javatests/com/google/turbine/deps/DependenciesTest.java b/javatests/com/google/turbine/deps/DependenciesTest.java
index bc663cd..ba905db 100644
--- a/javatests/com/google/turbine/deps/DependenciesTest.java
+++ b/javatests/com/google/turbine/deps/DependenciesTest.java
@@ -29,6 +29,7 @@
 import com.google.turbine.lower.IntegrationTestSupport;
 import com.google.turbine.lower.Lower;
 import com.google.turbine.lower.Lower.Lowered;
+import com.google.turbine.options.LanguageVersion;
 import com.google.turbine.parse.Parser;
 import com.google.turbine.proto.DepsProto;
 import com.google.turbine.testing.TestClassPaths;
@@ -106,7 +107,12 @@
               TestClassPaths.TURBINE_BOOTCLASSPATH,
               /* moduleVersion=*/ Optional.empty());
 
-      Lowered lowered = Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv());
+      Lowered lowered =
+          Lower.lowerAll(
+              LanguageVersion.createDefault(),
+              bound.units(),
+              bound.modules(),
+              bound.classPathEnv());
 
       return Dependencies.collectDeps(
           Optional.of("//test"), TestClassPaths.TURBINE_BOOTCLASSPATH, bound, lowered);
diff --git a/javatests/com/google/turbine/lower/IntegrationTestSupport.java b/javatests/com/google/turbine/lower/IntegrationTestSupport.java
index 744f341..f20962b 100644
--- a/javatests/com/google/turbine/lower/IntegrationTestSupport.java
+++ b/javatests/com/google/turbine/lower/IntegrationTestSupport.java
@@ -24,6 +24,7 @@
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toCollection;
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 import static org.junit.Assert.fail;
 
 import com.google.common.base.Joiner;
@@ -37,6 +38,7 @@
 import com.google.turbine.binder.ClassPath;
 import com.google.turbine.binder.ClassPathBinder;
 import com.google.turbine.diag.SourceFile;
+import com.google.turbine.options.LanguageVersion;
 import com.google.turbine.parse.Parser;
 import com.google.turbine.testing.AsmUtils;
 import com.google.turbine.tree.Tree.CompUnit;
@@ -81,6 +83,7 @@
 import org.objectweb.asm.tree.FieldNode;
 import org.objectweb.asm.tree.InnerClassNode;
 import org.objectweb.asm.tree.MethodNode;
+import org.objectweb.asm.tree.RecordComponentNode;
 import org.objectweb.asm.tree.TypeAnnotationNode;
 
 /** Support for bytecode diffing-integration tests. */
@@ -242,12 +245,22 @@
     for (FieldNode f : n.fields) {
       sortAnnotations(f.visibleAnnotations);
       sortAnnotations(f.invisibleAnnotations);
-
-      sortAnnotations(f.visibleAnnotations);
-      sortAnnotations(f.invisibleAnnotations);
       sortTypeAnnotations(f.visibleTypeAnnotations);
       sortTypeAnnotations(f.invisibleTypeAnnotations);
     }
+
+    if (n.recordComponents != null) {
+      for (RecordComponentNode r : n.recordComponents) {
+        sortAnnotations(r.visibleAnnotations);
+        sortAnnotations(r.invisibleAnnotations);
+        sortTypeAnnotations(r.visibleTypeAnnotations);
+        sortTypeAnnotations(r.invisibleTypeAnnotations);
+      }
+    }
+
+    if (n.nestMembers != null) {
+      Collections.sort(n.nestMembers);
+    }
   }
 
   private static void sortParameterAnnotations(List<AnnotationNode>[] parameters) {
@@ -323,6 +336,26 @@
       addTypesInTypeAnnotations(types, f.visibleTypeAnnotations);
       addTypesInTypeAnnotations(types, f.invisibleTypeAnnotations);
     }
+    if (n.recordComponents != null) {
+      for (RecordComponentNode r : n.recordComponents) {
+        collectTypesFromSignature(types, r.descriptor);
+        collectTypesFromSignature(types, r.signature);
+
+        addTypesInAnnotations(types, r.visibleAnnotations);
+        addTypesInAnnotations(types, r.invisibleAnnotations);
+        addTypesInTypeAnnotations(types, r.visibleTypeAnnotations);
+        addTypesInTypeAnnotations(types, r.invisibleTypeAnnotations);
+      }
+    }
+
+    if (n.nestMembers != null) {
+      for (String member : n.nestMembers) {
+        InnerClassNode i = infos.get(member);
+        if (i.outerName != null) {
+          types.add(member);
+        }
+      }
+    }
 
     List<InnerClassNode> used = new ArrayList<>();
     for (InnerClassNode i : n.innerClasses) {
@@ -336,6 +369,11 @@
     }
     addInnerChain(infos, used, n.name);
     n.innerClasses = used;
+
+    if (n.nestMembers != null) {
+      Set<String> members = used.stream().map(i -> i.name).collect(toSet());
+      n.nestMembers = n.nestMembers.stream().filter(members::contains).collect(toList());
+    }
   }
 
   private static void addTypesFromParameterAnnotations(
@@ -437,20 +475,32 @@
             });
   }
 
-  static Map<String, byte[]> runTurbine(Map<String, String> input, ImmutableList<Path> classpath)
+  public static Map<String, byte[]> runTurbine(
+      Map<String, String> input, ImmutableList<Path> classpath) throws IOException {
+    return runTurbine(input, classpath, ImmutableList.of());
+  }
+
+  public static Map<String, byte[]> runTurbine(
+      Map<String, String> input, ImmutableList<Path> classpath, ImmutableList<String> javacopts)
       throws IOException {
     return runTurbine(
-        input, classpath, TURBINE_BOOTCLASSPATH, /* moduleVersion= */ Optional.empty());
+        input, classpath, TURBINE_BOOTCLASSPATH, /* moduleVersion= */ Optional.empty(), javacopts);
   }
 
   static Map<String, byte[]> runTurbine(
       Map<String, String> input,
       ImmutableList<Path> classpath,
       ClassPath bootClassPath,
-      Optional<String> moduleVersion)
+      Optional<String> moduleVersion,
+      ImmutableList<String> javacopts)
       throws IOException {
     BindingResult bound = turbineAnalysis(input, classpath, bootClassPath, moduleVersion);
-    return Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
+    return Lower.lowerAll(
+            LanguageVersion.fromJavacopts(javacopts),
+            bound.units(),
+            bound.modules(),
+            bound.classPathEnv())
+        .bytes();
   }
 
   public static BindingResult turbineAnalysis(
@@ -640,5 +690,9 @@
     }
   }
 
+  public static int getMajor() {
+    return Runtime.version().feature();
+  }
+
   private IntegrationTestSupport() {}
 }
diff --git a/javatests/com/google/turbine/lower/LongStringIntegrationTest.java b/javatests/com/google/turbine/lower/LongStringIntegrationTest.java
new file mode 100644
index 0000000..a462b69
--- /dev/null
+++ b/javatests/com/google/turbine/lower/LongStringIntegrationTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.lower;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Opcodes;
+
+@RunWith(JUnit4.class)
+public class LongStringIntegrationTest {
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void test() throws Exception {
+    Map<String, byte[]> output =
+        runTurbineWithStack(
+            /* stackSize= */ 1,
+            /* input= */ ImmutableMap.of("Test.java", source()),
+            /* classpath= */ ImmutableList.of());
+
+    assertThat(output.keySet()).containsExactly("Test");
+    String result = fieldValue(output.get("Test"));
+    assertThat(result).startsWith("...");
+    assertThat(result).hasLength(10000);
+  }
+
+  private static Map<String, byte[]> runTurbineWithStack(
+      int stackSize, ImmutableMap<String, String> input, ImmutableList<Path> classpath)
+      throws InterruptedException {
+    Map<String, byte[]> output = new HashMap<>();
+    Thread t =
+        new Thread(
+            /* group= */ null,
+            () -> {
+              try {
+                output.putAll(IntegrationTestSupport.runTurbine(input, classpath));
+              } catch (IOException e) {
+                throw new UncheckedIOException(e);
+              }
+            },
+            /* name= */ "turbine",
+            stackSize);
+    t.run();
+    t.join();
+    return output;
+  }
+
+  /** Extract the string value of a constant field from the class file. */
+  private static String fieldValue(byte[] classFile) {
+    String[] result = {null};
+    new ClassReader(classFile)
+        .accept(
+            new ClassVisitor(Opcodes.ASM9) {
+              @Override
+              public FieldVisitor visitField(
+                  int access, String name, String desc, String signature, Object value) {
+                result[0] = (String) value;
+                return null;
+              }
+            },
+            0);
+    return result[0];
+  }
+
+  /** Create a source file with a long concatenated string literal: {@code "" + "." + "." + ...}. */
+  private static String source() {
+    StringBuilder input = new StringBuilder();
+    input.append("class Test { public static final String C = \"\"");
+    for (int i = 0; i < 10000; i++) {
+      input.append("+ \".\"\n");
+    }
+    input.append("; }");
+    return input.toString();
+  }
+}
diff --git a/javatests/com/google/turbine/lower/LowerIntegrationTest.java b/javatests/com/google/turbine/lower/LowerIntegrationTest.java
index ab4e0ee..97170ca 100644
--- a/javatests/com/google/turbine/lower/LowerIntegrationTest.java
+++ b/javatests/com/google/turbine/lower/LowerIntegrationTest.java
@@ -19,8 +19,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.turbine.testing.TestResources.getResource;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assume.assumeTrue;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import java.io.IOError;
 import java.io.IOException;
@@ -41,6 +43,9 @@
 @RunWith(Parameterized.class)
 public class LowerIntegrationTest {
 
+  private static final ImmutableMap<String, Integer> SOURCE_VERSION =
+      ImmutableMap.of("record.test", 16, "record2.test", 16, "sealed.test", 17);
+
   @Parameters(name = "{index}: {0}")
   public static Iterable<Object[]> parameters() {
     String[] testCases = {
@@ -55,6 +60,7 @@
       "B8148131.test",
       "abstractenum.test",
       "access1.test",
+      "ambiguous_identifier.test",
       "anno_const_coerce.test",
       "anno_const_scope.test",
       "anno_nested.test",
@@ -125,6 +131,7 @@
       "const_multi.test",
       "const_nonfinal.test",
       "const_octal_underscore.test",
+      "const_operation_order.test",
       "const_types.test",
       "const_underscore.test",
       "constlevel.test",
@@ -138,6 +145,7 @@
       "default_simple.test",
       "deficient_types_classfile.test",
       "dollar.test",
+      "empty_package_info.test",
       "enum1.test",
       "enum_abstract.test",
       "enum_final.test",
@@ -254,8 +262,11 @@
       "rawcanon.test",
       "rawfbound.test",
       "receiver_param.test",
+      "record.test",
+      "record2.test",
       "rek.test",
       "samepkg.test",
+      "sealed.test",
       "self.test",
       "semi.test",
       // https://bugs.openjdk.java.net/browse/JDK-8054064 ?
@@ -270,6 +281,7 @@
       "static_type_import.test",
       "strictfp.test",
       "string.test",
+      "string_const.test",
       "superabstract.test",
       "supplierfunction.test",
       "tbound.test",
@@ -363,9 +375,16 @@
       classpathJar = ImmutableList.of(lib);
     }
 
-    Map<String, byte[]> expected = IntegrationTestSupport.runJavac(input.sources, classpathJar);
+    int version = SOURCE_VERSION.getOrDefault(test, 8);
+    assumeTrue(version <= Runtime.version().feature());
+    ImmutableList<String> javacopts =
+        ImmutableList.of("-source", String.valueOf(version), "-target", String.valueOf(version));
 
-    Map<String, byte[]> actual = IntegrationTestSupport.runTurbine(input.sources, classpathJar);
+    Map<String, byte[]> expected =
+        IntegrationTestSupport.runJavac(input.sources, classpathJar, javacopts);
+
+    Map<String, byte[]> actual =
+        IntegrationTestSupport.runTurbine(input.sources, classpathJar, javacopts);
 
     assertThat(IntegrationTestSupport.dump(IntegrationTestSupport.sortMembers(actual)))
         .isEqualTo(IntegrationTestSupport.dump(IntegrationTestSupport.canonicalize(expected)));
diff --git a/javatests/com/google/turbine/lower/LowerTest.java b/javatests/com/google/turbine/lower/LowerTest.java
index d74e829..6d3a6df 100644
--- a/javatests/com/google/turbine/lower/LowerTest.java
+++ b/javatests/com/google/turbine/lower/LowerTest.java
@@ -41,6 +41,7 @@
 import com.google.turbine.model.TurbineConstantTypeKind;
 import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.model.TurbineTyKind;
+import com.google.turbine.options.LanguageVersion;
 import com.google.turbine.parse.Parser;
 import com.google.turbine.testing.AsmUtils;
 import com.google.turbine.type.Type;
@@ -184,9 +185,11 @@
     SourceTypeBoundClass c =
         new SourceTypeBoundClass(
             interfaceTypes,
+            ImmutableList.of(),
             xtnds,
             tps,
             access,
+            ImmutableList.of(),
             methods,
             fields,
             owner,
@@ -204,11 +207,13 @@
     SourceTypeBoundClass i =
         new SourceTypeBoundClass(
             ImmutableList.of(),
+            ImmutableList.of(),
             Type.ClassTy.OBJECT,
             ImmutableMap.of(),
             TurbineFlag.ACC_STATIC | TurbineFlag.ACC_PROTECTED,
             ImmutableList.of(),
             ImmutableList.of(),
+            ImmutableList.of(),
             new ClassSymbol("test/Test"),
             TurbineTyKind.CLASS,
             ImmutableMap.of("Inner", new ClassSymbol("test/Test$Inner")),
@@ -227,6 +232,7 @@
 
     Map<String, byte[]> bytes =
         Lower.lowerAll(
+                LanguageVersion.createDefault(),
                 ImmutableMap.of(
                     new ClassSymbol("test/Test"), c, new ClassSymbol("test/Test$Inner"), i),
                 ImmutableList.of(),
@@ -256,7 +262,12 @@
             TURBINE_BOOTCLASSPATH,
             /* moduleVersion=*/ Optional.empty());
     Map<String, byte[]> lowered =
-        Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
+        Lower.lowerAll(
+                LanguageVersion.createDefault(),
+                bound.units(),
+                bound.modules(),
+                bound.classPathEnv())
+            .bytes();
     List<String> attributes = new ArrayList<>();
     new ClassReader(lowered.get("Test$Inner$InnerMost"))
         .accept(
@@ -331,7 +342,12 @@
             TURBINE_BOOTCLASSPATH,
             /* moduleVersion=*/ Optional.empty());
     Map<String, byte[]> lowered =
-        Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
+        Lower.lowerAll(
+                LanguageVersion.createDefault(),
+                bound.units(),
+                bound.modules(),
+                bound.classPathEnv())
+            .bytes();
     TypePath[] path = new TypePath[1];
     new ClassReader(lowered.get("Test"))
         .accept(
@@ -409,7 +425,12 @@
             TURBINE_BOOTCLASSPATH,
             /* moduleVersion=*/ Optional.empty());
     Map<String, byte[]> lowered =
-        Lower.lowerAll(bound.units(), bound.modules(), bound.classPathEnv()).bytes();
+        Lower.lowerAll(
+                LanguageVersion.createDefault(),
+                bound.units(),
+                bound.modules(),
+                bound.classPathEnv())
+            .bytes();
     int[] acc = {0};
     new ClassReader(lowered.get("Test"))
         .accept(
@@ -477,7 +498,7 @@
       ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
       sources.forEach(
           (k, v) -> builder.put(k, v.replaceAll("import static b\\.B\\.nosuch\\..*;", "")));
-      noImports = builder.build();
+      noImports = builder.buildOrThrow();
     }
 
     Map<String, byte[]> expected = IntegrationTestSupport.runJavac(noImports, ImmutableList.of());
@@ -621,6 +642,40 @@
     assertThat((testAccess[0] & TurbineFlag.ACC_PROTECTED)).isNotEqualTo(TurbineFlag.ACC_PROTECTED);
   }
 
+  @Test
+  public void minClassVersion() throws Exception {
+    BindingResult bound =
+        Binder.bind(
+            ImmutableList.of(Parser.parse("class Test {}")),
+            ClassPathBinder.bindClasspath(ImmutableList.of()),
+            TURBINE_BOOTCLASSPATH,
+            /* moduleVersion=*/ Optional.empty());
+    Map<String, byte[]> lowered =
+        Lower.lowerAll(
+                LanguageVersion.fromJavacopts(ImmutableList.of("-source", "7", "-target", "7")),
+                bound.units(),
+                bound.modules(),
+                bound.classPathEnv())
+            .bytes();
+    int[] major = {0};
+    new ClassReader(lowered.get("Test"))
+        .accept(
+            new ClassVisitor(Opcodes.ASM9) {
+              @Override
+              public void visit(
+                  int version,
+                  int access,
+                  String name,
+                  String signature,
+                  String superName,
+                  String[] interfaces) {
+                major[0] = version;
+              }
+            },
+            0);
+    assertThat(major[0]).isEqualTo(Opcodes.V1_8);
+  }
+
   static String lines(String... lines) {
     return Joiner.on(System.lineSeparator()).join(lines);
   }
diff --git a/javatests/com/google/turbine/lower/MissingJavaBaseModule.java b/javatests/com/google/turbine/lower/MissingJavaBaseModule.java
new file mode 100644
index 0000000..230b18f
--- /dev/null
+++ b/javatests/com/google/turbine/lower/MissingJavaBaseModule.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.lower;
+
+import static com.google.common.base.StandardSystemProperty.JAVA_CLASS_VERSION;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.turbine.binder.ClassPath;
+import com.google.turbine.binder.CtSymClassBinder;
+import com.google.turbine.binder.JimageClassBinder;
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bytecode.BytecodeBoundClass;
+import com.google.turbine.binder.env.Env;
+import com.google.turbine.binder.lookup.TopLevelIndex;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
+import java.util.Map;
+import java.util.Optional;
+import org.jspecify.nullness.Nullable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class MissingJavaBaseModule {
+
+  @Test
+  public void test() throws Exception {
+
+    Map<String, String> sources = ImmutableMap.of("module-info.java", "module foo {}");
+
+    Map<String, byte[]> expected =
+        IntegrationTestSupport.runJavac(
+            sources, ImmutableList.of(), ImmutableList.of("--release", "9", "--module-version=42"));
+
+    ClassPath base =
+        Double.parseDouble(JAVA_CLASS_VERSION.value()) < 54
+            ? JimageClassBinder.bindDefault()
+            : CtSymClassBinder.bind(9);
+    ClassPath bootclasspath =
+        new ClassPath() {
+          @Override
+          public Env<ClassSymbol, BytecodeBoundClass> env() {
+            return base.env();
+          }
+
+          @Override
+          public Env<ModuleSymbol, ModuleInfo> moduleEnv() {
+            return new Env<ModuleSymbol, ModuleInfo>() {
+              @Override
+              public @Nullable ModuleInfo get(ModuleSymbol sym) {
+                if (sym.name().equals("java.base")) {
+                  return null;
+                }
+                return base.moduleEnv().get(sym);
+              }
+            };
+          }
+
+          @Override
+          public TopLevelIndex index() {
+            return base.index();
+          }
+
+          @Override
+          public @Nullable Supplier<byte[]> resource(String path) {
+            return base.resource(path);
+          }
+        };
+    Map<String, byte[]> actual =
+        IntegrationTestSupport.runTurbine(
+            sources,
+            ImmutableList.of(),
+            bootclasspath,
+            Optional.of("42"),
+            /* javacopts= */ ImmutableList.of());
+
+    assertEquals(dump(expected), dump(actual));
+  }
+
+  private String dump(Map<String, byte[]> map) throws Exception {
+    return IntegrationTestSupport.dump(
+        map.entrySet().stream()
+            .filter(e -> e.getKey().endsWith("module-info"))
+            .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)));
+  }
+}
diff --git a/javatests/com/google/turbine/lower/ModuleIntegrationTest.java b/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
index f2c0bbf..0157fea 100644
--- a/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
+++ b/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
@@ -96,8 +96,9 @@
             classpathJar,
             Double.parseDouble(JAVA_CLASS_VERSION.value()) < 54
                 ? JimageClassBinder.bindDefault()
-                : CtSymClassBinder.bind("9"),
-            Optional.of("42"));
+                : CtSymClassBinder.bind(9),
+            Optional.of("42"),
+            /* javacopts= */ ImmutableList.of());
 
     assertEquals(dump(expected), dump(actual));
   }
diff --git a/javatests/com/google/turbine/lower/testdata/ambiguous_identifier.test b/javatests/com/google/turbine/lower/testdata/ambiguous_identifier.test
new file mode 100644
index 0000000..d7bbc54
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/ambiguous_identifier.test
@@ -0,0 +1,14 @@
+=== Test.java ===
+class Test {
+  static final int non = 42;
+  static final int sealed = 1;
+  // here 'non-sealed' is a binary expression subtracting two identifiers,
+  // not a contextual hyphenated keyword
+  static final int x = non-sealed;
+}
+
+// handle backtracking when we see 'non', but it isn't part of a contextualy
+// hyphenated keyword 'non-sealed'
+class non {
+  non self;
+}
diff --git a/javatests/com/google/turbine/lower/testdata/array_class_literal.test b/javatests/com/google/turbine/lower/testdata/array_class_literal.test
index 9033b04..4287cdf 100644
--- a/javatests/com/google/turbine/lower/testdata/array_class_literal.test
+++ b/javatests/com/google/turbine/lower/testdata/array_class_literal.test
@@ -1,4 +1,6 @@
 === Test.java ===
+import java.util.Map;
+
 @interface Anno {
   Class<?> value() default Object.class;
 }
@@ -8,4 +10,5 @@
   @Anno(byte[][].class) int b;
   @Anno(int[][].class) int c;
   @Anno(Object[].class) int d;
+  @Anno(Map.Entry[].class) int e;
 }
diff --git a/javatests/com/google/turbine/lower/testdata/const_operation_order.test b/javatests/com/google/turbine/lower/testdata/const_operation_order.test
new file mode 100644
index 0000000..a088823
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/const_operation_order.test
@@ -0,0 +1,13 @@
+=== test/A.java ===
+package test;
+
+public class A {
+  public static final double D1 = 1.0 / (2 / 3);
+  public static final double D2 = 1.0 / 2 / 3;
+  public static final double M1 = 1.0 + (2 + 3);
+  public static final double M2 = 1.0 + 2 + 3;
+  public static final double A1 = 1.0 + (2 + 3);
+  public static final double A2 = 1.0 + 2 + 3;
+  public static final double S1 = 1.0 - (2 - 3);
+  public static final double S2 = 1.0 - 2 - 3;
+}
diff --git a/javatests/com/google/turbine/lower/testdata/empty_package_info.test b/javatests/com/google/turbine/lower/testdata/empty_package_info.test
new file mode 100644
index 0000000..be28f14
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/empty_package_info.test
@@ -0,0 +1,3 @@
+=== package-info.java ===
+
+package test;
diff --git a/javatests/com/google/turbine/lower/testdata/record.test b/javatests/com/google/turbine/lower/testdata/record.test
new file mode 100644
index 0000000..7d92c2b
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/record.test
@@ -0,0 +1,46 @@
+=== Records.java ===
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import java.util.List;
+
+class Records {
+  record R1() {}
+
+  private record R2() {}
+
+  @Deprecated
+  private record R3() {}
+
+  record R4<T>() {}
+
+  record R5<T>(int x) {}
+
+  record R6<T>(@Deprecated int x) {}
+
+  record R7<T>(@Deprecated int x, int... y) {}
+
+  record R8<T>() implements Comparable<R8<T>> {
+    @Override
+    public int compareTo(R8<T> other) {
+      return 0;
+    }
+  }
+
+  record R9(int x) {
+    R9(int x) {
+      this.x = x;
+    }
+  }
+
+  @Target(ElementType.TYPE_USE)
+  @interface A {}
+
+  @Target(ElementType.RECORD_COMPONENT)
+  @interface B {}
+
+  @Target({ElementType.TYPE_USE, ElementType.RECORD_COMPONENT})
+  @interface C {}
+
+  record R10<T>(@A List<@A T> x, @B int y, @C int z) {
+  }
+}
diff --git a/javatests/com/google/turbine/lower/testdata/record2.test b/javatests/com/google/turbine/lower/testdata/record2.test
new file mode 100644
index 0000000..af4093e
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/record2.test
@@ -0,0 +1,27 @@
+=== Records.java ===
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import java.util.Objects;
+
+class Records {
+  public record R1(String one) {
+    public R1 {
+      Objects.requireNonNull(one);
+    }
+  }
+
+  public record R2(String one) {
+    @Deprecated
+    public R2 {
+      Objects.requireNonNull(one);
+    }
+  }
+
+  public record R3<T>(T x) {
+    @Deprecated
+    public R3 {
+      Objects.requireNonNull(x);
+    }
+  }
+}
diff --git a/javatests/com/google/turbine/lower/testdata/sealed.test b/javatests/com/google/turbine/lower/testdata/sealed.test
new file mode 100644
index 0000000..0bac7b1
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/sealed.test
@@ -0,0 +1,11 @@
+=== Sealed.java ===
+
+sealed class Sealed permits Sealed.Foo, Sealed.Bar {
+  static final class Foo extends Sealed {}
+  static final class Bar extends Sealed {}
+}
+
+sealed interface ISealed permits ISealed.Foo, ISealed.Bar {
+  static final class Foo implements ISealed {}
+  static non-sealed class Bar implements ISealed {}
+}
diff --git a/javatests/com/google/turbine/lower/testdata/string_const.test b/javatests/com/google/turbine/lower/testdata/string_const.test
new file mode 100644
index 0000000..cd39c37
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/string_const.test
@@ -0,0 +1,7 @@
+=== T.java ===
+
+class T {
+  public static final String A = "" + 42;
+  public static final boolean B = "1" == "2";
+  public static final boolean C = "1" != "2";
+}
diff --git a/javatests/com/google/turbine/main/MainTest.java b/javatests/com/google/turbine/main/MainTest.java
index 57940f3..3504891 100644
--- a/javatests/com/google/turbine/main/MainTest.java
+++ b/javatests/com/google/turbine/main/MainTest.java
@@ -32,6 +32,7 @@
 import com.google.common.io.MoreFiles;
 import com.google.protobuf.ExtensionRegistry;
 import com.google.turbine.diag.TurbineError;
+import com.google.turbine.options.LanguageVersion;
 import com.google.turbine.options.TurbineOptions;
 import com.google.turbine.proto.ManifestProto;
 import java.io.BufferedInputStream;
@@ -175,7 +176,7 @@
 
     Main.compile(
         TurbineOptions.builder()
-            .setRelease("9")
+            .setLanguageVersion(LanguageVersion.fromJavacopts(ImmutableList.of("--release", "9")))
             .setSources(ImmutableList.of(src.toString()))
             .setSourceJars(ImmutableList.of(srcjar.toString()))
             .setOutput(output.toString())
diff --git a/javatests/com/google/turbine/options/LanguageVersionTest.java b/javatests/com/google/turbine/options/LanguageVersionTest.java
new file mode 100644
index 0000000..601652c
--- /dev/null
+++ b/javatests/com/google/turbine/options/LanguageVersionTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2020 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.options;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import javax.lang.model.SourceVersion;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class LanguageVersionTest {
+  @Test
+  public void parseSourceVersion() {
+    assertThat(LanguageVersion.fromJavacopts(ImmutableList.of()).sourceVersion())
+        .isEqualTo(SourceVersion.RELEASE_8);
+    assertThat(
+            LanguageVersion.fromJavacopts(ImmutableList.of("-source", "8", "-target", "11"))
+                .sourceVersion())
+        .isEqualTo(SourceVersion.RELEASE_8);
+    assertThat(
+            LanguageVersion.fromJavacopts(ImmutableList.of("-source", "8", "-source", "7"))
+                .sourceVersion())
+        .isEqualTo(SourceVersion.RELEASE_7);
+  }
+
+  @Test
+  public void withPrefix() {
+    assertThat(LanguageVersion.fromJavacopts(ImmutableList.of("-source", "1.7")).sourceVersion())
+        .isEqualTo(SourceVersion.RELEASE_7);
+    assertThat(LanguageVersion.fromJavacopts(ImmutableList.of("-source", "1.8")).sourceVersion())
+        .isEqualTo(SourceVersion.RELEASE_8);
+  }
+
+  @Test
+  public void invalidPrefix() {
+    assertThat(LanguageVersion.fromJavacopts(ImmutableList.of("-source", "1.10")).source())
+        .isEqualTo(10);
+    IllegalArgumentException expected =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> LanguageVersion.fromJavacopts(ImmutableList.of("-source", "1.11")));
+    assertThat(expected).hasMessageThat().contains("invalid -source version: 1.11");
+  }
+
+  @Test
+  public void latestSupported() {
+    String latest = SourceVersion.latestSupported().toString();
+    assertThat(latest).startsWith("RELEASE_");
+    latest = latest.substring("RELEASE_".length());
+    assertThat(LanguageVersion.fromJavacopts(ImmutableList.of("-source", latest)).sourceVersion())
+        .isEqualTo(SourceVersion.latestSupported());
+  }
+
+  @Test
+  public void missingArgument() {
+    for (String flag :
+        ImmutableList.of("-source", "--source", "-target", "--target", "--release")) {
+      IllegalArgumentException expected =
+          assertThrows(
+              IllegalArgumentException.class,
+              () -> LanguageVersion.fromJavacopts(ImmutableList.of(flag)));
+      assertThat(expected).hasMessageThat().contains(flag + " requires an argument");
+    }
+  }
+
+  @Test
+  public void invalidSourceVersion() {
+    IllegalArgumentException expected =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> LanguageVersion.fromJavacopts(ImmutableList.of("-source", "NOSUCH")));
+    assertThat(expected).hasMessageThat().contains("invalid -source version: NOSUCH");
+  }
+
+  @Test
+  public void invalidRelease() {
+    IllegalArgumentException expected =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> LanguageVersion.fromJavacopts(ImmutableList.of("--release", "NOSUCH")));
+    assertThat(expected).hasMessageThat().contains("invalid --release version: NOSUCH");
+  }
+
+  @Test
+  public void parseRelease() {
+    assertThat(LanguageVersion.fromJavacopts(ImmutableList.of("--release", "16")).release())
+        .hasValue(16);
+    assertThat(
+            LanguageVersion.fromJavacopts(ImmutableList.of("-source", "8", "-target", "8"))
+                .release())
+        .isEmpty();
+  }
+
+  @Test
+  public void parseTarget() {
+    assertThat(
+            LanguageVersion.fromJavacopts(
+                    ImmutableList.of("--release", "12", "-source", "8", "-target", "11"))
+                .target())
+        .isEqualTo(11);
+    assertThat(
+            LanguageVersion.fromJavacopts(
+                    ImmutableList.of("-source", "8", "-target", "11", "--release", "12"))
+                .target())
+        .isEqualTo(12);
+  }
+
+  @Test
+  public void releaseUnderride() {
+    assertThat(
+            LanguageVersion.fromJavacopts(ImmutableList.of("--release", "12", "-source", "8"))
+                .release())
+        .isEmpty();
+    assertThat(
+            LanguageVersion.fromJavacopts(ImmutableList.of("--release", "12", "-target", "8"))
+                .release())
+        .isEmpty();
+  }
+
+  @Test
+  public void unsupportedSourceVersion() {
+    LanguageVersion languageVersion =
+        LanguageVersion.fromJavacopts(ImmutableList.of("-source", "9999"));
+    IllegalArgumentException expected =
+        assertThrows(IllegalArgumentException.class, languageVersion::sourceVersion);
+    assertThat(expected).hasMessageThat().contains("invalid -source version:");
+  }
+}
diff --git a/javatests/com/google/turbine/options/TurbineOptionsTest.java b/javatests/com/google/turbine/options/TurbineOptionsTest.java
index 5d892c5..95eea59 100644
--- a/javatests/com/google/turbine/options/TurbineOptionsTest.java
+++ b/javatests/com/google/turbine/options/TurbineOptionsTest.java
@@ -304,28 +304,6 @@
   }
 
   @Test
-  public void releaseJavacopts() throws Exception {
-    TurbineOptions options =
-        TurbineOptionsParser.parse(
-            Iterables.concat(
-                BASE_ARGS,
-                Arrays.asList(
-                    "--release",
-                    "9",
-                    "--javacopts",
-                    "--release",
-                    "8",
-                    "--release",
-                    "7",
-                    "--release",
-                    "--")));
-    assertThat(options.release()).hasValue("7");
-    assertThat(options.javacOpts())
-        .containsExactly("--release", "8", "--release", "7", "--release")
-        .inOrder();
-  }
-
-  @Test
   public void miscOutputs() throws Exception {
     TurbineOptions options =
         TurbineOptionsParser.parse(
diff --git a/javatests/com/google/turbine/parse/ExpressionParserTest.java b/javatests/com/google/turbine/parse/ExpressionParserTest.java
index 9fa96e2..7b5889b 100644
--- a/javatests/com/google/turbine/parse/ExpressionParserTest.java
+++ b/javatests/com/google/turbine/parse/ExpressionParserTest.java
@@ -39,7 +39,7 @@
             "14 + 42", "(14 + 42)",
           },
           {
-            "14 + 42 + 123", "((14 + 42) + 123)",
+            "14 + 42 + 123", "(14 + 42 + 123)",
           },
           {
             "14 / 42 + 123", "((14 / 42) + 123)",
@@ -49,7 +49,7 @@
           },
           {
             "1 + 2 / 3 + 4 / 5 + 6 / 7 / 8 + 9 + 10",
-            "(((((1 + (2 / 3)) + (4 / 5)) + ((6 / 7) / 8)) + 9) + 10)",
+            "(1 + (2 / 3) + (4 / 5) + (6 / 7 / 8) + 9 + 10)",
           },
           {
             "1 >> 2 || 3 ^ 4 << 3", "((1 >> 2) || (3 ^ (4 << 3)))",
@@ -64,7 +64,7 @@
             "((Object) 1 + 2)", "((Object) 1 + 2)",
           },
           {
-            "(1) + 1 + 2", "((1 + 1) + 2)",
+            "(1) + 1 + 2", "(1 + 1 + 2)",
           },
           {
             "((1 + 2) / (1 + 2))", "((1 + 2) / (1 + 2))",
@@ -82,7 +82,7 @@
             "(int) +2", "(int) +2",
           },
           {
-            "(1 + 2) +2", "((1 + 2) + 2)",
+            "(1 + 2) + 2", "((1 + 2) + 2)",
           },
           {
             "((1 + (2 / 3)) + (4 / 5))", "((1 + (2 / 3)) + (4 / 5))",
@@ -121,7 +121,7 @@
             "x.y = z", null,
           },
           {
-            "0b100L + 0100L + 0x100L", "((4L + 64L) + 256L)",
+            "0b100L + 0100L + 0x100L", "(4L + 64L + 256L)",
           },
           {
             "1+-2", "(1 + -2)",
@@ -132,6 +132,9 @@
           {
             "A ? B : C ? D : E;", "(A ? B : (C ? D : E))",
           },
+          {
+            "Foo.class", "Foo.class",
+          },
         });
   }
 
@@ -146,7 +149,8 @@
   @Test
   public void test() {
     StreamLexer lexer = new StreamLexer(new UnicodeEscapePreprocessor(new SourceFile(null, input)));
-    Tree.Expression expression = new ConstExpressionParser(lexer, lexer.next()).expression();
+    Tree.Expression expression =
+        new ConstExpressionParser(lexer, lexer.next(), lexer.position()).expression();
     if (expected == null) {
       assertThat(expression).isNull();
     } else {
diff --git a/javatests/com/google/turbine/parse/ParseErrorTest.java b/javatests/com/google/turbine/parse/ParseErrorTest.java
index eeb3923..2c48b81 100644
--- a/javatests/com/google/turbine/parse/ParseErrorTest.java
+++ b/javatests/com/google/turbine/parse/ParseErrorTest.java
@@ -35,7 +35,7 @@
     StreamLexer lexer =
         new StreamLexer(
             new UnicodeEscapePreprocessor(new SourceFile("<>", String.valueOf("2147483648"))));
-    ConstExpressionParser parser = new ConstExpressionParser(lexer, lexer.next());
+    ConstExpressionParser parser = new ConstExpressionParser(lexer, lexer.next(), lexer.position());
     TurbineError e = assertThrows(TurbineError.class, () -> parser.expression());
     assertThat(e).hasMessageThat().contains("invalid literal");
   }
@@ -45,7 +45,7 @@
     StreamLexer lexer =
         new StreamLexer(
             new UnicodeEscapePreprocessor(new SourceFile("<>", String.valueOf("0x100000000"))));
-    ConstExpressionParser parser = new ConstExpressionParser(lexer, lexer.next());
+    ConstExpressionParser parser = new ConstExpressionParser(lexer, lexer.next(), lexer.position());
     TurbineError e = assertThrows(TurbineError.class, () -> parser.expression());
     assertThat(e).hasMessageThat().contains("invalid literal");
   }
@@ -294,6 +294,19 @@
                 "   ^"));
   }
 
+  @Test
+  public void notCast() {
+    String input = "@j(@truetugt^(oflur)!%t";
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: could not evaluate constant expression",
+                "@j(@truetugt^(oflur)!%t",
+                "                     ^"));
+  }
+
   private static String lines(String... lines) {
     return Joiner.on(System.lineSeparator()).join(lines);
   }
diff --git a/javatests/com/google/turbine/parse/ParserIntegrationTest.java b/javatests/com/google/turbine/parse/ParserIntegrationTest.java
index 2503553..c758a74 100644
--- a/javatests/com/google/turbine/parse/ParserIntegrationTest.java
+++ b/javatests/com/google/turbine/parse/ParserIntegrationTest.java
@@ -42,6 +42,7 @@
   public static Iterable<Object[]> parameters() {
     String[] tests = {
       "anno1.input",
+      "anno2.input",
       "annodecl1.input",
       "annodecl2.input",
       "annodecl3.input",
@@ -75,6 +76,8 @@
       "weirdstring.input",
       "type_annotations.input",
       "module-info.input",
+      "record.input",
+      "sealed.input",
     };
     return Iterables.transform(
         Arrays.asList(tests),
diff --git a/javatests/com/google/turbine/parse/testdata/anno2.input b/javatests/com/google/turbine/parse/testdata/anno2.input
new file mode 100644
index 0000000..60b1901
--- /dev/null
+++ b/javatests/com/google/turbine/parse/testdata/anno2.input
@@ -0,0 +1,3 @@
+@Foo(bar = FooBar.Bar[].class)
+class Test {
+}
\ No newline at end of file
diff --git a/javatests/com/google/turbine/parse/testdata/record.input b/javatests/com/google/turbine/parse/testdata/record.input
new file mode 100644
index 0000000..575d741
--- /dev/null
+++ b/javatests/com/google/turbine/parse/testdata/record.input
@@ -0,0 +1,53 @@
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import java.util.List;
+
+private record R() {
+}
+
+class Records {
+  record R1() {
+  }
+
+  private record R2() {
+  }
+
+  @Deprecated
+  private record R3() {
+  }
+
+  record R4<T>() {
+  }
+
+  record R5<T>(int x) {
+  }
+
+  record R6<T>(@Deprecated int x) {
+  }
+
+  record R7<T>(@Deprecated int x, int[] y) {
+  }
+
+  record R8<T>() implements Comparable<R8<T>> {
+    @Override
+    public int compareTo(R8<T> other) {}
+  }
+
+  record R9(int x) {
+  }
+
+  @Target(ElementType.TYPE_USE)
+  @interface A {
+  }
+
+  @Target(ElementType.RECORD_COMPONENT)
+  @interface B {
+  }
+
+  @Target({ElementType.TYPE_USE, ElementType.RECORD_COMPONENT})
+  @interface C {
+  }
+
+  record R10<T>(@A List<@A T> x, @B int y, @C int z) {
+  }
+}
diff --git a/javatests/com/google/turbine/parse/testdata/sealed.input b/javatests/com/google/turbine/parse/testdata/sealed.input
new file mode 100644
index 0000000..5a277b8
--- /dev/null
+++ b/javatests/com/google/turbine/parse/testdata/sealed.input
@@ -0,0 +1,15 @@
+sealed class Sealed permits Sealed.Foo, Sealed.Bar {
+  static final class Foo extends Sealed {
+  }
+
+  static final class Bar extends Sealed {
+  }
+}
+
+sealed interface ISealed permits ISealed.Foo, ISealed.Bar {
+  static final class Foo implements ISealed {
+  }
+
+  static non-sealed class Bar implements ISealed {
+  }
+}
diff --git a/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java b/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java
index d3b3836..7d8d479 100644
--- a/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java
+++ b/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java
@@ -345,6 +345,21 @@
                 "  void h(T t) {}",
                 "}"));
 
+    // type variable bounds
+    files.add(
+        Joiner.on('\n')
+            .join(
+                "import java.util.List;",
+                "class N<X, T extends X> {",
+                "  void h(T t) {}",
+                "}",
+                "class O<X extends Enum<X>, T extends X> {",
+                "  void h(T t) {}",
+                "}",
+                "class P<X extends List<?>, T extends X> {",
+                "  void h(T t) {}",
+                "}"));
+
     Context context = new Context();
     JavaFileManager fileManager = new JavacFileManager(context, true, UTF_8);
     idx.set(0);
diff --git a/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java b/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java
index 96664d2..fee2c75 100644
--- a/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java
+++ b/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java
@@ -23,7 +23,10 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
+import static javax.lang.model.util.ElementFilter.methodsIn;
+import static javax.lang.model.util.ElementFilter.typesIn;
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
@@ -53,7 +56,10 @@
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.lang.model.SourceVersion;
 import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.TypeElement;
+import javax.lang.model.type.ExecutableType;
 import javax.tools.Diagnostic;
 import javax.tools.JavaFileObject;
 import javax.tools.StandardLocation;
@@ -614,6 +620,93 @@
         .containsExactly("@Deprecated({})");
   }
 
+  @SupportedAnnotationTypes("*")
+  public static class RecordProcessor extends AbstractProcessor {
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+      for (Element e : roundEnv.getRootElements()) {
+        processingEnv
+            .getMessager()
+            .printMessage(
+                Diagnostic.Kind.ERROR,
+                e.getKind() + " " + e + " " + ((TypeElement) e).getSuperclass());
+        for (Element m : e.getEnclosedElements()) {
+          processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, m.getKind() + " " + m);
+        }
+      }
+      return false;
+    }
+  }
+
+  @Test
+  public void recordProcessing() throws IOException {
+    assumeTrue(Runtime.version().feature() >= 15);
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== R.java ===", //
+            "record R<T>(@Deprecated T x, int... y) {}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    ProcessorInfo.create(
+                        ImmutableList.of(new RecordProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    assertThat(
+            e.diagnostics().stream()
+                .filter(d -> d.severity().equals(Diagnostic.Kind.ERROR))
+                .map(d -> d.message()))
+        .containsExactly(
+            "RECORD R java.lang.Record",
+            "RECORD_COMPONENT x",
+            "RECORD_COMPONENT y",
+            "CONSTRUCTOR R(T,int[])",
+            "METHOD toString()",
+            "METHOD hashCode()",
+            "METHOD equals(java.lang.Object)",
+            "METHOD x()",
+            "METHOD y()");
+  }
+
+  @Test
+  public void missingElementValue() {
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== T.java ===", //
+            "import java.lang.annotation.Retention;",
+            "@Retention() @interface T {}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    ProcessorInfo.create(
+                        // missing annotation arguments are not a recoverable error, annotation
+                        // processing shouldn't happen
+                        ImmutableList.of(new CrashingProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    assertThat(e.diagnostics().stream().map(d -> d.message()))
+        .containsExactly("missing required annotation argument: value");
+  }
+
   private static ImmutableList<Tree.CompUnit> parseUnit(String... lines) {
     return IntegrationTestSupport.TestInput.parse(Joiner.on('\n').join(lines))
         .sources
@@ -623,4 +716,92 @@
         .map(Parser::parse)
         .collect(toImmutableList());
   }
+
+  @SupportedAnnotationTypes("*")
+  public static class AllMethodsProcessor extends AbstractProcessor {
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+
+      ImmutableList<ExecutableElement> methods =
+          typesIn(roundEnv.getRootElements()).stream()
+              .flatMap(t -> methodsIn(t.getEnclosedElements()).stream())
+              .collect(toImmutableList());
+      for (ExecutableElement a : methods) {
+        for (ExecutableElement b : methods) {
+          if (a.equals(b)) {
+            continue;
+          }
+          ExecutableType ta = (ExecutableType) a.asType();
+          ExecutableType tb = (ExecutableType) b.asType();
+          boolean r = processingEnv.getTypeUtils().isSubsignature(ta, tb);
+          processingEnv
+              .getMessager()
+              .printMessage(
+                  Diagnostic.Kind.ERROR,
+                  String.format(
+                      "%s#%s%s <: %s#%s%s ? %s",
+                      a.getEnclosingElement(),
+                      a.getSimpleName(),
+                      ta,
+                      b.getEnclosingElement(),
+                      b.getSimpleName(),
+                      tb,
+                      r));
+        }
+      }
+      return false;
+    }
+  }
+
+  @Test
+  public void bound() {
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== A.java ===", //
+            "import java.util.List;",
+            "class A<T> {",
+            "  <U extends T> U f(List<U> list) {",
+            "    return list.get(0);",
+            "  }",
+            "}",
+            "class B extends A<String> {",
+            "  @Override",
+            "  <U extends String> U f(List<U> list) {",
+            "    return super.f(list);",
+            "  }",
+            "}",
+            "class C extends A<Object> {",
+            "  @Override",
+            "  <U> U f(List<U> list) {",
+            "    return super.f(list);",
+            "  }",
+            "}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    ProcessorInfo.create(
+                        ImmutableList.of(new AllMethodsProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    assertThat(e.diagnostics().stream().map(d -> d.message()))
+        .containsExactly(
+            "A#f<U>(java.util.List<U>)U <: B#f<U>(java.util.List<U>)U ? false",
+            "A#f<U>(java.util.List<U>)U <: C#f<U>(java.util.List<U>)U ? false",
+            "B#f<U>(java.util.List<U>)U <: A#f<U>(java.util.List<U>)U ? false",
+            "B#f<U>(java.util.List<U>)U <: C#f<U>(java.util.List<U>)U ? false",
+            "C#f<U>(java.util.List<U>)U <: A#f<U>(java.util.List<U>)U ? false",
+            "C#f<U>(java.util.List<U>)U <: B#f<U>(java.util.List<U>)U ? false");
+  }
 }
diff --git a/javatests/com/google/turbine/processing/TurbineAnnotationMirrorTest.java b/javatests/com/google/turbine/processing/TurbineAnnotationMirrorTest.java
index a049860..b8daced 100644
--- a/javatests/com/google/turbine/processing/TurbineAnnotationMirrorTest.java
+++ b/javatests/com/google/turbine/processing/TurbineAnnotationMirrorTest.java
@@ -20,6 +20,7 @@
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.auto.common.AnnotationValues;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -134,17 +135,17 @@
 
           @Override
           public Object visitType(TypeMirror t, Void unused) {
-            return value.toString();
+            return AnnotationValues.toString(value);
           }
 
           @Override
           public Object visitEnumConstant(VariableElement c, Void unused) {
-            return value.toString();
+            return AnnotationValues.toString(value);
           }
 
           @Override
           public Object visitAnnotation(AnnotationMirror a, Void unused) {
-            return value.toString();
+            return AnnotationValues.toString(value);
           }
 
           @Override
diff --git a/javatests/com/google/turbine/processing/TurbineElementsHidesTest.java b/javatests/com/google/turbine/processing/TurbineElementsHidesTest.java
new file mode 100644
index 0000000..55e9039
--- /dev/null
+++ b/javatests/com/google/turbine/processing/TurbineElementsHidesTest.java
@@ -0,0 +1,318 @@
+/*
+ * Copyright 2019 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.processing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Arrays.stream;
+import static org.junit.Assert.fail;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ObjectArrays;
+import com.google.common.truth.Expect;
+import com.google.turbine.binder.Binder;
+import com.google.turbine.binder.ClassPathBinder;
+import com.google.turbine.binder.bound.TypeBoundClass;
+import com.google.turbine.binder.env.CompoundEnv;
+import com.google.turbine.binder.env.Env;
+import com.google.turbine.binder.env.SimpleEnv;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.diag.SourceFile;
+import com.google.turbine.lower.IntegrationTestSupport;
+import com.google.turbine.lower.IntegrationTestSupport.TestInput;
+import com.google.turbine.parse.Parser;
+import com.google.turbine.processing.TurbineElement.TurbineTypeElement;
+import com.google.turbine.testing.TestClassPaths;
+import com.google.turbine.tree.Tree.CompUnit;
+import com.sun.source.util.JavacTask;
+import com.sun.source.util.TaskEvent;
+import com.sun.source.util.TaskListener;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.Name;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.util.ElementScanner8;
+import javax.lang.model.util.Elements;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaFileObject;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class TurbineElementsHidesTest {
+
+  @Rule public final Expect expect = Expect.create();
+
+  @Parameters
+  public static Iterable<TestInput[]> parameters() {
+    // An array of test inputs. Each element is an array of lines of sources to compile.
+    String[][] inputs = {
+      {
+        "=== A.java ===", //
+        "abstract class A {",
+        "  int f;",
+        "  static int f() { return 1; }",
+        "  static int f(int x) { return 1; }",
+        "}",
+        "=== B.java ===",
+        "abstract class B extends A {",
+        "  int f;",
+        "  int g;",
+        "  static int f() { return 1; }",
+        "  static int f(int x) { return 1; }",
+        "  static int g() { return 1; }",
+        "  static int g(int x) { return 1; }",
+        "}",
+        "=== C.java ===",
+        "abstract class C extends B {",
+        "  int f;",
+        "  int g;",
+        "  int h;",
+        "  static int f() { return 1; }",
+        "  static int g() { return 1; }",
+        "  static int h() { return 1; }",
+        "  static int f(int x) { return 1; }",
+        "  static int g(int x) { return 1; }",
+        "  static int h(int x) { return 1; }",
+        "}",
+      },
+      {
+        "=== A.java ===",
+        "class A {",
+        "  class I {",
+        "  }",
+        "}",
+        "=== B.java ===",
+        "class B extends A {",
+        "  class I extends A.I {",
+        "  }",
+        "}",
+        "=== C.java ===",
+        "class C extends B {",
+        "  class I extends B.I {",
+        "  }",
+        "}",
+      },
+      {
+        "=== A.java ===",
+        "class A {",
+        "  class I {",
+        "  }",
+        "}",
+        "=== B.java ===",
+        "class B extends A {",
+        "  interface I {}",
+        "}",
+        "=== C.java ===",
+        "class C extends B {",
+        "  @interface I {}",
+        "}",
+      },
+      {
+        // the containing class or interface of Intf.foo is an interface
+        "=== Outer.java ===",
+        "class Outer {",
+        "  static class Inner {",
+        "    static void foo() {}",
+        "    static class Innerer extends Inner {",
+        "      interface Intf {",
+        "        static void foo() {}",
+        "      }",
+        "    }",
+        "  }",
+        "}",
+      },
+      {
+        // test two top-level classes with the same name
+        "=== one/A.java ===",
+        "package one;",
+        "public class A {",
+        "}",
+        "=== two/A.java ===",
+        "package two;",
+        "public class A {",
+        "}",
+      },
+    };
+    // https://bugs.openjdk.java.net/browse/JDK-8275746
+    if (Runtime.version().feature() >= 11) {
+      inputs =
+          ObjectArrays.concat(
+              inputs,
+              new String[][] {
+                {
+                  // interfaces
+                  "=== A.java ===",
+                  "interface A {",
+                  "  static void f() {}",
+                  "  int x = 42;",
+                  "}",
+                  "=== B.java ===",
+                  "interface B extends A {",
+                  "  static void f() {}",
+                  "  int x = 42;",
+                  "}",
+                }
+              },
+              String[].class);
+    }
+    return stream(inputs)
+        .map(input -> TestInput.parse(Joiner.on('\n').join(input)))
+        .map(x -> new TestInput[] {x})
+        .collect(toImmutableList());
+  }
+
+  private final TestInput input;
+
+  public TurbineElementsHidesTest(TestInput input) {
+    this.input = input;
+  }
+
+  // Compile the test inputs with javac and turbine, and assert that 'hides' returns the same
+  // results under each implementation.
+  @Test
+  public void test() throws Exception {
+    HidesTester javac = runJavac();
+    HidesTester turbine = runTurbine();
+    assertThat(javac.keys()).containsExactlyElementsIn(turbine.keys());
+    for (String k1 : javac.keys()) {
+      for (String k2 : javac.keys()) {
+        expect
+            .withMessage("hides(%s, %s)", k1, k2)
+            .that(javac.test(k1, k2))
+            .isEqualTo(turbine.test(k1, k2));
+      }
+    }
+  }
+
+  static class HidesTester {
+    // The elements for a particular annotation processing implementation
+    final Elements elements;
+    // A collection of Elements to use as test inputs, keyed by unique strings that can be used to
+    // compare them across processing implementations
+    final ImmutableMap<String, Element> inputs;
+
+    HidesTester(Elements elements, ImmutableMap<String, Element> inputs) {
+      this.elements = elements;
+      this.inputs = inputs;
+    }
+
+    boolean test(String k1, String k2) {
+      return elements.hides(inputs.get(k1), inputs.get(k2));
+    }
+
+    public ImmutableSet<String> keys() {
+      return inputs.keySet();
+    }
+  }
+
+  /** Compiles the test input with turbine. */
+  private HidesTester runTurbine() throws IOException {
+    ImmutableList<CompUnit> units =
+        input.sources.entrySet().stream()
+            .map(e -> new SourceFile(e.getKey(), e.getValue()))
+            .map(Parser::parse)
+            .collect(toImmutableList());
+    Binder.BindingResult bound =
+        Binder.bind(
+            units,
+            ClassPathBinder.bindClasspath(ImmutableList.of()),
+            TestClassPaths.TURBINE_BOOTCLASSPATH,
+            Optional.empty());
+    Env<ClassSymbol, TypeBoundClass> env =
+        CompoundEnv.<ClassSymbol, TypeBoundClass>of(bound.classPathEnv())
+            .append(new SimpleEnv<>(bound.units()));
+    ModelFactory factory = new ModelFactory(env, ClassLoader.getSystemClassLoader(), bound.tli());
+    TurbineTypes turbineTypes = new TurbineTypes(factory);
+    TurbineElements elements = new TurbineElements(factory, turbineTypes);
+    ImmutableList<TurbineTypeElement> typeElements =
+        bound.units().keySet().stream().map(factory::typeElement).collect(toImmutableList());
+    return new HidesTester(elements, collectElements(typeElements));
+  }
+
+  /** Compiles the test input with turbine. */
+  private HidesTester runJavac() throws Exception {
+    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
+    JavacTask javacTask =
+        IntegrationTestSupport.runJavacAnalysis(
+            input.sources, ImmutableList.of(), ImmutableList.of(), diagnostics);
+    List<TypeElement> typeElements = new ArrayList<>();
+    javacTask.addTaskListener(
+        new TaskListener() {
+          @Override
+          public void started(TaskEvent e) {
+            if (e.getKind().equals(TaskEvent.Kind.ANALYZE)) {
+              typeElements.add(e.getTypeElement());
+            }
+          }
+        });
+    Elements elements = javacTask.getElements();
+    if (!javacTask.call()) {
+      fail(Joiner.on("\n").join(diagnostics.getDiagnostics()));
+    }
+    return new HidesTester(elements, collectElements(typeElements));
+  }
+
+  /** Scans a test compilation for elements to use as test inputs. */
+  private ImmutableMap<String, Element> collectElements(List<? extends TypeElement> typeElements) {
+    Map<String, Element> elements = new HashMap<>();
+    for (TypeElement typeElement : typeElements) {
+      elements.put(key(typeElement), typeElement);
+      new ElementScanner8<Void, Void>() {
+        @Override
+        public Void scan(Element e, Void unused) {
+          Element p = elements.put(key(e), e);
+          if (p != null && !e.equals(p) && !p.getKind().equals(ElementKind.CONSTRUCTOR)) {
+            throw new AssertionError(key(e) + " " + p + " " + e);
+          }
+          return super.scan(e, unused);
+        }
+      }.visit(typeElement);
+    }
+    return ImmutableMap.copyOf(elements);
+  }
+
+  /** A unique string representation of an element. */
+  private static String key(Element e) {
+    ArrayDeque<Name> names = new ArrayDeque<>();
+    Element curr = e;
+    do {
+      if (curr.getSimpleName().length() > 0) {
+        names.addFirst(curr.getSimpleName());
+      }
+      curr = curr.getEnclosingElement();
+    } while (curr != null);
+    String key = e.getKind() + ":" + Joiner.on('.').join(names);
+    if (e.getKind().equals(ElementKind.METHOD)) {
+      key += ":" + e.asType();
+    }
+    return key;
+  }
+}
diff --git a/javatests/com/google/turbine/processing/TurbineFilerTest.java b/javatests/com/google/turbine/processing/TurbineFilerTest.java
index d433428..83dcc70 100644
--- a/javatests/com/google/turbine/processing/TurbineFilerTest.java
+++ b/javatests/com/google/turbine/processing/TurbineFilerTest.java
@@ -40,7 +40,7 @@
 import javax.tools.FileObject;
 import javax.tools.JavaFileObject;
 import javax.tools.StandardLocation;
-import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jspecify.nullness.Nullable;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -56,9 +56,8 @@
   public void setup() {
     Function<String, Supplier<byte[]>> classpath =
         new Function<String, Supplier<byte[]>>() {
-          @Nullable
           @Override
-          public Supplier<byte[]> apply(String input) {
+          public @Nullable Supplier<byte[]> apply(String input) {
             return null;
           }
         };
@@ -100,9 +99,9 @@
 
     assertThrows(
         FilerException.class, () -> filer.createSourceFile("com.foo.Bar", (Element[]) null));
-    filer.createSourceFile("com.foo.Baz", (Element[]) null);
+    JavaFileObject unused = filer.createSourceFile("com.foo.Baz", (Element[]) null);
 
-    filer.createClassFile("com.foo.Bar", (Element[]) null);
+    unused = filer.createClassFile("com.foo.Bar", (Element[]) null);
     assertThrows(
         FilerException.class, () -> filer.createClassFile("com.foo.Baz", (Element[]) null));
   }
@@ -125,7 +124,7 @@
     try (Writer writer = classFile.openWriter()) {
       writer.write("hello");
     }
-    filer.finishRound();
+    Collection<SourceFile> unused = filer.finishRound();
 
     FileObject output = filer.getResource(StandardLocation.SOURCE_OUTPUT, "com.foo", "Bar.java");
     assertThat(new String(ByteStreams.toByteArray(output.openInputStream()), UTF_8))
@@ -140,7 +139,7 @@
     try (OutputStream os = classFile.openOutputStream()) {
       os.write("goodbye".getBytes(UTF_8));
     }
-    filer.finishRound();
+    Collection<SourceFile> unused = filer.finishRound();
 
     FileObject output = filer.getResource(StandardLocation.CLASS_OUTPUT, "com.foo", "Baz.class");
     assertThat(new String(ByteStreams.toByteArray(output.openInputStream()), UTF_8))
diff --git a/javatests/com/google/turbine/processing/TurbineMessagerTest.java b/javatests/com/google/turbine/processing/TurbineMessagerTest.java
index 017012c..c9ca26f 100644
--- a/javatests/com/google/turbine/processing/TurbineMessagerTest.java
+++ b/javatests/com/google/turbine/processing/TurbineMessagerTest.java
@@ -20,7 +20,10 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
+import static org.junit.Assert.assertThrows;
 
+import com.google.auto.common.AnnotationMirrors;
+import com.google.auto.common.AnnotationValues;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -134,7 +137,13 @@
                 processingEnv
                     .getMessager()
                     .printMessage(
-                        Diagnostic.Kind.ERROR, String.format("%s %s %s", e, a, av), e, a, av);
+                        Diagnostic.Kind.ERROR,
+                        String.format(
+                            "%s %s %s",
+                            e, AnnotationMirrors.toString(a), AnnotationValues.toString(av)),
+                        e,
+                        a,
+                        av);
                 av.accept(
                     new SimpleAnnotationValueVisitor8<Void, Void>() {
                       @Override
@@ -199,35 +208,33 @@
             .map(TurbineMessagerTest::formatDiagnostic)
             .collect(toImmutableList());
 
-    ImmutableList<String> turbineDiagnostics;
     ImmutableList<Tree.CompUnit> units =
         SOURCES.sources.entrySet().stream()
             .map(e -> new SourceFile(e.getKey(), e.getValue()))
             .map(Parser::parse)
             .collect(toImmutableList());
-    try {
-      Binder.bind(
-          units,
-          ClassPathBinder.bindClasspath(ImmutableList.of()),
-          Processing.ProcessorInfo.create(
-              ImmutableList.of(new DiagnosticTesterProcessor()),
-              getClass().getClassLoader(),
-              ImmutableMap.of(),
-              SourceVersion.latestSupported()),
-          TestClassPaths.TURBINE_BOOTCLASSPATH,
-          Optional.empty());
-      throw new AssertionError();
-    } catch (TurbineError e) {
-      turbineDiagnostics =
-          e.diagnostics().stream()
-              .sorted(
-                  comparing(TurbineDiagnostic::path)
-                      .thenComparing(TurbineDiagnostic::line)
-                      .thenComparing(TurbineDiagnostic::column))
-              .map(TurbineMessagerTest::formatDiagnostic)
-              .collect(toImmutableList());
-    }
-
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    Processing.ProcessorInfo.create(
+                        ImmutableList.of(new DiagnosticTesterProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    ImmutableList<String> turbineDiagnostics =
+        e.diagnostics().stream()
+            .sorted(
+                comparing(TurbineDiagnostic::path)
+                    .thenComparing(TurbineDiagnostic::line)
+                    .thenComparing(TurbineDiagnostic::column))
+            .map(TurbineMessagerTest::formatDiagnostic)
+            .collect(toImmutableList());
     assertThat(turbineDiagnostics).containsExactlyElementsIn(javacDiagnostics).inOrder();
   }
 
diff --git a/javatests/com/google/turbine/testing/TestClassPaths.java b/javatests/com/google/turbine/testing/TestClassPaths.java
index 55e8b9e..56b471c 100644
--- a/javatests/com/google/turbine/testing/TestClassPaths.java
+++ b/javatests/com/google/turbine/testing/TestClassPaths.java
@@ -23,6 +23,7 @@
 import com.google.turbine.binder.ClassPath;
 import com.google.turbine.binder.ClassPathBinder;
 import com.google.turbine.binder.JimageClassBinder;
+import com.google.turbine.options.LanguageVersion;
 import com.google.turbine.options.TurbineOptions;
 import java.io.File;
 import java.io.IOException;
@@ -68,7 +69,7 @@
       options.setBootClassPath(
           BOOTCLASSPATH.stream().map(Path::toString).collect(toImmutableList()));
     } else {
-      options.setRelease("8");
+      options.setLanguageVersion(LanguageVersion.fromJavacopts(ImmutableList.of("--release", "8")));
     }
     return options;
   }
diff --git a/javatests/com/google/turbine/zip/ZipTest.java b/javatests/com/google/turbine/zip/ZipTest.java
index 0d49e1a..e9dfc44 100644
--- a/javatests/com/google/turbine/zip/ZipTest.java
+++ b/javatests/com/google/turbine/zip/ZipTest.java
@@ -159,7 +159,7 @@
       createEntry(zos, "hello", "world".getBytes(UTF_8));
       zos.setComment("this is a comment");
     }
-    Files.write(path, "trailing garbage".getBytes(UTF_8), StandardOpenOption.APPEND);
+    Files.writeString(path, "trailing garbage", StandardOpenOption.APPEND);
 
     ZipException e = assertThrows(ZipException.class, () -> actual(path));
     assertThat(e).hasMessageThat().isEqualTo("zip file comment length was 33, expected 17");
diff --git a/pom.xml b/pom.xml
index dae4b70..b007e74 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,20 +26,31 @@
   <version>HEAD-SNAPSHOT</version>
 
   <name>turbine</name>
-  <description>
-    turbine is a header compiler for Java
-  </description>
+  <description>turbine is a header compiler for Java</description>
+  <url>https://github.com/google/turbine</url>
 
   <properties>
-    <asm.version>9.1</asm.version>
-    <javac.version>9+181-r4173-1</javac.version>
-    <guava.version>30.0-jre</guava.version>
-    <errorprone.version>2.7.1</errorprone.version>
-    <maven-javadoc-plugin.version>3.1.0</maven-javadoc-plugin.version>
+    <asm.version>9.2</asm.version>
+    <guava.version>31.0.1-jre</guava.version>
+    <errorprone.version>2.11.0</errorprone.version>
+    <maven-javadoc-plugin.version>3.3.1</maven-javadoc-plugin.version>
     <maven-source-plugin.version>3.2.1</maven-source-plugin.version>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    <protobuf.version>3.19.2</protobuf.version>
+    <grpc.version>1.43.2</grpc.version>
   </properties>
 
+  <organization>
+    <name>Google Inc.</name>
+    <url>http://www.google.com/</url>
+  </organization>
+
+  <developers>
+    <developer>
+      <name>Liam Miller-Cushon</name>
+    </developer>
+  </developers>
+
   <dependencies>
     <dependency>
       <groupId>com.google.guava</groupId>
@@ -52,15 +63,15 @@
       <version>${errorprone.version}</version>
     </dependency>
     <dependency>
-      <groupId>org.checkerframework</groupId>
-      <artifactId>checker-qual</artifactId>
-      <version>3.9.1</version>
+      <groupId>org.jspecify</groupId>
+      <artifactId>jspecify</artifactId>
+      <version>0.2.0</version>
       <optional>true</optional>
     </dependency>
     <dependency>
       <groupId>com.google.protobuf</groupId>
       <artifactId>protobuf-java</artifactId>
-      <version>3.10.0</version>
+      <version>${protobuf.version}</version>
     </dependency>
     <dependency>
       <groupId>org.ow2.asm</groupId>
@@ -81,33 +92,27 @@
       <scope>test</scope>
     </dependency>
     <dependency>
-      <groupId>com.google.errorprone</groupId>
-      <artifactId>javac</artifactId>
-      <version>${javac.version}</version>
-      <scope>test</scope>
-    </dependency>
-    <dependency>
       <groupId>junit</groupId>
       <artifactId>junit</artifactId>
-      <version>4.13.1</version>
+      <version>4.13.2</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.1</version>
+      <version>1.1.3</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>com.google.truth.extensions</groupId>
       <artifactId>truth-proto-extension</artifactId>
-      <version>1.1</version>
+      <version>1.1.3</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>com.google.truth.extensions</groupId>
       <artifactId>truth-java8-extension</artifactId>
-      <version>1.1</version>
+      <version>1.1.3</version>
       <scope>test</scope>
     </dependency>
     <dependency>
@@ -125,9 +130,15 @@
     <dependency>
       <groupId>com.google.auto.value</groupId>
       <artifactId>auto-value-annotations</artifactId>
-      <version>1.7.4</version>
+      <version>1.9</version>
       <scope>provided</scope>
     </dependency>
+    <dependency>
+      <groupId>com.google.auto</groupId>
+      <artifactId>auto-common</artifactId>
+      <version>1.2.1</version>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
@@ -146,32 +157,22 @@
       <extension>
         <groupId>kr.motd.maven</groupId>
         <artifactId>os-maven-plugin</artifactId>
-        <version>1.4.0.Final</version>
+        <version>1.7.0</version>
       </extension>
     </extensions>
     <plugins>
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-compiler-plugin</artifactId>
-        <version>3.8.0</version>
+        <version>3.9.0</version>
         <configuration>
           <source>8</source>
           <target>8</target>
           <encoding>UTF-8</encoding>
-          <fork>true</fork>
           <compilerArgs>
             <arg>-parameters</arg>
             <arg>-XDcompilePolicy=simple</arg>
             <arg>-Xplugin:ErrorProne</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
-            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
-            <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
           </compilerArgs>
           <annotationProcessorPaths>
             <path>
@@ -190,12 +191,12 @@
       <plugin>
         <groupId>org.xolstice.maven.plugins</groupId>
         <artifactId>protobuf-maven-plugin</artifactId>
-        <version>0.5.0</version>
+        <version>0.6.1</version>
         <configuration>
           <protoSourceRoot>proto</protoSourceRoot>
-          <protocArtifact>com.google.protobuf:protoc:3.1.0:exe:${os.detected.classifier}</protocArtifact>
+          <protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
           <pluginId>grpc-java</pluginId>
-          <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.0.1:exe:${os.detected.classifier}</pluginArtifact>
+          <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
         </configuration>
         <executions>
           <execution>
@@ -209,7 +210,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-surefire-plugin</artifactId>
-        <version>2.19.1</version>
+        <version>2.22.2</version>
         <configuration>
           <!-- set heap size to work around http://github.com/travis-ci/travis-ci/issues/3396 -->
           <argLine>
@@ -230,7 +231,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-shade-plugin</artifactId>
-        <version>2.4.3</version>
+        <version>3.2.4</version>
         <executions>
           <execution>
             <id>shade-all-deps</id>
@@ -260,7 +261,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-javadoc-plugin</artifactId>
-        <version>3.1.1</version>
+        <version>3.3.1</version>
         <configuration>
           <source>8</source>
           <detectJavaApiLink>false</detectJavaApiLink>
@@ -286,37 +287,6 @@
 
   <profiles>
     <profile>
-      <id>java-8</id>
-      <activation>
-        <jdk>1.8</jdk>
-      </activation>
-      <build>
-        <plugins>
-          <plugin>
-            <groupId>org.apache.maven.plugins</groupId>
-            <artifactId>maven-surefire-plugin</artifactId>
-            <configuration>
-              <!-- put javac.jar on bootclasspath when executing tests -->
-              <argLine>-Xbootclasspath/p:${settings.localRepository}/com/google/errorprone/javac/${javac.version}/javac-${javac.version}.jar</argLine>
-            </configuration>
-          </plugin>
-          <plugin>
-            <groupId>org.apache.maven.plugins</groupId>
-            <artifactId>maven-compiler-plugin</artifactId>
-            <configuration>
-              <fork>true</fork>
-              <compilerArgs>
-                <arg>-parameters</arg>
-                <arg>-XDcompilePolicy=simple</arg>
-                <arg>-Xplugin:ErrorProne</arg>
-                <arg>-J-Xbootclasspath/p:${settings.localRepository}/com/google/errorprone/javac/${javac.version}/javac-${javac.version}.jar</arg>
-              </compilerArgs>
-            </configuration>
-          </plugin>
-        </plugins>
-      </build>
-    </profile>
-    <profile>
       <id>sonatype-oss-release</id>
       <build>
         <plugins>
@@ -349,7 +319,7 @@
           <plugin>
             <groupId>org.apache.maven.plugins</groupId>
             <artifactId>maven-gpg-plugin</artifactId>
-            <version>1.6</version>
+            <version>3.0.1</version>
             <executions>
               <execution>
                 <id>sign-artifacts</id>