Replace InstrumentationStrategy with a design that separates:
- what contract applies to a value flow,
- whether the active runtime policy wants that flow enforced, and
- where bytecode should be injected to enforce it.
The current architecture mixes those concerns inside a single opcode-shaped interface. The replacement should keep the top-level framework structure intact while making the checker semantics and enforcement planning explicit.
The current InstrumentationStrategy API has become the place where all of the following happen at
once:
- Qualifier interpretation.
- Defaulting rules.
- Checked/unchecked boundary reasoning.
- Inheritance and bridge logic.
- Policy gating for standard vs global mode.
- Per-opcode injection decisions.
That collapse has already produced real problems:
- Unchecked override parameters are not enforced in global mode.
- Checked parent contracts disappear when dispatch lands in an unchecked child override.
- This is a real soundness hole.
- Array checks are context-free.
- Nullable arrays are forced through the default reference check.
- This causes false positives.
- Local variable checks key only on slot, not live range.
- Reused slots can inherit stale contracts from earlier locals.
- This causes false positives and makes local enforcement unreliable.
- Initialization does not compose cleanly.
- Commit-state checking belongs at the contract layer, not in a forked transform.
The refactor needs to address those issues directly, not just rename the interface.
- Keep the framework generic.
- The framework should understand value flows, not nullness-specific rules.
- Keep the checker responsible for qualifier semantics.
- The checker decides what a field, parameter, return, array component, or receiver requires.
- Keep the policy responsible for world boundaries and enabled enforcement modes.
- Standard vs global remains a policy concern.
- Move to a data-oriented planning model.
- The transform should emit typed events and consume typed plans.
- Make initialization a compositional extension.
- Commit-state should layer on top of property contracts.
- Make the migration incremental.
- The current tests should continue to pass while the old strategy path is removed in phases.
| Concept | Decision | Reason |
|---|---|---|
RuntimePolicy | Keep, expand | Already owns checked vs unchecked classification; should also gate flow-level enforcement. |
RuntimeChecker | Keep, narrow | Should provide checker semantics, not manufacture strategy objects. |
RuntimeInstrumenter | Keep, reposition | Still useful as the bytecode rewrite layer, but it should become framework-owned and plan-driven. |
CheckGenerator | Keep short-term | Useful as a low-level bytecode emission primitive; should sit behind a richer emitter API. |
InstrumentationStrategy | Remove | It mixes too many concerns and does not carry enough context. |
BoundaryStrategy | Remove | Its responsibilities should be split across contract resolution, policy, and planning. |
TypeSystemConfiguration | Replace | Binary ENFORCE/NOOP plus one default is too weak for arrays, locals, overrides, and lifecycle. |
RuntimeAgent
-> load RuntimeChecker
-> build RuntimePolicy
-> install RuntimeTransformer
RuntimeTransformer
-> classify class with RuntimePolicy
-> build ClassContext / MethodContext
-> invoke RuntimeInstrumenter
RuntimeInstrumenter
-> scan bytecode
-> normalize instructions into FlowEvents
-> ask EnforcementPlanner for MethodPlan / BridgePlan
-> emit InstrumentationActions
EnforcementPlanner
-> consult RuntimePolicy
-> consult CheckerSemantics
-> resolve TargetRef into ValueContract
-> produce checks and lifecycle hooks
CheckerSemantics
-> ContractResolver
-> PropertyEmitter
-> optional LifecycleSemantics
The critical shift is that the framework becomes responsible for value-flow planning, while the checker becomes responsible for contract meaning.
We keep RuntimeChecker, but its primary job changes from “build my instrumenter” to “describe my
semantics”.
public interface RuntimeChecker {
String getName();
CheckerSemantics getSemantics();
}Transitional note:
- During migration, we can temporarily keep
getInstrumenter(RuntimePolicy)as a compatibility method implemented in terms of the new planner. - End state should remove checker-owned instrumenter construction from the public API.
The checker contributes semantic knowledge, not rewrite logic.
public interface CheckerSemantics {
ContractResolver contracts();
PropertyEmitter emitter();
default LifecycleSemantics lifecycle() {
return LifecycleSemantics.none();
}
}Responsibilities:
- Interpret qualifier annotations and checker defaults.
- Define required runtime properties.
- Provide low-level bytecode emission for those properties.
- Optionally layer lifecycle properties such as commitment.
This replaces the “which check do I use here?” half of InstrumentationStrategy.
public interface ContractResolver {
ValueContract resolve(TargetRef target, ResolutionContext context);
}The resolver must receive enough context to answer correctly:
- Target kind.
- Declaring class/member.
- Bytecode position when needed.
- Class loader / module.
- Resolution environment for inherited contracts and metadata lookup.
This replaces the “emit invokestatic for this checker” half of CheckGenerator.
public interface PropertyEmitter {
void emitCheck(
CodeBuilder builder,
PropertyRequirement property,
ValueAccess access,
DiagnosticSpec diagnostic);
}This lets one checker emit different runtime checks for different properties:
NON_NULLCOMMITTED- Future properties such as
IMMUTABLEorUNIQUE
We keep RuntimePolicy, but extend it from class classification to flow gating.
public interface RuntimePolicy {
ClassClassification classify(ClassInfo info);
ClassClassification classify(ClassInfo info, ClassModel model);
default boolean allows(FlowEvent event) {
return true;
}
}Examples of policy-owned decisions:
- Standard mode allows method-entry checks inside checked code.
- Global mode additionally allows unchecked override enforcement and unchecked-to-checked field write checks.
- Future modes may enable or disable lifecycle hooks independently.
The policy should not interpret nullness or initialization semantics. It should only decide whether the framework is allowed to enforce a given flow.
We keep RuntimeInstrumenter, but make it a framework-owned scanner + emitter.
public abstract class RuntimeInstrumenter {
protected abstract MethodPlan planMethod(MethodContext context);
protected abstract List<BridgePlan> planBridges(ClassContext context);
}In practice, the main implementation will likely be a concrete framework class such as
PlanningRuntimeInstrumenter or EnforcementInstrumenter, backed by an EnforcementPlanner.
A FlowEvent represents one semantic value flow observed while scanning bytecode.
Suggested initial flow kinds:
METHOD_PARAMETERMETHOD_RETURNBOUNDARY_CALL_RETURNFIELD_READFIELD_WRITEARRAY_LOADARRAY_STORELOCAL_STOREBRIDGE_PARAMETERBRIDGE_RETURNOVERRIDE_PARAMETEROVERRIDE_RETURNCONSTRUCTOR_ENTERCONSTRUCTOR_COMMITBOUNDARY_RECEIVER_USE
Possible shape:
public sealed interface FlowEvent permits ... {
FlowKind kind();
MethodContext enclosingMethod();
BytecodeLocation location();
}Important rule:
FlowEventdescribes what value is crossing what boundary.- It does not decide yet whether a check is needed.
A TargetRef identifies the receiving contract for the flow.
Suggested variants:
MethodParameterRefMethodReturnRefFieldRefArrayComponentRefLocalRefReceiverRef
Possible shape:
public sealed interface TargetRef permits ... {}
public record MethodParameterRef(MethodRef method, int parameterIndex) implements TargetRef {}
public record FieldRef(FieldModel field) implements TargetRef {}
public record ArrayComponentRef(TypeDescriptor arrayType) implements TargetRef {}
public record LocalRef(int slot, int bytecodeOffset) implements TargetRef {}This is what allows arrays and locals to be modeled correctly. The current strategy API only sees
TypeKind.REFERENCE, which is not enough.
Contracts should no longer be modeled as ENFORCE or NOOP. They should be a set of required
runtime properties.
public record ValueContract(List<PropertyRequirement> requirements) {
public static ValueContract none() {
return new ValueContract(List.of());
}
public boolean isEmpty() {
return requirements.isEmpty();
}
}
public record PropertyRequirement(PropertyId propertyId) {}
public enum PropertyId {
NON_NULL,
COMMITTED
}Examples:
- Nullness
@NonNull->{ NON_NULL } - Nullness
@Nullable->{} - Nullness + initialization on a trusted boundary receiver ->
{ NON_NULL, COMMITTED }
This is the core compositional mechanism for future checker overlays.
The planner should return explicit actions, not raw checker callbacks.
public sealed interface InstrumentationAction permits ValueCheckAction, LifecycleHookAction {}
public record ValueCheckAction(
InjectionPoint injectionPoint,
ValueContract contract,
AttributionKind attribution,
DiagnosticSpec diagnostic) implements InstrumentationAction {}
public record LifecycleHookAction(
InjectionPoint injectionPoint,
LifecycleHook hook) implements InstrumentationAction {}
public record MethodPlan(List<InstrumentationAction> actions) {}
public record BridgePlan(MethodRef parentMethod, List<InstrumentationAction> actions) {}This gives the framework a stable plan format independent of checker semantics.
Before implementing the planner, we should introduce a shared ResolutionEnvironment.
It should centralize:
- Parsed
ClassModelcaching. - Field and method lookup by declaring owner.
- Ancestor traversal.
- Package/class annotation lookup.
- Local variable live-range queries.
- Loader-aware identity and module context.
This environment replaces repeated ad hoc classfile parsing currently spread across strategy and hierarchy logic.
Suggested API:
public interface ResolutionEnvironment {
Optional<ClassModel> loadClass(String internalName, ClassLoader loader);
Optional<FieldModel> findField(FieldOwnerRef owner, String name, String descriptor);
Optional<MethodModel> findMethod(MethodOwnerRef owner, String name, String descriptor);
Stream<MethodRef> overriddenMethods(MethodRef method);
Stream<LocalBinding> localsAt(MethodModel method, int bytecodeOffset, int slot);
}Nullness should move from:
TypeSystemConfigurationBoundaryStrategyNullnessCheckGenerator
to:
NullnessRuntimeCheckerNullnessSemanticsNullnessContractResolverNullnessPropertyEmitter
Example:
public final class NullnessRuntimeChecker implements RuntimeChecker {
@Override
public String getName() {
return "nullness";
}
@Override
public CheckerSemantics getSemantics() {
return new NullnessSemantics();
}
}public final class NullnessSemantics implements CheckerSemantics {
@Override
public ContractResolver contracts() {
return new NullnessContractResolver();
}
@Override
public PropertyEmitter emitter() {
return new NullnessPropertyEmitter();
}
}NullnessContractResolver should determine contracts based on the target kind, not a single
global default for all reference values. That is important because defaults for fields, returns,
locals, arrays, and lifecycle-aware receivers may diverge over time.
Under the new model:
- The transform emits an
OVERRIDE_PARAMETERevent for unchecked overrides of checked parent methods when policy allows it. - The planner resolves the parent method parameter contract through
ContractResolver. - The planner emits a
ValueCheckActionwith caller attribution.
This closes the current global-mode hole where checked parent parameter contracts disappear inside unchecked overrides.
Under the new model:
ARRAY_STOREandARRAY_LOADevents targetArrayComponentRef.- The resolver reads the component contract, not just
TypeKind.REFERENCE. - Nullable arrays can resolve to
ValueContract.none()while non-null arrays resolve to{ NON_NULL }.
Under the new model:
LOCAL_STOREevents include bytecode offset.LocalRef(slot, offset)is resolved against local-variable live ranges.- Stale annotations from reused slots no longer leak into later locals.
Under the new model:
- The lifecycle overlay augments contracts with
COMMITTEDwhen required. - The framework emits lifecycle hooks at constructor entry/commit and receiver use sites.
- Property composition remains checker-agnostic.
This is much cleaner than duplicating or subclassing the transform.
| Old API Method | New Representation |
|---|---|
getParameterCheck | METHOD_PARAMETER event + MethodParameterRef |
getReturnCheck | METHOD_RETURN event + MethodReturnRef |
getBoundaryCallCheck | BOUNDARY_CALL_RETURN event + callee MethodReturnRef |
getFieldReadCheck | FIELD_READ event + FieldRef |
getFieldWriteCheck | FIELD_WRITE event + FieldRef |
getBoundaryFieldReadCheck | FIELD_READ event + boundary-aware policy/planner |
getBoundaryFieldWriteCheck | FIELD_WRITE event + boundary-aware policy/planner |
getBridgeParameterCheck | BRIDGE_PARAMETER event |
getBridgeReturnCheck | BRIDGE_RETURN event |
getUncheckedOverrideReturnCheck | OVERRIDE_RETURN event |
| missing today | OVERRIDE_PARAMETER event |
getArrayStoreCheck | ARRAY_STORE event + ArrayComponentRef |
getArrayLoadCheck | ARRAY_LOAD event + ArrayComponentRef |
getLocalVariableWriteCheck | LOCAL_STORE event + LocalRef(slot, offset) |
This table is the core migration aid. Every current strategy hook becomes a typed flow event instead of a permanent API method.
Suggested package additions:
framework/semanticsCheckerSemanticsContractResolverPropertyEmitterLifecycleSemantics
framework/contractsValueContractPropertyRequirementPropertyId
framework/planningFlowEventFlowKindTargetRefEnforcementPlannerMethodPlanBridgePlanInstrumentationAction
framework/resolutionResolutionEnvironment- supporting lookup and cache classes
checker/nullnessNullnessSemanticsNullnessContractResolverNullnessPropertyEmitter
The following refactor phases are already implemented on framework-refactor:
- Shared resolution environment
- Added
ResolutionEnvironmentandCachingResolutionEnvironment. - Routed existing lookup paths through the shared environment.
- Commit:
14283ff(refactor: introduce shared resolution environment)
- Added
- Planner and contract model
- Added the contract model:
PropertyId,PropertyRequirement,ValueContract. - Added the planning model:
FlowEvent,TargetRef,MethodPlan,BridgePlan,InstrumentationAction, and related context/value-location types. - Commit:
6e58975(refactor: add planner and contract model types)
- Added the contract model:
- Compatibility planner over the legacy strategy path
- Added
EnforcementPlannerandStrategyBackedEnforcementPlanner. - Added the temporary bridge pieces needed to express the old behavior through planner output.
- Commit:
3007e3b(refactor: add strategy-backed enforcement planner)
- Added
- Method-body enforcement now routes through the planner
EnforcementTransformnow buildsFlowEventinstances and consumesMethodPlanactions.- The method-body runtime path no longer calls
InstrumentationStrategydirectly. - Commit:
5aeb550(refactor: route method enforcement through planner)
- Bridge generation now routes through the planner
EnforcementInstrumenternow asks the planner whether to synthesize a bridge and emits bridge-entry and bridge-exit checks fromBridgePlanactions.- This work was pulled forward from the original plan so the entire enforcement pipeline is now planner-driven, even though it is still backed by the legacy strategy adapter.
- Commit:
2c485f9(refactor: route bridge generation through planner)
At this point, the framework-owned instrumentation path is mostly in place. The remaining legacy
center of gravity is semantic resolution inside StrategyBackedEnforcementPlanner and
InstrumentationStrategy.
What is still left to do:
- Regression tests
- The planned tests for unchecked override parameters, nullable arrays, local slot reuse, and no-line-number classes have not been added yet.
- We intentionally deferred them to keep the refactor moving, but they still need to land before the behavior-changing phases.
- Introduce checker-owned semantics
- Add
CheckerSemantics,ContractResolver,PropertyEmitter, and any lifecycle overlay API. - Change
RuntimeCheckerso the end-state API is semantics-driven rather than strategy/instrumenter-driven.
- Add
- Port nullness off the strategy adapter
- Implement
NullnessSemantics,NullnessContractResolver, andNullnessPropertyEmitter. - Replace planner output based on legacy
CheckGeneratorcallbacks with planner-nativeValueCheckActionoutput for nullness.
- Implement
- Fix the unchecked-override-parameter hole
- Add real
OVERRIDE_PARAMETERplanning in global mode. - This is the first intended soundness-changing step.
- Add real
- Use richer targets to fix arrays and locals
- Resolve actual array component contracts instead of treating all reference arrays the same.
- Resolve locals by slot plus live range rather than slot alone.
- Add lifecycle composition
- Add constructor entry/commit hooks and boundary receiver hooks so initialization overlays can compose on top of the property model.
- Delete the migration scaffolding
- Remove
StrategyBackedEnforcementPlanner. - Remove
InstrumentationAction.LegacyCheckAction. - Remove
InstrumentationStrategy,BoundaryStrategy, and the oldTypeSystemConfigurationruntime path once the planner-native semantics are in place.
- Remove
Add tests for the problems that motivated the refactor:
- Unchecked override parameter parity in global mode.
- Nullable array store/load.
- Local variable slot reuse with different type annotations.
- No-line-number classes to make sure method-entry planning does not depend on debug tables.
Status:
- Deferred for now. These tests still need to be added before we start the behavior-changing phases.
Implement ResolutionEnvironment and start routing existing bytecode lookup through it.
Goals:
- Remove repeated classfile parsing.
- Make loader-aware lookups consistent.
- Provide a home for local-variable live-range resolution.
Status:
- Completed in
14283ff. - No intended observable behavior changes.
Add:
FlowEventTargetRefValueContractPropertyRequirementMethodPlanBridgePlanInstrumentationAction
Status:
- Completed in
6e58975. - The old strategy path was still active after this phase, by design.
Create an EnforcementPlanner that internally delegates to the current strategy logic.
Goals:
- Rewrite the transform to use the planner abstraction.
- Preserve behavior while the new architecture is introduced.
- Reduce the surface area of the old API without changing semantics all at once.
Status:
- Completed in
3007e3b. - This is temporary migration infrastructure, not part of the intended end state.
Refactor the transform so it:
- Detects semantic flow sites.
- Builds
FlowEventinstances. - Applies
MethodPlanactions.
At this point, the transform no longer calls getParameterCheck, getFieldReadCheck, and so on
directly.
Status:
- Completed in
5aeb550.
Bridge generation becomes a normal planning pass:
- Find inherited obligations missing from the current checked class.
- Build
BridgePlanobjects. - Emit bridge methods from those plans.
Status:
- Completed early in
2c485f9. - The bridge pipeline now goes through the planner, but it is still fed by the legacy strategy adapter.
Implement:
NullnessSemanticsNullnessContractResolverNullnessPropertyEmitter
Then switch the planner from the strategy adapter to real contract resolution for nullness.
Status:
- Not started yet.
- This is now the next major architectural step because it removes the semantic dependency on
InstrumentationStrategyrather than just routing around it.
Add real OVERRIDE_PARAMETER planning for unchecked overrides in global mode.
This is the first behavior-changing phase and should be accompanied by the new regression tests.
Status:
- Not started yet.
Use the richer target model to:
- Fix array component contract resolution.
- Fix local variable range-sensitive resolution.
- Add lifecycle hooks for initialization overlays.
Status:
- Not started yet.
Delete:
InstrumentationStrategyBoundaryStrategyStrictBoundaryStrategy- Old
TypeSystemConfigurationruntime path
Any remaining compatibility shims on RuntimeChecker should be removed here as well.
Status:
- Not started yet.
Completed:
- Add
ResolutionEnvironmentand migrate existing lookup code to it. - Add planner data model.
- Add planner compatibility adapter.
- Refactor method enforcement to consume
MethodPlan. - Refactor bridge generation to consume
BridgePlan.
Remaining:
- Add regression tests for override parameters, arrays, and locals.
- Port nullness to
CheckerSemantics. - Enable
OVERRIDE_PARAMETERchecks and fix the soundness hole. - Fix arrays and locals through richer target resolution.
- Add lifecycle semantics for initialization.
- Delete old strategy classes and compatibility scaffolding.
We should keep the current directory tests and add:
- Focused unit tests for
ResolutionEnvironment. - Planner tests that assert event-to-plan mapping.
- Integration tests for:
- unchecked override parameters
- override returns
- nullable arrays
- local slot reuse
- bridge generation parity
- lifecycle hooks once initialization lands
The planner tests are important. Today most semantics are only validated indirectly through instrumented integration tests, which makes architectural regressions harder to localize.
- Should attribution remain part of the planner, or become a small separate policy object?
- Recommendation: keep attribution in planner output for now.
- Should
RuntimeCheckerlosegetInstrumenter(...)immediately?- Recommendation: no. Keep a compatibility path during migration.
- Should bridge planning live in the same planner as method flows?
- Recommendation: yes. Bridges are just another way to satisfy inherited obligations.
- Should defaults remain checker-global?
- Recommendation: no. Defaults should be resolved per target kind.
The refactor should keep the top-level structure and replace the middle layer.
Keep:
RuntimeCheckerRuntimeInstrumenterRuntimePolicy
Replace:
InstrumentationStrategyBoundaryStrategyTypeSystemConfigurationas the main semantic representation
The new center of the design should be:
CheckerSemanticsContractResolverEnforcementPlannerFlowEventValueContract
That gives the framework enough structure to close the current nullness soundness hole and enough headroom to add initialization and future qualifier systems without another architectural reset.