ace-assign 0.63.1 → 0.64.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +43 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  61. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  62. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  63. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  64. data/lib/ace/assign/version.rb +1 -1
  65. data/lib/ace/assign.rb +13 -0
  66. metadata +107 -8
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../../authority/launch_driver"
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class Terminate < Ace::Support::Cli::Command
10
+ desc "Close the original gated native child and request proven abort"
11
+ option :mapping, required: true, desc: "Installed launch mapping ID"
12
+ option :assignment, required: true, desc: "Assignment ID"
13
+ option :attempt, required: true, desc: "Attempt ID"
14
+ def call(**options)
15
+ client = Ace::Assign::Authority::Client.new(mapping_id: options.fetch(:mapping))
16
+ state = client.call("inspect_launch", {"assignment_id" => options.fetch(:assignment), "attempt_id" => options.fetch(:attempt)}).data
17
+ binding = state.fetch("process_binding") { raise ArgumentError, "no positively recorded original child; supervisor inspection required" }
18
+ result = Ace::Assign::Authority::LaunchDriver.new(mapping_id: options.fetch(:mapping)).terminate(
19
+ state: state, binding: binding, evidence: "protected exact-child termination requested")
20
+ puts JSON.generate(result)
21
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
22
+ raise Ace::Support::Cli::Error, error.message
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require "json"
2
3
 
3
4
  module Ace
4
5
  module Assign
@@ -10,6 +11,7 @@ module Ace
10
11
 
11
12
  desc "Create a new workflow assignment"
12
13
 
14
+ option :delivery_parameters, desc: "JSON file with forge selection and explicit PR provenance (task mode)"
13
15
  option :yaml, desc: "Path to job.yaml config file"
14
16
  option :task, aliases: ["-t"], type: :array,
15
17
  desc: "Task reference(s), repeatable and comma-separated"
@@ -19,13 +21,15 @@ module Ace
19
21
 
20
22
  def call(yaml: nil, task: nil, preset: nil, **options)
21
23
  validate_modes!(yaml, task, preset)
24
+ raise Ace::Support::Cli::Error, "--delivery-parameters requires --task" if yaml && options[:delivery_parameters]
22
25
 
23
26
  result = if yaml
24
27
  Organisms::AssignmentExecutor.new.start(yaml)
25
28
  else
26
29
  Organisms::TaskAssignmentCreator.new.call(
27
30
  task_refs: task,
28
- preset_name: preset || Organisms::TaskAssignmentCreator::DEFAULT_PRESET
31
+ preset_name: preset || Organisms::TaskAssignmentCreator::DEFAULT_PRESET,
32
+ delivery_parameters: options[:delivery_parameters] && JSON.parse(File.read(options[:delivery_parameters]))
29
33
  )
30
34
  end
31
35
 
@@ -34,6 +38,10 @@ module Ace
34
38
  print_assignment_header(result[:assignment])
35
39
  print_step_instructions(result[:current] || result[:state]&.next_workable)
36
40
  end
41
+ rescue JSON::ParserError, Errno::ENOENT
42
+ raise Ace::Support::Cli::Error, "Delivery parameters file unavailable or invalid JSON"
43
+ rescue ArgumentError => e
44
+ raise Ace::Support::Cli::Error, e.message
37
45
  end
38
46
 
39
47
  private
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ class Delivery < Ace::Support::Cli::Command
9
+ desc "Execute or reconcile attempt-bound forge delivery"
10
+ option :assignment, required: true, desc: "Managed assignment ID"
11
+ option :attempt, required: true, desc: "Active delivery attempt ID"
12
+ option :operation, required: true, desc: "create, update, ready, review, merge, status"
13
+ option :parameters, desc: "JSON delivery parameters (otherwise assignment source delivery mapping)"
14
+ option :title, desc: "PR title"
15
+ option :body_file, desc: "PR description file"
16
+ option :tests, desc: "JSON reference to coordinator-accepted test attempt and receipt digest"
17
+ option :review, desc: "JSON reference to coordinator-accepted independent review attempt and receipt digest"
18
+ option :service_request, desc: "Completed authorized qjx merge request ID (merge consumption only)"
19
+
20
+ def call(**options)
21
+ result = build_delivery.perform(assignment_id: options[:assignment], attempt_id: options[:attempt],
22
+ operation: options[:operation], parameters: read_json(options[:parameters]),
23
+ title: options[:title], body: options[:body_file] && File.read(options[:body_file]),
24
+ tests: read_json(options[:tests]), review: read_json(options[:review]),
25
+ service_request_id: options[:service_request])
26
+ puts JSON.generate(json_value(result))
27
+ rescue ArgumentError, Ace::Git::Error, Ace::Assign::Error, Errno::ENOENT => e
28
+ raise Ace::Support::Cli::Error, e.message
29
+ end
30
+
31
+ private
32
+
33
+ def build_delivery
34
+ Organisms::DeliveryCoordinator.new(repo_root: Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current)
35
+ end
36
+
37
+ def read_json(path)
38
+ return nil unless path
39
+ data = JSON.parse(File.read(path))
40
+ raise ArgumentError, "Delivery input must be a JSON object" unless data.is_a?(Hash)
41
+ data
42
+ rescue JSON::ParserError
43
+ raise ArgumentError, "Delivery input must be valid JSON"
44
+ end
45
+
46
+ def json_value(value)
47
+ case value
48
+ when Hash then value.transform_values { |item| json_value(item) }
49
+ when Array then value.map { |item| json_value(item) }
50
+ else value.respond_to?(:to_h) ? json_value(value.to_h) : value
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module CLI
6
+ module Commands
7
+ class InboxReconcile < Ace::Support::Cli::Command
8
+ include Ace::Support::Cli::Base
9
+ desc "Verify a Herdr inbox proof and record its references in the assignment journal"
10
+ option :attempt, desc: "Exact attempt ID"
11
+ option :event, desc: "Herdr inbox event ID"
12
+ option :receipt, desc: "Existing native receipt JSON file with detached FILE.sig"
13
+
14
+ def initialize(coordinator: nil, inbox: nil)
15
+ super()
16
+ @coordinator = coordinator
17
+ @inbox = inbox
18
+ end
19
+
20
+ def call(**options)
21
+ %i[attempt event receipt].each do |key|
22
+ raise Ace::Support::Cli::Error, "--#{key} is required" if options[key].to_s.strip.empty?
23
+ end
24
+ result = (@coordinator || Organisms::AttemptCoordinator.new).reconcile_inbox(
25
+ attempt_id: options[:attempt], event_id: options[:event], receipt_path: options[:receipt],
26
+ inbox: @inbox || Ace::Herdr::Organisms::Inbox.from_config)
27
+ # Keep the public projection bounded; raw native output and the
28
+ # human observation text belong to the existing receipt file.
29
+ puts JSON.generate(result.slice("event_id", "attempt_id", "state", "claim_generation",
30
+ "receipt_key_sha256", "reconciliation_refusal"))
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Assign
7
+ module CLI
8
+ module Commands
9
+ class Resume < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+ desc "Recover accepted assignment state without launching another writer"
12
+ option :assignment, desc: "Assignment ID"
13
+ option :dry_run, type: :boolean, default: false, desc: "Inspect recovery without writing observations"
14
+
15
+ def call(**options)
16
+ id = options[:assignment].to_s.strip
17
+ raise Ace::Support::Cli::Error, "--assignment ID is required" if id.empty?
18
+
19
+ puts JSON.generate(Organisms::AttemptCoordinator.new.resume(
20
+ assignment_id: id, dry_run: options[:dry_run]))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -44,6 +44,7 @@ require_relative "molecules/runtime_control_surface_runner"
44
44
  require_relative "molecules/preset_inferrer"
45
45
  require_relative "molecules/evidence_calculator"
46
46
  require_relative "molecules/evidence_journal"
47
+ require_relative "molecules/canonical_evidence"
47
48
  require_relative "molecules/execution_identity_resolver"
48
49
  require_relative "molecules/receipt_verifier"
49
50
  require_relative "molecules/attempt_reconciler"
@@ -53,9 +54,12 @@ require_relative "organisms/assignment_executor"
53
54
  require_relative "organisms/attempt_coordinator"
54
55
 
55
56
  # Commands
57
+ require_relative "cli/commands/delivery"
56
58
  require_relative "cli/commands/create"
57
59
  require_relative "cli/commands/assignment_target"
58
60
  require_relative "cli/commands/status"
61
+ require_relative "cli/commands/resume"
62
+ require_relative "cli/commands/inbox_reconcile"
59
63
  require_relative "cli/commands/step"
60
64
  require_relative "cli/commands/start"
61
65
  require_relative "cli/commands/finish"
@@ -66,6 +70,10 @@ require_relative "cli/commands/list"
66
70
  require_relative "cli/commands/select"
67
71
  require_relative "cli/commands/fork_run"
68
72
  require_relative "cli/commands/fork_session"
73
+ require_relative "cli/commands/authority/serve"
74
+ require_relative "cli/commands/authority/launch"
75
+ require_relative "cli/commands/authority/status"
76
+ require_relative "cli/commands/authority/terminate"
69
77
  require_relative "cli/commands/attempt/base"
70
78
  require_relative "cli/commands/attempt/start"
71
79
  require_relative "cli/commands/attempt/status"
@@ -84,6 +92,11 @@ module Ace
84
92
  # Application commands with descriptions (for help output)
85
93
  REGISTERED_COMMANDS = [
86
94
  ["create", "Create assignment from preset or YAML"],
95
+ ["authority serve", "Serve the installed protected assignment owner"],
96
+ ["authority launch", "Launch one mapped native gated worker"],
97
+ ["authority status", "Inspect protected launch ownership"],
98
+ ["authority terminate", "Terminate the original native gated child"],
99
+ ["delivery", "Execute or reconcile attempt-bound forge delivery"],
87
100
  ["status", "Show assignment status"],
88
101
  ["step", "Show step instructions"],
89
102
  ["start", "Start next workable step"],
@@ -110,7 +123,8 @@ module Ace
110
123
  "cat report.md | ace-assign finish # Complete step via stdin",
111
124
  "ace-assign fork-run 010.01 # Run subtree in subprocess",
112
125
  "ace-assign attempt start --assignment ID --step 010 --project ID",
113
- "ace-assign attempt finish --attempt ID --receipt receipt.json"
126
+ "ace-assign attempt finish --attempt ID --receipt receipt.json",
127
+ "ace-assign resume --assignment ID --dry-run"
114
128
  ].freeze
115
129
 
116
130
  # Captured command exit code from last run
@@ -146,7 +160,12 @@ module Ace
146
160
  end
147
161
 
148
162
  # Register commands (wrapped to capture exit codes)
163
+ register "authority serve", wrap_command(Commands::Authority::Serve)
164
+ register "authority launch", wrap_command(Commands::Authority::Launch)
165
+ register "authority status", wrap_command(Commands::Authority::Status)
166
+ register "authority terminate", wrap_command(Commands::Authority::Terminate)
149
167
  register "create", wrap_command(Commands::Create)
168
+ register "delivery", wrap_command(Commands::Delivery)
150
169
  register "status", wrap_command(Commands::Status)
151
170
  register "step", wrap_command(Commands::Step)
152
171
  register "start", wrap_command(Commands::Start)
@@ -163,6 +182,8 @@ module Ace
163
182
  register "attempt evidence", wrap_command(Commands::Attempt::Evidence)
164
183
  register "attempt finish", wrap_command(Commands::Attempt::Finish)
165
184
  register "attempt reconcile", wrap_command(Commands::Attempt::Reconcile)
185
+ register "resume", wrap_command(Commands::Resume)
186
+ register "inbox-reconcile", wrap_command(Commands::InboxReconcile)
166
187
 
167
188
  # Register version command
168
189
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -17,7 +17,9 @@ module Ace
17
17
  TYPES = %w[
18
18
  intent process_start receipt_accepted transition
19
19
  candidate_invalidated reconciliation
20
- service_claim service_transition
20
+ service_claim service_transition recovery_observation inbox_binding inbox_reconciliation delivery
21
+ authority_mutation evidence_import proposal_state result_submitted
22
+ scope_provisioning scope_bound scope_native_bound scope_child_bound scope_sealed scope_closed_no_writers
21
23
  ].freeze
22
24
 
23
25
  # Build an event chained to a previous digest.
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Ace
4
6
  module Assign
5
7
  module Molecules
6
8
  # Conservative classification and resolution of interrupted attempts.
7
9
  #
8
10
  # Classification of a `running` attempt found after a lost session:
9
- # - no recorded process start -> `stopped` (nothing could have run)
11
+ # - no recorded process start -> `uncertain` (launch may be unbound)
10
12
  # - recorded process still verifiably live -> keep `running`
11
13
  # - otherwise -> `uncertain` (the operation may have completed without
12
14
  # a durable receipt)
@@ -19,9 +21,11 @@ module Ace
19
21
  class AttemptReconciler
20
22
  # @param journal [EvidenceJournal, nil] Managed evidence journal
21
23
  # @param verifier [ReceiptVerifier, nil] Receipt verifier (coordinator supplies on use)
22
- def initialize(journal: nil, verifier: nil)
24
+ def initialize(journal: nil, verifier: nil, observer: nil, runtime_resolver: Ace::Runtime)
23
25
  @journal = journal
24
26
  @verifier = verifier
27
+ @observer = observer || Ace::Runtime::Molecules::ProcessIdentity.new
28
+ @runtime_resolver = runtime_resolver
25
29
  end
26
30
 
27
31
  # Classify a running attempt after an interruption.
@@ -30,8 +34,6 @@ module Ace
30
34
  # @return [Symbol] :stopped, :live, or :uncertain
31
35
  def classify(attempt)
32
36
  return :live if process_live?(attempt)
33
- return :stopped unless process_started?(attempt)
34
-
35
37
  :uncertain
36
38
  end
37
39
 
@@ -44,15 +46,27 @@ module Ace
44
46
  # @param attempt [Models::Attempt] Attempt to inspect
45
47
  # @return [Boolean] True when the recorded process is verifiably alive
46
48
  def process_live?(attempt)
47
- pid = start_event(attempt)&.dig("payload", "pid")
48
- return false unless pid
49
+ observation(attempt)["liveness"] == "live"
50
+ end
51
+
52
+ def observation(attempt)
53
+ payload = start_event(attempt)&.dig("payload")
54
+ observation = @observer.observe(payload&.dig("process_identity"))
55
+ binding = payload&.dig("runtime_binding")
56
+ return observation unless binding && observation["liveness"] == "live"
57
+
58
+ live = @runtime_resolver.resolve(binding["runtime"]).process_binding(
59
+ pane: binding["pane"], caller_pid: binding.dig("process_identity", "pid"))
60
+ return observation if binding == live
49
61
 
50
- Process.kill(0, pid)
51
- true
52
- rescue Errno::EPERM
53
- true
54
- rescue Errno::ESRCH, TypeError, ArgumentError
55
- false
62
+ observation.merge("liveness" => "unknown", "reason" => "native process/session binding changed")
63
+ rescue Ace::Runtime::Error
64
+ {"liveness" => "unknown", "observed_at" => Time.now.utc.iso8601,
65
+ "reason" => "native process/session observation unavailable"}
66
+ end
67
+
68
+ def events(attempt)
69
+ events_for(attempt)
56
70
  end
57
71
 
58
72
  # Runtime recorded at the attempt's process start — the boundary any
@@ -64,6 +78,11 @@ module Ace
64
78
  start_event(attempt)&.dig("payload", "runtime")
65
79
  end
66
80
 
81
+ def recorded_binding(attempt)
82
+ binding = start_event(attempt)&.dig("payload", "runtime_binding")
83
+ JSON.parse(JSON.generate(binding)) if binding.is_a?(Hash)
84
+ end
85
+
67
86
  private
68
87
 
69
88
  def start_event(attempt)
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # One import/verification contract for protected review, service and
11
+ # inbox evidence. Filesystem projections never supply accepted bytes.
12
+ class CanonicalEvidence
13
+ MAX_ARTIFACT_BYTES = 64 * 1024
14
+ MAX_TOTAL_BYTES = 256 * 1024
15
+ MAX_ARTIFACTS = 16
16
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
17
+ ROLES = {"service" => "executor", "review" => "reviewer", "inbox" => "signer",
18
+ "observation" => "observer", "result" => "worker"}.freeze
19
+ DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
20
+ binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
21
+ candidate_generation_or_claim_generation].freeze
22
+
23
+ # Shared closed descriptor validation before clients accept any bytes.
24
+ def self.valid_descriptor?(descriptor)
25
+ return false unless descriptor.is_a?(Hash) && descriptor.keys.sort == DESCRIPTOR_FIELDS.sort &&
26
+ descriptor["version"] == 1 && ROLES.key?(descriptor["kind"]) && ROLES[descriptor["kind"]] == descriptor["role"] &&
27
+ %w[artifact_id project_id assignment_id attempt_id request_id_or_event_id].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(ID) } &&
28
+ %w[binding_digest sha256 admitted_after_event_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
29
+ descriptor["peer_uid"].is_a?(Integer) && descriptor["peer_uid"] >= 0 &&
30
+ descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(0, MAX_ARTIFACT_BYTES) &&
31
+ descriptor["candidate_generation_or_claim_generation"].is_a?(Integer) && descriptor["candidate_generation_or_claim_generation"] >= 0 &&
32
+ descriptor["admitted_at"].is_a?(String)
33
+ Time.iso8601(descriptor.fetch("admitted_at"))
34
+ true
35
+ rescue ArgumentError, TypeError
36
+ false
37
+ end
38
+
39
+ def initialize(journal:)
40
+ @journal = journal
41
+ end
42
+
43
+ # Construct the immutable bytes/events for EvidenceJournal#mutate.
44
+ # peer_uid comes from the accepted kernel-authenticated connection;
45
+ # callers of the wire API cannot supply provenance or artifact IDs.
46
+ def import_plan(kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
47
+ request_id_or_event_id:, generation:, admitted_after_event_digest:, artifacts:)
48
+ role = ROLES.fetch(kind) { raise ArgumentError, "invalid evidence kind" }
49
+ ids = [project_id, assignment_id, attempt_id, request_id_or_event_id]
50
+ unless ids.all? { |id| id.is_a?(String) && id.match?(ID) } && binding.is_a?(Hash) &&
51
+ admitted_after_event_digest.is_a?(String) && admitted_after_event_digest.match?(/\A[0-9a-f]{64}\z/) &&
52
+ peer_uid.is_a?(Integer) && peer_uid >= 0 && generation.is_a?(Integer) && generation >= 0 &&
53
+ artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= MAX_ARTIFACTS &&
54
+ artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES } &&
55
+ artifacts.sum(&:bytesize) <= MAX_TOTAL_BYTES
56
+ raise ArgumentError, "invalid or oversized canonical evidence import"
57
+ end
58
+ events = []
59
+ blobs = {}
60
+ references = artifacts.map do |bytes|
61
+ artifact_id = SecureRandom.hex(16)
62
+ artifact_id += "-no-effect" if kind == "service" && binding["no_effect_challenge"]
63
+ digest = Digest::SHA256.hexdigest(bytes.b)
64
+ path = "evidence/imports/#{artifact_id}"
65
+ descriptor = {"version" => 1, "artifact_id" => artifact_id, "kind" => kind,
66
+ "project_id" => project_id, "assignment_id" => assignment_id,
67
+ "attempt_id" => attempt_id, "peer_uid" => peer_uid, "role" => role,
68
+ "binding_digest" => Atoms::EvidenceDigest.digest(binding), "sha256" => digest,
69
+ "bytes" => bytes.bytesize, "admitted_at" => Time.now.utc.iso8601(9),
70
+ "admitted_after_event_digest" => admitted_after_event_digest,
71
+ "request_id_or_event_id" => request_id_or_event_id,
72
+ "candidate_generation_or_claim_generation" => generation}
73
+ events << {type: "evidence_import", payload: descriptor}
74
+ blobs[path] = bytes.b
75
+ {"ref" => path, "sha256" => digest}
76
+ end
77
+ {events: events, blobs: blobs, references: references}
78
+ end
79
+
80
+ def read(reference, kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
81
+ request_id_or_event_id:, generation:, commit: @journal.ref_value)
82
+ # One immutable commit supplies both provenance events and bytes.
83
+ events = @journal.read_events(assignment_id, commit: commit)
84
+ .select { |event| event["attempt_id"] == attempt_id }
85
+ verified_bytes(reference, events: events, kind: kind, project_id: project_id,
86
+ assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
87
+ request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
88
+ @journal.blob(path, commit: commit)
89
+ end
90
+ end
91
+
92
+ # Only the journal writer supplies this transient pre-CAS view. It
93
+ # validates the exact same provenance/chain/bytes as committed reads;
94
+ # it does not accept a receipt or advance the canonical ref itself.
95
+ def read_pending(reference, current_events:, pending_events:, blobs:, commit:,
96
+ kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
97
+ request_id_or_event_id:, generation:)
98
+ events = current_events + pending_events
99
+ unless events.all? { |event| event["attempt_id"] == attempt_id } && blobs.is_a?(Hash)
100
+ unavailable!
101
+ end
102
+ verified_bytes(reference, events: events, kind: kind, project_id: project_id,
103
+ assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
104
+ request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
105
+ blobs.key?(path) ? blobs.fetch(path) : @journal.blob(path, commit: commit)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def verified_bytes(reference, events:, kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
112
+ request_id_or_event_id:, generation:)
113
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[ref sha256] &&
114
+ reference["ref"].is_a?(String) && reference["ref"].match?(%r{\Aevidence/imports/[a-z0-9-]+\z})
115
+ unavailable!
116
+ end
117
+ unavailable! unless Models::EvidenceEvent.chain_valid?(events)
118
+ artifact_id = reference.fetch("ref").delete_prefix("evidence/imports/")
119
+ imports = events.select do |event|
120
+ event["type"] == "evidence_import" && event.dig("payload", "artifact_id") == artifact_id
121
+ end
122
+ unavailable! unless imports.length == 1
123
+ event = imports.first
124
+ descriptor = event.fetch("payload")
125
+ expected = {"version" => 1, "artifact_id" => artifact_id, "kind" => kind,
126
+ "project_id" => project_id, "assignment_id" => assignment_id,
127
+ "attempt_id" => attempt_id, "peer_uid" => peer_uid, "role" => ROLES.fetch(kind),
128
+ "binding_digest" => Atoms::EvidenceDigest.digest(binding),
129
+ "sha256" => reference.fetch("sha256"), "request_id_or_event_id" => request_id_or_event_id,
130
+ "candidate_generation_or_claim_generation" => generation}
131
+ unavailable! unless self.class.valid_descriptor?(descriptor) &&
132
+ expected.all? { |key, value| descriptor[key] == value }
133
+ before = events.take_while { |entry| entry["digest"] != event["digest"] }
134
+ unavailable! unless before.any? { |entry| entry["digest"] == descriptor["admitted_after_event_digest"] }
135
+ Time.iso8601(descriptor.fetch("admitted_at"))
136
+ bytes = yield(reference.fetch("ref"))
137
+ unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
138
+ Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
139
+ bytes
140
+ rescue ArgumentError, KeyError, TypeError
141
+ unavailable!
142
+ end
143
+
144
+ def unavailable!
145
+ raise AttemptErrors::EvidenceUnavailable, "Canonical evidence import or binding is unverifiable"
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end