Skip to content

feat: migrate occurrence module to FalseProtocol library#32

Merged
yairfalse merged 2 commits into
mainfrom
feat/false-protocol-migration
Mar 1, 2026
Merged

feat: migrate occurrence module to FalseProtocol library#32
yairfalse merged 2 commits into
mainfrom
feat/false-protocol-migration

Conversation

@yairfalse
Copy link
Copy Markdown
Collaborator

Summary

  • Replace hand-rolled FALSE Protocol map construction in Nopea.Occurrence with the official FalseProtocol Elixir library
  • Add entity traceability from applied K8s resources (Entity.from_k8s)
  • Add typed reasoning with PatternMatch structs and narrative explanation field
  • Add LogEmitter integration for structured deploy logs (mode: :both)
  • Use FalseProtocol.JSON.encode/1 for validated persistence (invalid aggregates never hit disk)

Test plan

  • All 260 existing tests pass (0 failures)
  • mix compile --warnings-as-errors clean
  • mix format --check-formatted passes
  • mix credo passes
  • New occurrence_log_test.exs covers LogEmitter mode, sequencing, occurrence ref, and log_ref attachment
  • Rewritten occurrence_test.exs asserts on struct fields (protocol_version, context.namespace, context.entities, reasoning.patterns_matched, history step timestamps/actions)
  • Integration test updated for data field (was deploy_data)
  • Verify false-protocol/elixir upstream fixes are committed separately (module ordering, defstruct keyword ordering)

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_resourcesEntity.from_k8s/5.
  • Added log emitter integration and updated tests/assertions for new data field 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.

Comment thread mix.exs
{:jason, "~> 1.4"},

# FALSE Protocol
{:false_protocol, path: "../false-protocol/elixir"},
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

{: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.

Copilot uses AI. Check for mistakes.
Comment thread lib/nopea/deploy.ex Outdated
Comment on lines +248 to +285
{: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)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
{: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

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — fixed in d4c86c5. Now handles {:error, reason} gracefully and returns the occurrence without log_ref.

Comment thread lib/nopea/deploy.ex

# Persist to .nopea/ directory
workdir = File.cwd!()
Nopea.Occurrence.persist(occurrence, workdir)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/nopea/occurrence.ex
Comment on lines +31 to +43
{: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)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
{: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

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/nopea/deploy.ex Outdated
Comment on lines +260 to +282
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"
}
)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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>
@yairfalse yairfalse merged commit 759e915 into main Mar 1, 2026
0 of 2 checks passed
@yairfalse yairfalse deleted the feat/false-protocol-migration branch March 1, 2026 23:13
@yairfalse yairfalse restored the feat/false-protocol-migration branch March 2, 2026 23:41
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.

2 participants