ADR-7: v0.1.0 Slice 4 – 6 Working Decisions
Status: Accepted (working decisions).
Built on top of ADR-2 (Extension API);
closes the open implementation choices for the remaining v0.1.0
slices (4 — FlowContribution wiring through internal narrowing,
5 — plugin diagnostic emission protocol, 6 — plugin-side cache
producers). The slice-1 / slice-2 / slice-3 / slice-5 (formatter)
work that already landed on master is recorded in
docs/CURRENT_WORK.md and
docs/ROADMAP.md; this ADR pins the
choices the next implementer commits to as the v0.1.0 work
continues.
Context
Section titled “Context”ADR-2 fixes the v0.1.0 design surface (extension protocols,
contribution merging, registration / configuration, trust /
I/O policy, cache descriptors) but defers several
implementation-level questions to “working decisions” the slice
plan resolves as it progresses. With slice 1 (registration /
loading), slice 2 (trust / I/O policy), slice 3 (contribution
merger), and the formatter half of slice 5 (qualified-rule text
rendering) on master, the remaining surfaces — wiring the
merger into existing internal narrowing, the plugin diagnostic
emission protocol, and plugin-side cache producers — each carry
discrete design questions that need a decision before
implementation.
This ADR records the chosen direction for each of those questions.
Decisions
Section titled “Decisions”Slice 4 — FlowContribution wiring through internal narrowing
Section titled “Slice 4 — FlowContribution wiring through internal narrowing”4-A: bundle slot ↔ engine-internal fact translation rule
Section titled “4-A: bundle slot ↔ engine-internal fact translation rule”Today the engine carries four flow-contribution data shapes in parallel:
Rigor::RbsExtended::PredicateEffect(truthy / falsey predicate narrowing fromrigor:v1:predicate-if-*)Rigor::RbsExtended::AssertEffect(assert-style post-return facts fromrigor:v1:assert*)Rigor::RbsExtended::ParamOverride(parameter-type narrowing fromrigor:v1:param:)- Built-in narrowing rules’ direct fact emission
(
Inference::Narrowing#predicate_scopesand friends, which editScopedirectly without an intermediate value object)
Rigor::RbsExtended.read_flow_contribution(method_def) already
folds the first three into a single Rigor::FlowContribution
bundle (v0.0.9 group D), but the consumers still read the typed
Effect carriers directly across eight call sites
(narrowing.rb × 2, statement_evaluator.rb,
method_parameter_binder.rb, check_rules.rb,
overload_selector.rb, rbs_dispatch.rb,
analysis/check_rules.rb).
For Rigor::FlowContribution::Merger (slice 3) to be the
single point of integration the merge policy operates over —
the deduplication / dedup-by-equality / intersection rules
require — the bundle slot payloads must compare meaningfully.
A built-in narrowing rule’s Type payload and an RBS::Extended
PredicateEffect for the same target / edge are not directly
comparable, so the merger today cannot detect the conflicts
ADR-2 § “Plugin Contribution Merging” requires.
Decision: introduce a normalised intermediate fact value
object. A new
Rigor::FlowContribution::Fact = Data.define(:target, :predicate, :type, :edge)
(or a similar minimal shape exercised by slice 4’s first
implementation) becomes the canonical slot payload. Each of
the existing carriers (built-in narrowing, PredicateEffect,
AssertEffect, future plugin contributions) translates to / from
the canonical Fact at the boundary. The typed RbsExtended::*Effect
carriers stay internal as parser-side staging types, but
downstream of read_flow_contribution everything flows as
canonical Facts.
Rationale: the long-term debt of leaving four non-comparable carriers parallel is higher than the one-time translation cost. The canonical Fact also gives plugin authors (slice 5 emission protocol, slice 6 cache producers) a stable target to write against — they declare Facts, not internal Effect classes. Reflects the user’s preference for the path that does not accrue legacy.
Out of scope for this ADR: the exact field set on
Rigor::FlowContribution::Fact. Slice 4’s implementer settles
the surface against the existing call sites’ actual narrowing
needs; the constraints are (a) frozen value object, (b) usable
as a slot payload that supports equality / hash, (c) round-
trippable from each existing carrier without information
loss for the slot semantics defined in slice 3.
4-B: Merger application granularity
Section titled “4-B: Merger application granularity”The merger can be invoked at two granularities:
- Per call-site: every
Prism::CallNodethat activates one or more contribution sources runs the merger over the active bundles. The merge result becomes the per-call narrowing / return-type / mutation effect. - Per method-definition: the static portion of a method’s contributions (RBS::Extended directives, declared mutations) merge once per method-definition; per-call-site merging only runs for plugin contributions whose result depends on the argument types.
Decision: per call-site. Every call-site merge composes built-in, RBS::Extended, and (future) plugin contributions together; slice 3’s Merger is invoked at every dispatch point that previously consulted any of those sources.
Rationale: call-site granularity keeps the merge policy
a single, well-understood function ((contributions[]) → MergeResult) without a separate “static portion” code path
that future contribution kinds would have to fit into.
Performance is mitigated by:
- The canonical Fact’s equality-based dedup means monomorphic call-sites (where every contribution’s payload is identical across call-sites) hit a small, cache-friendly result set.
- Slice 6’s plugin-side cache producers extend the same cache layer to the merge result when contributions that depend on receiver-type / parameter-type are stable across the analysis run.
The user’s read on this decision: cache efficiency must carry
the overhead. Slice 4’s implementer is responsible for
verifying the per-call-site merger is hot-path-cheap (no
allocation per non-contributing call) under existing specs;
if profiling on bundle exec exe/rigor check lib shows
non-trivial regression versus pre-slice-4 timings, the
per-method-definition fallback is added as an optimisation
on top of the per-call-site primary path. Per-method-definition
is never the primary integration point.
Slice 5 — Plugin diagnostic emission protocol
Section titled “Slice 5 — Plugin diagnostic emission protocol”The text-rendering half of slice 5 already landed on master
at commit ef730b2 (Diagnostic#to_s appends
[<source_family>.<rule>] for non-builtin source families).
The remaining work is the plugin-side emission hook.
5-A: emission protocol granularity
Section titled “5-A: emission protocol granularity”ADR-2 § “Custom rules” mentions PHPStan-style Rule<TNode>
node-scoped rules as the eventual surface. For slice 5’s MVP,
options are a flat per-file hook, the full Rule
Decision: per-file flat hook as the v0.1.0 MVP.
class Rigor::Plugin::Base # Override in plugin subclasses. Default returns no # diagnostics. The `scope` argument is the file-entry scope # the analyzer reached after `ScopeIndexer` ran; `root` is # the parsed `Prism::Node`. def diagnostics_for_file(path:, scope:, root:) [] endendThe runner calls every loaded plugin’s diagnostics_for_file
once per analysed file and folds the returned diagnostics
into the run result. The plugin author writes their own
traversal of root if they need node-specific rules.
Rationale: keeps the v0.1.0 MVP small and the
plugin-protocol surface area minimal. The Rule<TNode>
node-scoped rule API is queued for v0.1.x, when it can be
designed against actual plugin authors’ usage of the flat
hook.
MergeResult#conflicts from slice 4 reaches the same
diagnostic stream by routing through the runner’s existing
diagnostic-collection path (see 5-C below); it does not need
its own emission protocol.
5-B: source_family stamping responsibility
Section titled “5-B: source_family stamping responsibility”Plugins return Rigor::Analysis::Diagnostic rows from
diagnostics_for_file. The source_family field can be set
either by the plugin author or stamped automatically by the
runner.
Decision: the runner stamps automatically. Plugins return
Diagnostic rows without setting source_family (or with
any value); the runner overwrites with
source_family: "plugin.#{plugin.manifest.id}" before adding
the diagnostic to the result. Plugin authors cannot
accidentally publish diagnostics under another plugin’s id
or under :builtin.
Rationale: matches the slice-1 / slice-2 trust model — plugin manifest id is the trusted identifier; everything that attributes back to the plugin uses it consistently.
5-C: Merger Conflict → Diagnostic conversion
Section titled “5-C: Merger Conflict → Diagnostic conversion”When Rigor::FlowContribution::Merger.merge produces a
Conflict row, the analyzer converts it to a Diagnostic
for the run result.
Decision: Conflict#to_diagnostic(path:, line:, column:).
The conversion lives on Rigor::FlowContribution::Conflict
itself. The conflict is the value object; converting itself
to a diagnostic is the value object’s responsibility. The
runner (slice 4 wiring) collects conflicts from each
MergeResult and calls to_diagnostic per row, attaching
the call-site path / line / column.
The resulting Diagnostic carries
source_family: :contribution_merge and a rule derived
from the conflict’s reason (e.g.
return-type-collapse, exceptional-disagreement,
lower-tier-contradiction). The qualified rule path renders
as [contribution_merge.return-type-collapse] per slice 5’s
formatter half.
Rationale: the user expressed no strong preference; the
Conflict#to_diagnostic location keeps Conflict as the
authoritative carrier for what the diagnostic should say
(which provenances, what message). Letting the runner
construct the diagnostic from the outside (alternative Y) is
also fine but spreads the conversion logic; placing it on
the value object keeps the diagnostic shape co-evolving with
the Conflict shape.
Slice 6 — Plugin-side cache producers
Section titled “Slice 6 — Plugin-side cache producers”6-A: producer registration API
Section titled “6-A: producer registration API”Plugins ride the v0.0.9 Cache::Store#fetch_or_compute(producer_id:, params:, descriptor:, serialize:, deserialize:) surface.
The plugin-author-facing API can be fully declarative,
fully imperative, or a hybrid.
Decision: DSL declaration + Services helper hybrid.
class MyRailsPlugin < Rigor::Plugin::Base manifest(id: "rails", version: "0.1.0")
# Declares an id (and optionally a custom serialiser pair). # The block defines the producer body — same shape as today's # built-in producers. producer :schema_table, serialize: ->(value) { ... }, deserialize: ->(bytes) { ... } do |params| schema_path = services.io_boundary_for(manifest.id).read_file("db/schema.rb") parse_schema(schema_path, params) end
def schema_for(table_name) # `services.cache_for(:schema_table, params: { table: table_name })` # returns a callable that hits `Cache::Store#fetch_or_compute` # with the descriptor automatically built (see 6-B). services.cache_for(:schema_table, params: { table: table_name }).call endendTwo surfaces:
Plugin::Base.producer(id, serialize:, deserialize:, &block)— class-level DSL; declares the producer’s id and optional custom serialiser pair, plus the producer body. The id gets prefixed automatically (see 6-C);serialize/deserializedefault to the slice-1Marshal.dump/Marshal.loadpair.Services#cache_for(producer_id, params:)— instance helper plugins call when they want a result. Returns a callable that performs thefetch_or_computeround-trip with the descriptor auto-assembled (see 6-B).
Rationale: purely declarative manifests don’t compose
well with producer logic (the body is real Ruby code, not a
data row). Purely imperative
Cache::Store#fetch_or_compute calls leave the descriptor
construction to the plugin author and invite mistakes
(missing PluginEntry, wrong producer-id prefix). The hybrid
keeps the declaration light (id + serialiser) while letting
the body live in regular Ruby and the descriptor get built
automatically.
6-B: PluginEntry attachment
Section titled “6-B: PluginEntry attachment”Cache::Descriptor::PluginEntry(id:, version:, config_hash:)
identifies the plugin in the cache invariants; it must be
present on every plugin-side cache slice for invalidation
to work correctly.
Decision: the loader / Services helper attaches it
automatically. When the loader instantiates a plugin
through slice 1’s Loader.load, the resulting Services
container records a per-plugin descriptor template:
PluginEntry.new( id: manifest.id, version: manifest.version, config_hash: digest(plugin.config))Every Services#cache_for(producer_id, params:) round-trip
auto-composes the descriptor from:
- The plugin’s
PluginEntrytemplate (above). - The plugin’s
IoBoundary#cache_descriptor(slice 2 — every file the boundary read). - The user’s
params:hash (mixed into the cache key per v0.0.8Descriptor#cache_key_for).
Plugin authors never construct a descriptor manually. Custom
descriptor extensions (extra FileEntry / GemEntry /
ConfigEntry rows) ride a future API extension; slice 6
ships only the auto-built path.
Rationale: plugin id + version invalidation is an invariant the plugin contract relies on. Burdening the plugin author with manually composing it is unnecessary risk; the loader has the manifest, the runtime has the IoBoundary, the auto-build is the natural assembly.
6-C: cache root sandbox boundary
Section titled “6-C: cache root sandbox boundary”Plugin-declared producer_ids share filesystem layout with
built-in producers
(<root>/<producer_id>/<2-prefix>/<62-suffix>.entry). A
plugin author who declares
producer :rbs_environment would collide with the built-in
v0.0.9 RbsEnvironment cache slice.
Decision: the loader / DSL prefixes plugin producer ids
with plugin.<manifest.id>. automatically. A
producer :schema_table declaration on a plugin with
manifest.id = "rails" is registered and persisted under
plugin.rails.schema_table. The prefix:
- Lives within the existing
Cache::Store::VALID_PRODUCER_ID = /\A[a-z][a-z0-9._-]*\z/regex. - Prevents id collisions with built-in producers (which use
rbs.*prefixes today and won’t useplugin.*). - Is deterministic and visible in
--cache-statsoutput, so cache attribution is unambiguous.
Rationale: sandbox by construction, not by documentation. Plugin authors who try to write their own collision-prone ids cannot reach the cache layer through the supported path.
6-D: per-method Reflection cache re-attempt (v0.0.9 carry-over)
Section titled “6-D: per-method Reflection cache re-attempt (v0.0.9 carry-over)”A v0.0.9 attempt at cache-wiring RbsLoader#instance_definition
/ singleton_definition triggered an analyzer regression
(uninitialized constant Rigor::Cache::RbsDescriptor::Descriptor
on bundle exec exe/rigor check lib); root cause was not
isolated. The original v0.1.0 plan bundled the re-attempt with
slice 6.
Decision: descope from slice 6. The per-method Reflection cache re-attempt is split into a separate v0.1.x ticket, owned post-v0.1.0. Slice 6 ships only the plugin-facing producer surface (6-A through 6-C above) and the existing built-in RBS-side caches stay untouched.
Rationale: the per-method cache re-attempt requires a
separate root-cause investigation of the v0.0.9 regression
before the wiring can be re-applied. Bundling it with slice 6
adds risk (engine-internal regression on a slice that already
introduces a new public plugin API) without buying anything
slice 6 needs. The Cache::Store#fetch_or_compute callable
surface is sufficient on its own to land plugin-side
producers; the per-method Reflection cache is an
orthogonal optimisation.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Slice 4’s merger has a single canonical slot payload type; all four contribution sources translate at their boundary and the merger’s intersection / dedup logic operates on a homogeneous surface.
- Slice 5’s emission protocol is the smallest viable shape; plugin authors get a clear single hook to override.
- Slice 6’s producer API gives plugin authors a declarative registration surface while keeping the descriptor invariants (PluginEntry attachment, prefixed producer ids) enforced by the loader rather than by documentation.
- Slice 6’s scope stays bounded; the v0.0.9 per-method Reflection cache re-attempt isn’t entangled with the new public API.
Negative
Section titled “Negative”- The canonical
Factvalue object is a new public-shape surface that plugin authors will write against. Slice 4 implementer is responsible for the field set; it lands drift-pinned atspec/rigor/public_api_drift_spec.rbalongside the slice-4 commit. - Per-call-site merger granularity needs profiling discipline
to ensure the cold-path overhead is small. Slice 4 carries
the responsibility of verifying no regression on
bundle exec exe/rigor check libtimings against pre-slice-4 baseline. - The
Rule<TNode>node-scoped rule API stays deferred. A plugin author who needs node-scoped rules in v0.1.0 will re-implement traversal in their flat hook. v0.1.x lifts that limitation.
Out of scope
Section titled “Out of scope”Rigor::FlowContribution::Fact’s exact field set. Slice 4’s implementer surveys the eight existing call sites and picks the minimal field set that round-trips every carrier. The drift spec catches accidental changes.- The
serialize:/deserialize:callables’ shape on thePlugin::Base.producerDSL. Slice 6’s implementer mirrors the v0.0.9Cache::Store#fetch_or_compute(serialize:, deserialize:)callable surface verbatim; deviations would be a separate ADR. - The exact
Rigor::Analysis::Diagnostic#rulemapping forConflict#to_diagnostic. Slice 5 / slice 4 implementer picks the kebab-caserulestrings; ADR-2 § “Plugin Diagnostic Provenance” already constrains the<source_family>.<rule>shape. - Cross-machine cache sharing of plugin-side caches. Per ADR-6, the cache is single-machine; plugin-side producers inherit that constraint.
References
Section titled “References”- ADR-2 (Extension API) — design surface for the v0.1.0 plugin contract.
- ADR-6 (Cache persistence backend) — storage layer plugin-side producers ride.
docs/internal-spec/flow-contribution-merger.md— slice-3 merger spec (already landed).docs/internal-spec/plugin.md— slice-1 registration / loading spec (already landed).docs/internal-spec/plugin-trust.md— slice-2 trust / I/O policy spec (already landed).docs/CURRENT_WORK.md— in-flight v0.1.0 implementation bookmark.
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.