ace-assign 0.57.1 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +13 -0
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +32 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  12. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  13. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  14. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  15. data/lib/ace/assign/cli/commands/status.rb +24 -1
  16. data/lib/ace/assign/cli.rb +30 -2
  17. data/lib/ace/assign/models/assignment.rb +21 -4
  18. data/lib/ace/assign/models/attempt.rb +178 -0
  19. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  20. data/lib/ace/assign/models/evidence_event.rb +82 -0
  21. data/lib/ace/assign/models/execution_receipt.rb +135 -0
  22. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  23. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  24. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  25. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  26. data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
  27. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  28. data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
  29. data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
  30. data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
  31. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  32. data/lib/ace/assign/version.rb +1 -1
  33. data/lib/ace/assign.rb +56 -0
  34. metadata +20 -2
@@ -88,7 +88,10 @@ module Ace
88
88
  preset_name = Ace::Assign::Molecules::PresetInferrer.infer_from_assignment(assignment)
89
89
  auto_merge = true if preset_name && preset_name.start_with?("work-on-task-auto-merge")
90
90
 
91
- evidence = Ace::Assign::Molecules::EvidenceCalculator.calculate(auto_merge: auto_merge)
91
+ evidence = Ace::Assign::Molecules::EvidenceCalculator.calculate(
92
+ auto_merge: auto_merge,
93
+ assignment_id: assignment.id
94
+ )
92
95
 
93
96
  payload = {
94
97
  assignment: {
@@ -132,6 +135,10 @@ module Ace
132
135
  lines = []
133
136
  lines.concat(compact_summary_lines(view.assignment, view.scoped_state, view.active_steps, view.next_step))
134
137
 
138
+ if (attempt_line = compact_attempt_line(view.assignment.id))
139
+ lines.insert(1, attempt_line)
140
+ end
141
+
135
142
  unless target.assignment_id
136
143
  other_line = compact_other_assignments_line(view.assignment.id, include_completed: include_completed)
137
144
  lines << other_line if other_line
@@ -140,6 +147,22 @@ module Ace
140
147
  puts lines.take(10).join("\n")
141
148
  end
142
149
 
150
+ # Active attempt summary projected from the coordinator authority.
151
+ def compact_attempt_line(assignment_id)
152
+ projection = Organisms::AttemptCoordinator.new.status(assignment_id)
153
+ return nil unless projection
154
+
155
+ base = projection["base_head"].to_s[0, 7]
156
+ candidate = projection["candidate_head"].to_s[0, 7]
157
+ line = "Attempt: #{projection['attempt_id']} #{projection['state']}"
158
+ line += " | base:#{base}" unless base.to_s.empty?
159
+ line += " | cand:#{candidate.empty? ? 'none' : candidate}"
160
+ line += " | unresolved: #{projection['unresolved_effects'].join(',')}" if projection["unresolved_effects"].any?
161
+ line
162
+ rescue Ace::Assign::Error, Ace::Support::Cli::Error
163
+ nil
164
+ end
165
+
143
166
  def compact_summary_lines(assignment, state, active_steps, next_step)
144
167
  lines = [
145
168
  compact_assignment_line(assignment, state, active_steps, next_step),
@@ -9,6 +9,10 @@ require_relative "models/assignment"
9
9
  require_relative "models/step"
10
10
  require_relative "models/queue_state"
11
11
  require_relative "models/assignment_info"
12
+ require_relative "models/attempt_binding"
13
+ require_relative "models/attempt"
14
+ require_relative "models/execution_receipt"
15
+ require_relative "models/evidence_event"
12
16
 
13
17
  # Atoms
14
18
  require_relative "atoms/step_numbering"
@@ -22,9 +26,13 @@ require_relative "atoms/catalog_loader"
22
26
  require_relative "atoms/composition_rules"
23
27
  require_relative "atoms/assign_frontmatter_parser"
24
28
  require_relative "atoms/tree_formatter"
29
+ require_relative "atoms/attempt_state_machine"
30
+ require_relative "atoms/evidence_digest"
31
+ require_relative "atoms/assignment_scope"
25
32
 
26
33
  # Molecules
27
34
  require_relative "molecules/assignment_manager"
35
+ require_relative "molecules/attempt_store"
28
36
  require_relative "molecules/assignment_discoverer"
29
37
  require_relative "molecules/queue_scanner"
30
38
  require_relative "molecules/step_writer"
@@ -34,9 +42,14 @@ require_relative "molecules/fork_session_launcher"
34
42
  require_relative "molecules/tmux_control_surface_runner"
35
43
  require_relative "molecules/preset_inferrer"
36
44
  require_relative "molecules/evidence_calculator"
45
+ require_relative "molecules/evidence_journal"
46
+ require_relative "molecules/execution_identity_resolver"
47
+ require_relative "molecules/receipt_verifier"
48
+ require_relative "molecules/attempt_reconciler"
37
49
 
38
50
  # Organisms
39
51
  require_relative "organisms/assignment_executor"
52
+ require_relative "organisms/attempt_coordinator"
40
53
 
41
54
  # Commands
42
55
  require_relative "cli/commands/create"
@@ -52,6 +65,11 @@ require_relative "cli/commands/list"
52
65
  require_relative "cli/commands/select"
53
66
  require_relative "cli/commands/fork_run"
54
67
  require_relative "cli/commands/fork_session"
68
+ require_relative "cli/commands/attempt/base"
69
+ require_relative "cli/commands/attempt/start"
70
+ require_relative "cli/commands/attempt/status"
71
+ require_relative "cli/commands/attempt/finish"
72
+ require_relative "cli/commands/attempt/reconcile"
55
73
 
56
74
  module Ace
57
75
  module Assign
@@ -73,7 +91,11 @@ module Ace
73
91
  ["retry", "Retry failed step"],
74
92
  ["list", "List all assignments"],
75
93
  ["select", "Select active assignment"],
76
- ["fork-run", "Run subtree in forked process"]
94
+ ["fork-run", "Run subtree in forked process"],
95
+ ["attempt start", "Start a scoped attempt for an assignment step"],
96
+ ["attempt status", "Show attempt status for an assignment"],
97
+ ["attempt finish", "Finish an attempt with a structured execution receipt"],
98
+ ["attempt reconcile", "Reconcile an interrupted or uncertain attempt"]
77
99
  ].freeze
78
100
 
79
101
  HELP_EXAMPLES = [
@@ -83,7 +105,9 @@ module Ace
83
105
  "ace-assign start # Start next workable step",
84
106
  "ace-assign finish --message done.md # Complete active step",
85
107
  "cat report.md | ace-assign finish # Complete step via stdin",
86
- "ace-assign fork-run 010.01 # Run subtree in subprocess"
108
+ "ace-assign fork-run 010.01 # Run subtree in subprocess",
109
+ "ace-assign attempt start --assignment ID --step 010 --project ID",
110
+ "ace-assign attempt finish --attempt ID --receipt receipt.json"
87
111
  ].freeze
88
112
 
89
113
  # Captured command exit code from last run
@@ -131,6 +155,10 @@ module Ace
131
155
  register "select", wrap_command(Commands::Select)
132
156
  register "fork-run", wrap_command(Commands::ForkRun)
133
157
  register "fork-session", wrap_command(Commands::ForkSession)
158
+ register "attempt start", wrap_command(Commands::Attempt::Start)
159
+ register "attempt status", wrap_command(Commands::Attempt::Status)
160
+ register "attempt finish", wrap_command(Commands::Attempt::Finish)
161
+ register "attempt reconcile", wrap_command(Commands::Attempt::Reconcile)
134
162
 
135
163
  # Register version command
136
164
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -19,7 +19,8 @@ module Ace
19
19
  # NOTE: Could be refactored to use Data.define in Ruby 3.2+ for reduced boilerplate,
20
20
  # but tests would need to be updated to provide all required fields.
21
21
  class Assignment
22
- attr_reader :id, :name, :description, :created_at, :updated_at, :source_config, :cache_dir, :parent
22
+ attr_reader :id, :name, :description, :created_at, :updated_at, :source_config, :cache_dir, :parent,
23
+ :task_id, :project_id
23
24
 
24
25
  # @param id [String] Unique assignment ID (6-char compact timestamp)
25
26
  # @param name [String] Human-readable assignment name
@@ -29,7 +30,10 @@ module Ace
29
30
  # @param source_config [String] Path to source configuration file
30
31
  # @param cache_dir [String, nil] Assignment cache directory
31
32
  # @param parent [String, nil] Parent assignment ID for hierarchy linking
32
- def initialize(id:, name:, created_at:, source_config:, description: nil, updated_at: nil, cache_dir: nil, parent: nil)
33
+ # @param task_id [String, nil] Source task ID attached to this assignment (nil = taskless)
34
+ # @param project_id [String, nil] Project ID this assignment executes in
35
+ def initialize(id:, name:, created_at:, source_config:, description: nil, updated_at: nil, cache_dir: nil,
36
+ parent: nil, task_id: nil, project_id: nil)
33
37
  @id = id.freeze
34
38
  @name = name.freeze
35
39
  @description = description&.freeze
@@ -38,6 +42,15 @@ module Ace
38
42
  @source_config = source_config.freeze
39
43
  @cache_dir = cache_dir&.freeze
40
44
  @parent = parent&.freeze
45
+ @task_id = task_id&.freeze
46
+ @project_id = project_id&.freeze
47
+ end
48
+
49
+ # @return [Boolean] True when a source task is attached; taskless
50
+ # assignments persist attempts locally only and never claim
51
+ # Git-backed recovery
52
+ def managed?
53
+ !task_id.nil? && !task_id.to_s.strip.empty?
41
54
  end
42
55
 
43
56
  # Convert to hash for YAML serialization
@@ -50,7 +63,9 @@ module Ace
50
63
  "created_at" => created_at.iso8601,
51
64
  "updated_at" => updated_at.iso8601,
52
65
  "source_config" => source_config,
53
- "parent" => parent
66
+ "parent" => parent,
67
+ "task_id" => task_id,
68
+ "project_id" => project_id
54
69
  }.compact
55
70
  end
56
71
 
@@ -67,7 +82,9 @@ module Ace
67
82
  updated_at: parse_time(data["updated_at"]),
68
83
  source_config: data["source_config"],
69
84
  cache_dir: cache_dir,
70
- parent: data["parent"]
85
+ parent: data["parent"],
86
+ task_id: data["task_id"],
87
+ project_id: data["project_id"]
71
88
  )
72
89
  end
73
90
 
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/attempt_state_machine"
4
+ require_relative "attempt_binding"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Models
9
+ # Mutable derived state of an assignment attempt.
10
+ #
11
+ # The binding is immutable; an Attempt carries the derived execution
12
+ # state: the current state-machine state, the pinned candidate head,
13
+ # the latest evidence journal commit, accepted receipts (by digest),
14
+ # recorded effects, and the append-only local event trail for
15
+ # taskless attempts. `base_head` (binding) and `candidate_head`
16
+ # (deliverable) are always tracked separately.
17
+ class Attempt
18
+ attr_reader :binding, :state, :candidate_head, :journal_commit,
19
+ :accepted_receipts, :effects, :events, :updated_at
20
+
21
+ # @param binding [Models::AttemptBinding] Immutable start-time binding
22
+ # @param state [String] State-machine state
23
+ # @param candidate_head [String, nil] Pinned deliverable head
24
+ # @param journal_commit [String, nil] Latest evidence journal commit SHA
25
+ # @param accepted_receipts [Array<Hash>] Accepted receipt payloads (digests included)
26
+ # @param effects [Array<Hash>] Recorded effects with intent/receipt linkage
27
+ # @param events [Array<Hash>] Local event trail (taskless attempts only)
28
+ # @param updated_at [Time] Last state change
29
+ def initialize(binding:, state: "reserved", candidate_head: nil, journal_commit: nil,
30
+ accepted_receipts: [], effects: [], events: [], updated_at: nil)
31
+ @binding = binding
32
+ @state = state.to_s
33
+ @candidate_head = candidate_head
34
+ @journal_commit = journal_commit
35
+ @accepted_receipts = accepted_receipts.dup.freeze
36
+ @effects = effects.dup.freeze
37
+ @events = events.dup.freeze
38
+ @updated_at = updated_at || binding.created_at
39
+ end
40
+
41
+ # @return [String] Immutable attempt ID
42
+ def attempt_id
43
+ binding.attempt_id
44
+ end
45
+
46
+ # @return [Boolean] True if no further transitions or effects are allowed
47
+ def terminal?
48
+ Atoms::AttemptStateMachine.terminal?(state)
49
+ end
50
+
51
+ # @return [Boolean] True if the attempt awaits reconciliation
52
+ def uncertain?
53
+ state == "uncertain"
54
+ end
55
+
56
+ # @return [Boolean] True if the attempt owns an active subtree slot
57
+ def active?
58
+ %w[reserved running uncertain].include?(state)
59
+ end
60
+
61
+ # @return [String] "git" or "local_only" recovery mode
62
+ def recovery_mode
63
+ binding.recovery_mode
64
+ end
65
+
66
+ # @return [Boolean] True when managed evidence-ref journaling applies
67
+ def managed?
68
+ binding.managed?
69
+ end
70
+
71
+ # Effects whose intent was recorded but whose accepted receipt is missing.
72
+ #
73
+ # @return [Array<Hash>] Unresolved effect records
74
+ def unresolved_effects
75
+ effects.reject { |effect| effect["receipt_digest"] }
76
+ end
77
+
78
+ # Return a new Attempt with the state transitioned.
79
+ #
80
+ # @param to [String] Target state (validated against the state machine)
81
+ # @param at [Time] Transition time
82
+ # @return [Attempt] New attempt instance
83
+ # @raise [Ace::Assign::AttemptErrors::InvalidTransition] if illegal
84
+ def transition(to, at: Time.now.utc)
85
+ Atoms::AttemptStateMachine.transition!(state, to)
86
+ with(state: to.to_s, updated_at: at)
87
+ end
88
+
89
+ # Return a new Attempt with updated derived fields. The binding is
90
+ # never replaced.
91
+ #
92
+ # @param attrs [Hash] Fields to override (state, candidate_head,
93
+ # journal_commit, accepted_receipts, effects, events, updated_at)
94
+ # @return [Attempt] New attempt instance
95
+ def with(**attrs)
96
+ Attempt.new(
97
+ binding: binding,
98
+ state: attrs.fetch(:state, state),
99
+ candidate_head: attrs.fetch(:candidate_head, candidate_head),
100
+ journal_commit: attrs.fetch(:journal_commit, journal_commit),
101
+ accepted_receipts: attrs.fetch(:accepted_receipts, accepted_receipts),
102
+ effects: attrs.fetch(:effects, effects),
103
+ events: attrs.fetch(:events, events),
104
+ updated_at: attrs.fetch(:updated_at, Time.now.utc)
105
+ )
106
+ end
107
+
108
+ # Convert to a hash for canonical serialization.
109
+ # @return [Hash] Attempt data with string keys
110
+ def to_h
111
+ {
112
+ "binding" => binding.to_h,
113
+ "state" => state,
114
+ "candidate_head" => candidate_head,
115
+ "journal_commit" => journal_commit,
116
+ "accepted_receipts" => accepted_receipts,
117
+ "effects" => effects,
118
+ "events" => events,
119
+ "updated_at" => updated_at.iso8601
120
+ }
121
+ end
122
+
123
+ # JSON projection exposed by public commands. Contains attempt
124
+ # ID, state, binding facts, references and digests only — never
125
+ # credentials, receipt bytes, or terminal output.
126
+ #
127
+ # @return [Hash] Redacted projection
128
+ def projection
129
+ {
130
+ "attempt_id" => attempt_id,
131
+ "state" => state,
132
+ "assignment_id" => binding.assignment_id,
133
+ "scope" => binding.scope,
134
+ "project_id" => binding.project_id,
135
+ "task_id" => binding.task_id,
136
+ "actor" => binding.actor,
137
+ "role" => binding.role,
138
+ "runtime" => binding.runtime,
139
+ "base_head" => binding.base_head,
140
+ "candidate_head" => candidate_head,
141
+ "evidence_git_ref" => binding.evidence_git_ref,
142
+ "journal_commit" => journal_commit,
143
+ "recovery_mode" => recovery_mode,
144
+ "accepted_receipt_digests" => accepted_receipts.map { |receipt| receipt["digest"] },
145
+ "unresolved_effects" => unresolved_effects.map { |effect| effect["operation"] },
146
+ "created_at" => binding.created_at.iso8601,
147
+ "updated_at" => updated_at.iso8601
148
+ }
149
+ end
150
+
151
+ # Rebuild an attempt from serialized data.
152
+ #
153
+ # @param data [Hash] Serialized attempt (string keys)
154
+ # @return [Attempt] Rebuilt attempt
155
+ def self.from_h(data)
156
+ new(
157
+ binding: AttemptBinding.from_h(data["binding"]),
158
+ state: data["state"],
159
+ candidate_head: data["candidate_head"],
160
+ journal_commit: data["journal_commit"],
161
+ accepted_receipts: data["accepted_receipts"] || [],
162
+ effects: data["effects"] || [],
163
+ events: data["events"] || [],
164
+ updated_at: parse_time(data["updated_at"])
165
+ )
166
+ end
167
+
168
+ def self.parse_time(value)
169
+ return nil if value.nil?
170
+ return value if value.is_a?(Time)
171
+ require "time"
172
+ Time.parse(value)
173
+ end
174
+ private_class_method :parse_time
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Models
6
+ # Immutable start-time facts for an assignment attempt.
7
+ #
8
+ # A binding freezes exactly what an attempt owns: the immutable attempt
9
+ # ID, the assignment and step/subtree scope, the project, the
10
+ # authenticated actor/role and runtime identity resolved at the
11
+ # execution boundary, the source task ID, the base head captured once at
12
+ # start, and the configured evidence ref. Bindings are never mutated;
13
+ # changes require a new attempt.
14
+ class AttemptBinding
15
+ attr_reader :attempt_id, :assignment_id, :scope, :project_id, :task_id,
16
+ :actor, :role, :runtime, :base_head, :evidence_git_ref, :created_at
17
+
18
+ # @param attempt_id [String] Immutable attempt ID (ace-b36ts)
19
+ # @param assignment_id [String] Owning assignment ID
20
+ # @param scope [String] Canonical step/subtree scope
21
+ # @param project_id [String] Project the attempt executes in
22
+ # @param actor [String] Authenticated actor from the execution boundary
23
+ # @param role [String] Trusted role (coordinator, service, worker)
24
+ # @param runtime [String] Runtime identity of the executing boundary
25
+ # @param base_head [String] Git HEAD captured exactly once at start
26
+ # @param task_id [String, nil] Source task ID (nil for taskless assignments)
27
+ # @param evidence_git_ref [String, nil] Evidence ref for managed attempts
28
+ # @param created_at [Time] Binding creation time
29
+ def initialize(attempt_id:, assignment_id:, scope:, project_id:, actor:, role:, runtime:, base_head:,
30
+ task_id: nil, evidence_git_ref: nil, created_at:)
31
+ @attempt_id = attempt_id.freeze
32
+ @assignment_id = assignment_id.freeze
33
+ @scope = scope.freeze
34
+ @project_id = project_id.freeze
35
+ @task_id = task_id&.freeze
36
+ @actor = actor.freeze
37
+ @role = role.freeze
38
+ @runtime = runtime.freeze
39
+ @base_head = base_head.freeze
40
+ @evidence_git_ref = evidence_git_ref&.freeze
41
+ @created_at = created_at
42
+ end
43
+
44
+ # @return [Boolean] True when the attempt is bound to a source task and
45
+ # eligible for managed evidence-ref delivery
46
+ def managed?
47
+ !task_id.nil? && !task_id.to_s.strip.empty?
48
+ end
49
+
50
+ # Recovery mode implied by task attachment: git-backed journaling for
51
+ # task-attached attempts, disposable local state otherwise.
52
+ #
53
+ # @return [String] "git" or "local_only"
54
+ def recovery_mode
55
+ managed? ? "git" : "local_only"
56
+ end
57
+
58
+ # Identity facts repeated by receipts must match the binding exactly.
59
+ #
60
+ # @param other [AttemptBinding] Binding to compare
61
+ # @return [Boolean] True if both bindings own the same attempt identity
62
+ def same_identity?(other)
63
+ attempt_id == other.attempt_id &&
64
+ assignment_id == other.assignment_id &&
65
+ scope == other.scope &&
66
+ project_id == other.project_id
67
+ end
68
+
69
+ # Convert to a hash for canonical serialization.
70
+ # @return [Hash] Binding data with string keys
71
+ def to_h
72
+ {
73
+ "attempt_id" => attempt_id,
74
+ "assignment_id" => assignment_id,
75
+ "scope" => scope,
76
+ "project_id" => project_id,
77
+ "task_id" => task_id,
78
+ "actor" => actor,
79
+ "role" => role,
80
+ "runtime" => runtime,
81
+ "base_head" => base_head,
82
+ "evidence_git_ref" => evidence_git_ref,
83
+ "created_at" => created_at.iso8601
84
+ }
85
+ end
86
+
87
+ # Rebuild a binding from serialized data.
88
+ #
89
+ # @param data [Hash] Serialized binding (string keys)
90
+ # @return [AttemptBinding] Rebuilt binding
91
+ def self.from_h(data)
92
+ new(
93
+ attempt_id: data["attempt_id"],
94
+ assignment_id: data["assignment_id"],
95
+ scope: data["scope"],
96
+ project_id: data["project_id"],
97
+ task_id: data["task_id"],
98
+ actor: data["actor"],
99
+ role: data["role"],
100
+ runtime: data["runtime"],
101
+ base_head: data["base_head"],
102
+ evidence_git_ref: data["evidence_git_ref"],
103
+ created_at: parse_time(data["created_at"])
104
+ )
105
+ end
106
+
107
+ def self.parse_time(value)
108
+ return value if value.is_a?(Time)
109
+ require "time"
110
+ Time.parse(value)
111
+ end
112
+ private_class_method :parse_time
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/evidence_digest"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Models
8
+ # Append-only accepted evidence event.
9
+ #
10
+ # Evidence events are the journal trail of an attempt: intent,
11
+ # process start, accepted receipts, state transitions, candidate
12
+ # invalidations, and reconciliations. Each event chains to the digest
13
+ # of its predecessor for the attempt, so accepted history cannot be
14
+ # reordered or silently rewritten. Events carry bounded, non-secret
15
+ # metadata only.
16
+ module EvidenceEvent
17
+ TYPES = %w[
18
+ intent process_start receipt_accepted transition
19
+ candidate_invalidated reconciliation
20
+ ].freeze
21
+
22
+ # Build an event chained to a previous digest.
23
+ #
24
+ # @param type [String] Event type (see TYPES)
25
+ # @param attempt_id [String] Owning attempt ID
26
+ # @param payload [Hash] Bounded, non-secret event metadata
27
+ # @param previous_digest [String, nil] Digest of the previous event (chain)
28
+ # @param recorded_at [Time] Event creation time
29
+ # @return [Hash] Serializable event with type, payload, digests, timestamp
30
+ # @raise [ArgumentError] for unknown event types
31
+ def self.build(type:, attempt_id:, payload:, previous_digest: nil, recorded_at: Time.now.utc)
32
+ raise ArgumentError, "Unknown evidence event type: #{type}" unless TYPES.include?(type.to_s)
33
+
34
+ recorded_at = recorded_at.utc
35
+ body = {
36
+ "type" => type.to_s,
37
+ "attempt_id" => attempt_id,
38
+ "payload" => payload,
39
+ "previous_digest" => previous_digest,
40
+ "recorded_at" => recorded_at.iso8601
41
+ }
42
+ body.merge("digest" => Atoms::EvidenceDigest.digest(body))
43
+ end
44
+
45
+ # Validate that a stored event is intact: its digest must match a
46
+ # recomputation over its own body.
47
+ #
48
+ # @param event [Hash] Stored event
49
+ # @return [Boolean] True if the event digest verifies
50
+ def self.valid?(event)
51
+ stored = event["digest"]
52
+ return false if stored.nil?
53
+
54
+ body = {
55
+ "type" => event["type"],
56
+ "attempt_id" => event["attempt_id"],
57
+ "payload" => event["payload"],
58
+ "previous_digest" => event["previous_digest"],
59
+ "recorded_at" => event["recorded_at"]
60
+ }
61
+ Atoms::EvidenceDigest.digest(body) == stored
62
+ end
63
+
64
+ # Validate a whole chain: every event verifies and links to its
65
+ # predecessor.
66
+ #
67
+ # @param events [Array<Hash>] Ordered event list
68
+ # @return [Boolean] True if the chain is intact
69
+ def self.chain_valid?(events)
70
+ previous = nil
71
+ events.all? do |event|
72
+ return false unless valid?(event)
73
+ return false unless event["previous_digest"] == previous
74
+
75
+ previous = event["digest"]
76
+ true
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end