Skip to content

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.

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.

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 from rigor:v1:predicate-if-*)
  • Rigor::RbsExtended::AssertEffect (assert-style post-return facts from rigor:v1:assert*)
  • Rigor::RbsExtended::ParamOverride (parameter-type narrowing from rigor:v1:param:)
  • Built-in narrowing rules’ direct fact emission (Inference::Narrowing#predicate_scopes and friends, which edit Scope directly 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.

The merger can be invoked at two granularities:

  • Per call-site: every Prism::CallNode that 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.

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 machinery, or PHPStan’s two-stage Collector + Rule pattern.

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:)
[]
end
end

The 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.

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
end
end

Two surfaces:

  1. 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 / deserialize default to the slice-1 Marshal.dump / Marshal.load pair.
  2. Services#cache_for(producer_id, params:) — instance helper plugins call when they want a result. Returns a callable that performs the fetch_or_compute round-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.

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 PluginEntry template (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.8 Descriptor#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.

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 use plugin.*).
  • Is deterministic and visible in --cache-stats output, 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.

  • 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.
  • The canonical Fact value 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 at spec/rigor/public_api_drift_spec.rb alongside 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 lib timings 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.
  • 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 the Plugin::Base.producer DSL. Slice 6’s implementer mirrors the v0.0.9 Cache::Store#fetch_or_compute(serialize:, deserialize:) callable surface verbatim; deviations would be a separate ADR.
  • The exact Rigor::Analysis::Diagnostic#rule mapping for Conflict#to_diagnostic. Slice 5 / slice 4 implementer picks the kebab-case rule strings; 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.

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.