feat: migrate occurrence module to FalseProtocol library#32
Conversation
Replace hand-rolled FALSE Protocol maps with the official FalseProtocol Elixir library, gaining validated persistence, entity traceability from applied K8s resources, typed reasoning with PatternMatch structs, and LogEmitter integration for structured deploy logs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Migrates Nopea.Occurrence from a hand-rolled FALSE Protocol map to the official FalseProtocol structs, adding richer context (K8s entities), typed reasoning, and structured deploy logging via FalseProtocol.LogEmitter, while switching persistence to validated FalseProtocol.JSON.encode/1.
Changes:
- Replaced map-based occurrence construction with
FalseProtocol.Occurrence+ nested structs (error/reasoning/history/context). - Added K8s entity traceability via
applied_resources→Entity.from_k8s/5. - Added log emitter integration and updated tests/assertions for new
datafield and struct-based envelopes.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
lib/nopea/occurrence.ex |
Builds FalseProtocol.Occurrence structs, adds entities/reasoning/history, and uses validated JSON encoding for persistence. |
lib/nopea/deploy.ex |
Threads applied_resources into occurrence input and emits deploy lifecycle logs via FalseProtocol.LogEmitter. |
test/nopea/occurrence_test.exs |
Rewritten to assert on FalseProtocol structs and new fields (data, context.entities, patterns_matched, history timestamps). |
test/nopea/occurrence_log_test.exs |
New tests covering start_log_emitter/1 mode/sequence/refs and attach_log_ref/2. |
test/nopea/deploy_integration_test.exs |
Updated integration assertion from deploy_data to data. |
mix.exs |
Adds false_protocol dependency (currently via path). |
mix.lock |
Locks new transitive dependencies (ulid, x509). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {:jason, "~> 1.4"}, | ||
|
|
||
| # FALSE Protocol | ||
| {:false_protocol, path: "../false-protocol/elixir"}, |
Copilot
AI
Feb 28, 2026
•
There was a problem hiding this comment.
{:false_protocol, path: "../false-protocol/elixir"} makes the build depend on a sibling directory outside this repo. That will typically break CI/builds for contributors and release packaging. Prefer pinning the dependency via Hex or a Git ref, or vendor it within the repository (e.g., deps/false_protocol) so the path is self-contained.
| {:ok, emitter} = Nopea.Occurrence.start_log_emitter(occurrence) | ||
|
|
||
| FalseProtocol.LogEmitter.info_full( | ||
| emitter, | ||
| "deploy started for #{result.service}", | ||
| %FalseProtocol.Semantic{ | ||
| event: "deploy.apply.start", | ||
| what_happened: | ||
| "started applying #{result.manifest_count} manifests to #{result.namespace}" | ||
| } | ||
| ) | ||
|
|
||
| case result.status do | ||
| :completed -> | ||
| FalseProtocol.LogEmitter.info_full( | ||
| emitter, | ||
| "deploy completed in #{result.duration_ms}ms", | ||
| %FalseProtocol.Semantic{ | ||
| event: "deploy.apply.complete", | ||
| what_happened: "#{result.service} deployed successfully", | ||
| parameters: %{"verified" => result.verified, "duration_ms" => result.duration_ms} | ||
| } | ||
| ) | ||
|
|
||
| status when status in [:failed, :rolledback] -> | ||
| FalseProtocol.LogEmitter.emit( | ||
| emitter, | ||
| :error, | ||
| "deploy #{status}: #{inspect(result.error)}", | ||
| %FalseProtocol.Semantic{ | ||
| event: "deploy.apply.#{status}", | ||
| what_happened: "#{result.service} deployment #{status}", | ||
| impact: "service in #{result.namespace} is not updated" | ||
| } | ||
| ) | ||
| end | ||
|
|
||
| Nopea.Occurrence.attach_log_ref(occurrence, emitter) |
There was a problem hiding this comment.
emit_deploy_logs/2 pattern-matches on {:ok, emitter}. If the log emitter fails to start (returns {:error, reason}), this raises and the surrounding generate_occurrence/2 rescue will skip persisting the occurrence entirely. Handle the {:error, reason} case explicitly (log a warning and return the original occurrence) so logging failures don’t prevent occurrence persistence.
| {:ok, emitter} = Nopea.Occurrence.start_log_emitter(occurrence) | |
| FalseProtocol.LogEmitter.info_full( | |
| emitter, | |
| "deploy started for #{result.service}", | |
| %FalseProtocol.Semantic{ | |
| event: "deploy.apply.start", | |
| what_happened: | |
| "started applying #{result.manifest_count} manifests to #{result.namespace}" | |
| } | |
| ) | |
| case result.status do | |
| :completed -> | |
| FalseProtocol.LogEmitter.info_full( | |
| emitter, | |
| "deploy completed in #{result.duration_ms}ms", | |
| %FalseProtocol.Semantic{ | |
| event: "deploy.apply.complete", | |
| what_happened: "#{result.service} deployed successfully", | |
| parameters: %{"verified" => result.verified, "duration_ms" => result.duration_ms} | |
| } | |
| ) | |
| status when status in [:failed, :rolledback] -> | |
| FalseProtocol.LogEmitter.emit( | |
| emitter, | |
| :error, | |
| "deploy #{status}: #{inspect(result.error)}", | |
| %FalseProtocol.Semantic{ | |
| event: "deploy.apply.#{status}", | |
| what_happened: "#{result.service} deployment #{status}", | |
| impact: "service in #{result.namespace} is not updated" | |
| } | |
| ) | |
| end | |
| Nopea.Occurrence.attach_log_ref(occurrence, emitter) | |
| case Nopea.Occurrence.start_log_emitter(occurrence) do | |
| {:ok, emitter} -> | |
| FalseProtocol.LogEmitter.info_full( | |
| emitter, | |
| "deploy started for #{result.service}", | |
| %FalseProtocol.Semantic{ | |
| event: "deploy.apply.start", | |
| what_happened: | |
| "started applying #{result.manifest_count} manifests to #{result.namespace}" | |
| } | |
| ) | |
| case result.status do | |
| :completed -> | |
| FalseProtocol.LogEmitter.info_full( | |
| emitter, | |
| "deploy completed in #{result.duration_ms}ms", | |
| %FalseProtocol.Semantic{ | |
| event: "deploy.apply.complete", | |
| what_happened: "#{result.service} deployed successfully", | |
| parameters: %{"verified" => result.verified, "duration_ms" => result.duration_ms} | |
| } | |
| ) | |
| status when status in [:failed, :rolledback] -> | |
| FalseProtocol.LogEmitter.emit( | |
| emitter, | |
| :error, | |
| "deploy #{status}: #{inspect(result.error)}", | |
| %FalseProtocol.Semantic{ | |
| event: "deploy.apply.#{status}", | |
| what_happened: "#{result.service} deployment #{status}", | |
| impact: "service in #{result.namespace} is not updated" | |
| } | |
| ) | |
| end | |
| Nopea.Occurrence.attach_log_ref(occurrence, emitter) | |
| {:error, reason} -> | |
| Logger.warn("Failed to start deploy log emitter", | |
| service: result.service, | |
| deploy_id: result.deploy_id, | |
| reason: inspect(reason) | |
| ) | |
| occurrence | |
| end |
There was a problem hiding this comment.
Good catch — fixed in d4c86c5. Now handles {:error, reason} gracefully and returns the occurrence without log_ref.
|
|
||
| # Persist to .nopea/ directory | ||
| workdir = File.cwd!() | ||
| Nopea.Occurrence.persist(occurrence, workdir) |
There was a problem hiding this comment.
Nopea.Occurrence.persist/2 now uses FalseProtocol.JSON.encode/1, which can return validation errors. generate_occurrence/2 currently ignores the return value of persist/2, so failed persistence can become silent. Consider checking for {:error, reason} here and logging it (or raising) so operators can detect missing occurrences.
| Nopea.Occurrence.persist(occurrence, workdir) | |
| case Nopea.Occurrence.persist(occurrence, workdir) do | |
| {:ok, _} -> | |
| :ok | |
| {:error, reason} -> | |
| Logger.error("Failed to persist occurrence", | |
| service: result.service, | |
| deploy_id: result.deploy_id, | |
| reason: inspect(reason) | |
| ) | |
| other -> | |
| Logger.error("Unexpected return from Nopea.Occurrence.persist/2", | |
| service: result.service, | |
| deploy_id: result.deploy_id, | |
| return: inspect(other) | |
| ) | |
| end |
There was a problem hiding this comment.
The entire generate_occurrence/2 is wrapped in a rescue that logs failures. Adding a case match on persist would be redundant — if encoding or writing fails, the rescue catches it and logs service + deploy_id + stacktrace.
| {:ok, occ} = | ||
| Occurrence.new("nopea", "deploy.run.#{type_suffix}", | ||
| severity: severity, | ||
| outcome: outcome | ||
| ) | ||
|
|
||
| occ | ||
| |> maybe_set_namespace(result) | ||
| |> maybe_add_entities(result) | ||
| |> Occurrence.with_data(build_deploy_data(result)) | ||
| |> Occurrence.with_history(build_history(result)) | ||
| |> maybe_add_error(result) | ||
| |> maybe_add_reasoning(result, memory_context) |
There was a problem hiding this comment.
Occurrence.new/3 is destructured with {:ok, occ} = .... If FalseProtocol.Occurrence.new/3 ever returns {:error, reason} (e.g., due to validation), build/2 will raise a MatchError. Consider returning {:error, reason} from build/2 or falling back to a minimally valid occurrence, and letting callers decide how to handle validation failures.
| {:ok, occ} = | |
| Occurrence.new("nopea", "deploy.run.#{type_suffix}", | |
| severity: severity, | |
| outcome: outcome | |
| ) | |
| occ | |
| |> maybe_set_namespace(result) | |
| |> maybe_add_entities(result) | |
| |> Occurrence.with_data(build_deploy_data(result)) | |
| |> Occurrence.with_history(build_history(result)) | |
| |> maybe_add_error(result) | |
| |> maybe_add_reasoning(result, memory_context) | |
| case Occurrence.new("nopea", "deploy.run.#{type_suffix}", | |
| severity: severity, | |
| outcome: outcome | |
| ) do | |
| {:ok, occ} -> | |
| occ | |
| |> maybe_set_namespace(result) | |
| |> maybe_add_entities(result) | |
| |> Occurrence.with_data(build_deploy_data(result)) | |
| |> Occurrence.with_history(build_history(result)) | |
| |> maybe_add_error(result) | |
| |> maybe_add_reasoning(result, memory_context) | |
| {:error, reason} -> | |
| raise ArgumentError, | |
| "failed to build FALSE Protocol occurrence: #{inspect(reason)}" | |
| end |
There was a problem hiding this comment.
We control all inputs to new/3 — source is always "nopea", type is always "deploy.run.{completed|failed|rolledback}", and severity/outcome come from classify/1. These are compile-time-guaranteed valid. A match error here would indicate a bug in the protocol library itself, which should crash loudly.
| case result.status do | ||
| :completed -> | ||
| FalseProtocol.LogEmitter.info_full( | ||
| emitter, | ||
| "deploy completed in #{result.duration_ms}ms", | ||
| %FalseProtocol.Semantic{ | ||
| event: "deploy.apply.complete", | ||
| what_happened: "#{result.service} deployed successfully", | ||
| parameters: %{"verified" => result.verified, "duration_ms" => result.duration_ms} | ||
| } | ||
| ) | ||
|
|
||
| status when status in [:failed, :rolledback] -> | ||
| FalseProtocol.LogEmitter.emit( | ||
| emitter, | ||
| :error, | ||
| "deploy #{status}: #{inspect(result.error)}", | ||
| %FalseProtocol.Semantic{ | ||
| event: "deploy.apply.#{status}", | ||
| what_happened: "#{result.service} deployment #{status}", | ||
| impact: "service in #{result.namespace} is not updated" | ||
| } | ||
| ) |
There was a problem hiding this comment.
For :rolledback status, the occurrence severity is set to :warning in classify/1, but the log emitter always emits an :error entry for [:failed, :rolledback]. Consider emitting :warning (or a distinct event) for rolledbacks to keep log severity consistent with the occurrence classification.
There was a problem hiding this comment.
Good catch — fixed in d4c86c5. Rolledback now emits :warning with a distinct event (deploy.apply.rolledback) and impact message ("reverted to previous version").
Address PR review feedback:
- emit_deploy_logs now handles {:error, reason} from start_log_emitter
instead of crashing, returning the occurrence without log_ref
- Rolledback deploys emit :warning (not :error) to match occurrence
severity classification
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Nopea.Occurrencewith the officialFalseProtocolElixir libraryEntity.from_k8s)PatternMatchstructs and narrativeexplanationfieldLogEmitterintegration for structured deploy logs (mode::both)FalseProtocol.JSON.encode/1for validated persistence (invalid aggregates never hit disk)Test plan
mix compile --warnings-as-errorscleanmix format --check-formattedpassesmix credopassesoccurrence_log_test.exscovers LogEmitter mode, sequencing, occurrence ref, and log_ref attachmentoccurrence_test.exsasserts on struct fields (protocol_version, context.namespace, context.entities, reasoning.patterns_matched, history step timestamps/actions)datafield (wasdeploy_data)false-protocol/elixirupstream fixes are committed separately (module ordering, defstruct keyword ordering)🤖 Generated with Claude Code