Skip to content

TEF-663 Raise NullFactError when a fact resolves to nil - #15

Open
dfl wants to merge 1 commit into
mainfrom
TEF-663-raise-on-null-fact
Open

TEF-663 Raise NullFactError when a fact resolves to nil#15
dfl wants to merge 1 commit into
mainfrom
TEF-663-raise-on-null-fact

Conversation

@dfl

@dfl dfl commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Ticket

https://codeforamerica.atlassian.net/browse/TEF-663

Summary

Enforces a core fact-graph invariant: no fact may resolve to nil. When a value can't be calculated, a fact must signal an unmet dependency (an error hash / data_errors) so the facts downstream can tell a missing value apart from a satisfied one. A fact that returns nil breaks that guarantee silently — dependents treat the gap as answered.

Fact#call now raises FactGraph::NullFactError — naming the offending fact (and entity, for per-entity facts) — whenever a resolver or constant resolves to nil.

Why

This is a developer-error invariant, like a NoMethodError: a nil fact is a bug in the graph definition, so it should fail loudly rather than propagate. Catching it at evaluation time covers every input, everywhere the graph runs (dev, test, prod, jobs) — not just paths a spec happens to exercise. It closes the two ways nil leaks in:

  • an allow_unmet_dependencies fact whose branch returns nil instead of data_errors
  • an input fact whose validator permits nil (the blank case should resolve to an error, not pass nil through)

false remains a valid value and does not raise.

Changes

  • lib/fact_graph.rb — add FactGraph::NullFactError
  • lib/fact_graph/fact.rbensure_non_nil! guard on both the resolver and constant/pass-through paths
  • spec/fact_graph/fact_spec.rb — cover the raise (proc, allow_unmet_dependencies, input pass-through, per-entity message) and that false does not raise
  • README.md — document the invariant under "Handling unmet dependencies"

🤖 Generated with Claude Code

@dfl dfl self-assigned this Jul 17, 2026
@dfl
dfl force-pushed the TEF-663-raise-on-null-fact branch from a480bf2 to fb86083 Compare July 17, 2026 03:09
@dfl
dfl marked this pull request as ready for review July 17, 2026 03:17
Comment thread lib/fact_graph/fact.rb
@@ -147,6 +147,7 @@ def call(input, results)
results[module_name] ||= {}

if !resolver.respond_to?(:call)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts on flipping this to unless ?

A core invariant of the fact graph is that no fact returns null: when a
value can't be calculated the fact must signal an unmet dependency (an
error hash), so dependent facts can tell "missing" apart from
"satisfied". A fact that returns nil breaks that guarantee silently.

Enforce it at evaluation time: Fact#call now raises FactGraph::NullFactError,
naming the offending fact (and entity, for per-entity facts), whenever a
resolver or constant resolves to nil. This covers the two ways nil leaks
in — an allow_unmet_dependencies fact whose branch returns nil instead of
data_errors, and an input fact whose validator permits nil. `false`
remains a valid value.

- lib/fact_graph.rb: add FactGraph::NullFactError
- lib/fact_graph/fact.rb: ensure_non_nil! guard on both resolve paths
- spec/fact_graph/fact_spec.rb: cover the raise (proc, allow_unmet,
  input pass-through, per-entity) and that false does not raise
- README: document the no-null invariant

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dfl
dfl force-pushed the TEF-663-raise-on-null-fact branch from fb86083 to 768f038 Compare July 17, 2026 03:19
@dfl
dfl requested a review from mrotondo July 17, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant