ace-assign 0.69.0 → 0.70.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +7 -0
  6. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  7. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  8. data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
  9. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  10. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  11. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  12. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  13. data/lib/ace/assign/authority/ordinary_completion.rb +94 -0
  14. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  15. data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -0
  16. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  17. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  18. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  19. data/lib/ace/assign/authority/ordinary_results.rb +196 -0
  20. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  21. data/lib/ace/assign/authority/prepared_work.rb +2 -2
  22. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  23. data/lib/ace/assign/authority/router.rb +11 -6
  24. data/lib/ace/assign/authority/server.rb +11 -180
  25. data/lib/ace/assign/authority/socket_server.rb +255 -0
  26. data/lib/ace/assign/authority/transfer_codec.rb +2 -2
  27. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  28. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  29. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  30. data/lib/ace/assign/molecules/evidence_journal.rb +141 -34
  31. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  32. data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  34. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  35. data/lib/ace/assign/organisms/attempt_coordinator.rb +51 -36
  36. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  37. data/lib/ace/assign/version.rb +1 -1
  38. metadata +23 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 407eb167c22a0bac683f3ac5db391b535f6200f420a38d458cc13bf36590d930
4
- data.tar.gz: ef23045c1b5c2915768a21110675121dd390a896347dba0f6be90f134eb5fe12
3
+ metadata.gz: 9de34921fd3a299eabfd530c8df3c76fed9405606cb0e717a0458fed9ecf614e
4
+ data.tar.gz: a9a2d22448d0c100b5404ce9ca9666b72ed0e6fb3d9bf158a5051ce001b703b1
5
5
  SHA512:
6
- metadata.gz: 217a25d646c1499d23196d0d43e1eed171914b01fa91532cf2d996f728eb1ef2b60a7126e3cc46aa8b58d0e7015c844637ee67e18c8fb3cfb36b900c81ee4342
7
- data.tar.gz: 4de219ae9bdf137b84d813c599643a459961fd0337b7de987b69ac6edd8cbc4c483bd63def33a30f7bbbb075df66030843dbe71cab847f863edeb2f483ad2c26
6
+ metadata.gz: f68696b349c145768cf15634945c0fe40a659a80394f6053b37fd4270c244d8884345dbc9ba438a257de66593a0e44d88feca77c3c2580ea0e5208221d896746
7
+ data.tar.gz: 07c573b0f8160c1929fbba896362180ad2bd08097ab306139ca0e5a28c00762650845840f8709e16f825660add6057b6569ce49a1f7595370c2f83a6740c4e07
@@ -2,7 +2,7 @@
2
2
  # Usage: ace-assign create --task TASK[,TASK...] --preset deliver-release
3
3
 
4
4
  name: deliver-release
5
- description: Prepare and bind a stable release cut without exercising publication authority
5
+ description: Deliver a bound release cut through the project-authorized publisher and installation owners
6
6
 
7
7
  parameters:
8
8
  taskrefs:
@@ -37,15 +37,18 @@ steps:
37
37
  final artifact digest, and update the manifest before it is accepted. Preparation
38
38
  is not publication, installation, deployment, or proof of any of those effects.
39
39
 
40
- - name: publication-authority-placeholder
40
+ - name: request-authorized-publication
41
41
  number: "040"
42
42
  instructions: |
43
- Publication placeholder only. Do not run a publish command in this preset. Route
44
- the exact manifest to the separate publication authority named by project policy.
45
- Missing credentials, publisher identity, or admission is
46
- BLOCKED(provision, owner). If that authority accepts a request and returns a
47
- durable operation reference plus reconciliation path, record
48
- WAIT(reference, next-check); otherwise preserve uncertainty and do not retry.
43
+ Resolve the concrete publication workflow and executor from project policy and
44
+ invoke that existing workflow with the exact accepted manifest through the separate
45
+ publication authority. This step grants no credentials or permission by itself.
46
+ Missing publisher identity, executable binding, or admission is BLOCKED(provision, owner).
47
+ Use the project's existing publisher, dependency order and scoped HITL OTP route.
48
+ Record the accepted operation reference and reconciliation path before WAIT.
49
+ Track published, pending and uncertain artifacts separately. Verify registry version
50
+ and digest before retry; never republish an already matching artifact or repeat an
51
+ uncertain effect blindly. Keep delivery open after partial or unknown publication.
49
52
 
50
53
  - name: record-release-outcome
51
54
  number: "050"
@@ -32,11 +32,16 @@ steps:
32
32
  - name: delegate-renewed-evidence
33
33
  number: "030"
34
34
  instructions: |
35
- After any candidate movement, delegate renewed evidence for the new exact head:
36
- prove replay content identity with range-diff or a stronger project-approved
37
- comparison, run fresh relevant tests, and obtain an independent reviewer verdict.
38
- The reviewer must be independent of the executor. Content beyond the declared
39
- replay delta requires the full project review gate, not a verdict-stands shortcut.
35
+ Require an initial independent reviewer verdict, then record an accepted-head
36
+ assessment after every candidate movement. Retain old/new head and base SHAs,
37
+ the executed range-diff/content comparison, full repository context, risks and
38
+ fresh relevant tests. Proven identical rebases may carry the initial review;
39
+ patch-id alone is insufficient. An integrator may accept a minor safe correction
40
+ with recorded reasoning and tests. Material or uncertain changes, including small
41
+ authority/security changes, require independent delta review. Keep the initial
42
+ review and formal forge approval actor/state/SHA separate from this assessment.
43
+ Post and read back the evidence before merge; use the exact accepted head in the
44
+ server-side merge guard. Failed publication of evidence blocks integration.
40
45
 
41
46
  - name: reevaluate-integration-readiness
42
47
  number: "040"
@@ -20,8 +20,8 @@ steps:
20
20
 
21
21
  These are references to producer tasks; never reparent, duplicate, reopen, or
22
22
  change producer task status. A PR-ready worker remains terminal even while this
23
- delivery assignment stays open. No second queue, journal, daemon, or cache may
24
- be created. Treat an empty observation as current input only: Transiently empty
23
+ delivery assignment stays open. A local objective queue may store goals, next actions, blockers and references;
24
+ no second execution queue, journal, daemon, or cache may be created. Treat an empty observation as current input only: Transiently empty
25
25
  is not accepted completion.
26
26
 
27
27
  - name: supervise-delivery-queue
@@ -42,8 +42,10 @@ steps:
42
42
  - policy/plan revision and evidence references used for the decision.
43
43
 
44
44
  For every entry, evaluate dependency state, exact candidate head SHA, fresh
45
- test and independent-review evidence bound to that head, required authority and
46
- transport, and grouping constraints. Re-record the decision after dependency,
45
+ tests, initial independent review and a documented accepted-head assessment.
46
+ An identical rebase needs executed content-identity proof; a minor safe correction
47
+ needs integrator reasoning and tests; material or uncertain delta needs independent
48
+ delta review. Evaluate required authority, transport and grouping constraints. Re-record the decision after dependency,
47
49
  candidate head, evidence, authority, transport, or target-branch movement.
48
50
 
49
51
  - name: reconcile-integration-operation
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.70.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Admit ordinary assignments, native starts, progress, submitted results and terminal settlement through the installed canonical owner. Retain original mutations and immutable snapshots across restart, isolate sibling scopes, and join current HITL, service and Inbox evidence.
15
+ - Preserve historical review checks at their recorded head and align delivery presets with accepted-head assessment and serial integration.
16
+
10
17
  ## [0.69.0] - 2026-10-16
11
18
 
12
19
  ### Changed
@@ -47,6 +47,11 @@ module Ace
47
47
  rescue Ace::Assign::AttemptErrors::InvalidScope
48
48
  false
49
49
  end
50
+
51
+ # An owner of a subtree also owns every descendant scope.
52
+ def self.overlap?(left, right)
53
+ equal?(left, right) || left.start_with?("#{right}.") || right.start_with?("#{left}.")
54
+ end
50
55
  end
51
56
  end
52
57
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "campaign_execution"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Shared byte/schema validation; each lifecycle separately authenticates
11
+ # the registration's owner, campaign provenance and original selection.
12
+ module AssignmentDefinition
13
+ def self.parse(bytes:, digest:, assignment_id:, project_id:)
14
+ unless bytes.is_a?(String) && bytes.bytesize <= 32_768 && digest.is_a?(String) &&
15
+ Digest::SHA256.hexdigest(bytes) == digest
16
+ raise ArgumentError, "invalid assignment definition digest or size"
17
+ end
18
+ value = JSON.parse(bytes)
19
+ allowed = %w[session_id name description created_at updated_at source_config parent task_id project_id prepared_work review_campaign campaign_execution]
20
+ unless value.is_a?(Hash) && (value.keys - allowed).empty? &&
21
+ %w[session_id name created_at source_config task_id project_id].all? { |key| value[key].is_a?(String) && !value[key].empty? } &&
22
+ value["session_id"] == assignment_id && value["project_id"] == project_id
23
+ raise ArgumentError, "invalid managed assignment definition"
24
+ end
25
+ CampaignExecution.validate_parent!(value.fetch("review_campaign")) if value.key?("review_campaign")
26
+ assignment = Models::Assignment.from_h(value)
27
+ unless assignment.managed? && assignment.task_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
28
+ raise ArgumentError, "definition is not managed or task identity is invalid"
29
+ end
30
+ assignment
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -115,14 +115,14 @@ module Ace
115
115
  reject!("Prepared export filesystem is unavailable")
116
116
  end
117
117
 
118
- def admit(bytes:, sha256:, size:, head:, &consumer)
118
+ def admit(bytes:, sha256:, size:, head:, deadline: nil, &consumer)
119
119
  unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
120
120
  size == bytes.bytesize && sha256.is_a?(String) &&
121
121
  Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
122
122
  reject!("Candidate bundle binding or size is invalid")
123
123
  end
124
124
  verify_root!
125
- deadline = monotonic + DEADLINE
125
+ deadline = deadline ? [deadline, monotonic + DEADLINE].min : monotonic + DEADLINE
126
126
  Dir.mktmpdir("quarantine-", @root) do |directory|
127
127
  File.chmod(0700, directory)
128
128
  bundle = File.join(directory, "candidate.bundle")
@@ -179,7 +179,7 @@ module Ace
179
179
  @launch.campaign_children_settled!(journal: selected, commit: prefix, params: params, map: map)
180
180
  @launch.completion_scope!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
181
181
  service_settlement_evidence!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
182
- plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"))
182
+ plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"), authority: :protected)
183
183
  plan.merge(data: params.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
184
184
  "receipt_digest" => result.fetch("receipt_digest"), "state" => result.dig("receipt", "verdict")))
185
185
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "../molecules/protected_control_exclusion"
3
3
  require_relative "campaign_execution"
4
+ require_relative "assignment_definition"
4
5
 
5
6
  module Ace
6
7
  module Assign
@@ -117,23 +118,8 @@ module Ace
117
118
  end
118
119
 
119
120
  def validated_registration_definition!(params, map)
120
- bytes = params.fetch("definition_bytes")
121
- unless bytes.is_a?(String) && bytes.bytesize <= 32_768 && params["definition_digest"].is_a?(String) &&
122
- Digest::SHA256.hexdigest(bytes) == params["definition_digest"]
123
- raise ArgumentError, "invalid assignment definition digest or size"
124
- end
125
- value = JSON.parse(bytes)
126
- allowed = %w[session_id name description created_at updated_at source_config parent task_id project_id prepared_work review_campaign campaign_execution]
127
- unless value.is_a?(Hash) && (value.keys - allowed).empty? &&
128
- %w[session_id name created_at source_config task_id project_id].all? { |key| value[key].is_a?(String) && !value[key].empty? } &&
129
- value["session_id"] == params["assignment_id"] && value["project_id"] == map["project_id"]
130
- raise ArgumentError, "invalid managed assignment definition"
131
- end
132
- CampaignExecution.validate_parent!(value.fetch("review_campaign")) if value.key?("review_campaign")
133
- assignment = Models::Assignment.from_h(value)
134
- raise ArgumentError, "definition is not managed" unless assignment.managed?
135
- token!(assignment.task_id)
136
- assignment
121
+ AssignmentDefinition.parse(bytes: params.fetch("definition_bytes"), digest: params["definition_digest"],
122
+ assignment_id: params["assignment_id"], project_id: map["project_id"])
137
123
  end
138
124
  end
139
125
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ module OrdinaryAttemptStart
7
+ START_KEYS = %w[assignment_id attempt_id expected_generation head pane scope].freeze
8
+
9
+ private
10
+
11
+ def start_attempt(request, peer, role, deadline)
12
+ params = request.fetch("params")
13
+ strict!(params, START_KEYS)
14
+ %w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
15
+ if params.fetch("attempt_id").start_with?("definition-")
16
+ raise ArgumentError, "ordinary attempt ID uses the registration namespace"
17
+ end
18
+ token!(request.fetch("mutation_id"))
19
+ unless params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/)
20
+ raise ArgumentError, "ordinary start HEAD is invalid"
21
+ end
22
+ unless %i[supervisor worker].include?(role) && params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation") == 0
23
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary start requires an installed worker and a new attempt"
24
+ end
25
+ commit = @journal.ref_value
26
+ registered = registration(params.fetch("assignment_id"), commit: commit)
27
+ raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
28
+ work = registered_work(registered, commit, deadline)
29
+ scope = params.fetch("scope")
30
+ unless work.manifest.fetch("steps").any? { |step| step.fetch("number") == scope }
31
+ raise AttemptErrors::InvalidScope, "ordinary scope is not in the accepted work"
32
+ end
33
+ keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
34
+ @exclusion.with_shared_multi(keys, deadline: deadline) do
35
+ if @journal.mutation_result(request.fetch("mutation_id"))
36
+ # Work may legitimately have advanced since an acknowledged or
37
+ # lost start reply. Recover its original head, never recapture it.
38
+ identity = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
39
+ return @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
40
+ mutation_id: request.fetch("mutation_id"), operation: "start_attempt", parameters_digest: start_digest(params, peer, identity),
41
+ expected_generation: 0, with_replay: true, deadline: deadline,
42
+ replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do
43
+ raise AttemptErrors::EvidenceUnavailable, "accepted ordinary start disappeared"
44
+ end
45
+ end
46
+ # Only the installed admission can observe the actual native owner
47
+ # and worker checkout. It performs no launch or delivery effect.
48
+ observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: params.fetch("head"), deadline: deadline)
49
+ identity = observed.fetch(:identity)
50
+ principal = {"uid" => peer.fetch("uid"), "actor" => identity.actor}
51
+ digest = start_digest(params, peer, identity)
52
+ authorize!(request, peer, role, deadline)
53
+ @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
54
+ mutation_id: request.fetch("mutation_id"), operation: "start_attempt", parameters_digest: digest,
55
+ expected_generation: 0, with_replay: true, deadline: deadline,
56
+ replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, _generation|
57
+ authorize!(request, peer, role, deadline)
58
+ unless @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline) == identity
59
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary native worker changed before start acceptance"
60
+ end
61
+ unless events.empty? && registration(params.fetch("assignment_id"), commit: prefix) == registered
62
+ raise AttemptErrors::Conflict, "ordinary start registration or attempt changed"
63
+ end
64
+ if @journal.assignment_ids(commit: prefix).any? { |assignment|
65
+ assignment != params.fetch("assignment_id") && @journal.read_events(assignment, commit: prefix).any? { |event|
66
+ event.fetch("attempt_id") == params.fetch("attempt_id") } }
67
+ raise AttemptErrors::Conflict, "ordinary attempt ID belongs to another assignment"
68
+ end
69
+ # Sibling scopes may run independently. A different request ID
70
+ # never repeats an already consumed scope or any part of it.
71
+ if @journal.derived_attempts(params.fetch("assignment_id"), commit: prefix).any? { |attempt|
72
+ Atoms::AssignmentScope.overlap?(attempt.binding.scope, scope) }
73
+ raise AttemptErrors::Conflict, "ordinary scope already has an attempt; reconcile its original execution"
74
+ end
75
+ record = current_queue_record(registered, prefix)
76
+ queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(record, prefix))
77
+ .transition(operation: :activate, scope: scope)
78
+ queue_sha = Digest::SHA256.hexdigest(queue.bytes)
79
+ queue_path = "execution/definitions/#{params.fetch('assignment_id')}-queue-#{queue_sha}.json"
80
+ {events: [
81
+ {type: "intent", payload: {"assignment_id" => params.fetch("assignment_id"), "scope" => scope,
82
+ "project_id" => @project, "task_id" => registered.fetch("task_id"), "base_head" => observed.fetch(:checkout).fetch("head")}},
83
+ {type: "process_start", payload: {"actor" => identity.actor, "role" => identity.role, "runtime" => identity.runtime,
84
+ "pid" => identity.process_pid, "process_identity" => identity.runtime_binding.fetch("process_identity"),
85
+ "runtime_binding" => identity.runtime_binding, "checkout" => observed.fetch(:checkout)}}
86
+ ], blobs: {queue_path => queue.bytes}, data: {
87
+ "authority" => "ordinary", "assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
88
+ "project_id" => @project, "scope" => scope, "state" => "running", "started_by" => principal,
89
+ "base_head" => observed.fetch(:checkout).fetch("head"), "runtime_binding" => identity.runtime_binding,
90
+ "queue_ref" => queue_path, "queue_sha256" => queue_sha}}
91
+ end
92
+ end
93
+ end
94
+
95
+ def start_digest(params, peer, identity)
96
+ Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project,
97
+ "principal" => {"uid" => peer.fetch("uid"), "actor" => identity.actor}})
98
+ end
99
+
100
+ def authorize_start_replay!(request, peer, role, deadline)
101
+ authorize!(request, peer, role, deadline)
102
+ params = request.fetch("params")
103
+ identity = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
104
+ start_replay_owner!(params, identity)
105
+ end
106
+
107
+ def registered_work(registered, commit, deadline)
108
+ reference = registered.fetch("prepared_work")
109
+ bytes = @journal.bounded_blob(registered.fetch("prepared_bundle_ref"), commit: commit,
110
+ max_bytes: registered.fetch("prepared_bundle_bytes"))
111
+ work = PreparedWork.admit(bytes: bytes, head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"),
112
+ sha256: registered.fetch("prepared_bundle_sha256"), size: registered.fetch("prepared_bundle_bytes"),
113
+ root: @input_root, deadline: deadline)
114
+ unless work.reference(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree")) == reference &&
115
+ work.manifest.values_at("assignment_id", "project_id", "task_id") == registered.values_at("assignment_id", "project_id", "task_id") &&
116
+ Digest::SHA256.hexdigest(work.definition_bytes(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"))) == registered.fetch("definition_digest")
117
+ raise AttemptErrors::EvidenceUnavailable, "ordinary prepared registration bytes differ"
118
+ end
119
+ work
120
+ end
121
+
122
+ def start_replay_owner!(params, identity)
123
+ events = @journal.read_events(params.fetch("assignment_id"))
124
+ .select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
125
+ unless Models::EvidenceEvent.chain_valid?(events) && events.count { |event| event["type"] == "process_start" } == 1
126
+ raise AttemptErrors::EvidenceUnavailable, "ordinary start evidence is unavailable"
127
+ end
128
+ owner = events.find { |event| event["type"] == "process_start" }.fetch("payload")
129
+ unless owner.values_at("actor", "role", "runtime", "runtime_binding") ==
130
+ [identity.actor, identity.role, identity.runtime, identity.runtime_binding]
131
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary replay belongs to its original native worker"
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ordinary_peer"
4
+ require_relative "transfer_codec"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Installed selection is supplied by the caller's trusted composition.
10
+ # This client never selects a journal, local fallback or execution identity.
11
+ class OrdinaryClient
12
+ Reply = Struct.new(:data, :replayed, :parts, keyword_init: true)
13
+
14
+ def initialize(project_id:, service:, verify_selection:, peer: OrdinaryPeer.new, scratch_root: nil)
15
+ @project_id, @service, @verify_selection, @peer, @scratch_root = project_id, service, verify_selection, peer, scratch_root
16
+ end
17
+
18
+ def call(operation, params, mutation_id: nil, upload_parts: nil, download: false, purpose: nil, timeout: 30)
19
+ unless operation.is_a?(String) && params.is_a?(Hash) && !params.key?("transfer") &&
20
+ timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
21
+ raise ArgumentError, "invalid ordinary authority request"
22
+ end
23
+ raise ArgumentError, "ordinary download needs private scratch and forbids upload" if download && (upload_parts || !@scratch_root)
24
+ @verify_selection.call
25
+ wire = Ace::Runtime::Molecules::ProtectedSocket
26
+ path = @service.fetch("socket_path")
27
+ wire.root_path!(File.dirname(path), directory: true, owner: @service.fetch("uid"))
28
+ before = wire.socket_identity(path, mode: 0o660)
29
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint owner differs" unless before.last == @service.fetch("uid")
30
+ codec = TransferCodec.new(root: download ? @scratch_root : nil)
31
+ descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
32
+ parameters = descriptor ? params.merge("transfer" => descriptor) : params
33
+ deadline = wire.deadline([timeout, 30].min)
34
+ wire.connect(path, deadline: deadline) do |socket|
35
+ observed = @peer.capture(socket, deadline: deadline)
36
+ unless observed.values_at("uid", "gid") == @service.values_at("uid", "gid") && wire.socket_identity(path, mode: 0o660) == before
37
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint peer changed"
38
+ end
39
+ @verify_selection.call
40
+ wire.write(socket, {"version" => 1, "operation" => operation, "mutation_id" => mutation_id,
41
+ "project_id" => @project_id, "params" => parameters}, deadline: deadline, limit: 16_384)
42
+ codec.send(socket, parts: upload_parts, descriptor: descriptor, purpose: purpose, deadline: deadline) if upload_parts
43
+ socket.shutdown(Socket::SHUT_WR)
44
+ ingress = wire::Ingress.new(socket)
45
+ result = wire.read(ingress, deadline: deadline, limit: 16_384)
46
+ unless result.is_a?(Hash) && result.keys.sort == %w[data status transport] && result["status"] == "ok" &&
47
+ result["data"].is_a?(Hash) && result["transport"].is_a?(Hash) &&
48
+ result["transport"].keys == ["replayed"] && [true, false].include?(result["transport"]["replayed"])
49
+ code = result.is_a?(Hash) && result["error"].is_a?(Hash) ? result["error"]["code"] : nil
50
+ raise AttemptErrors::EvidenceUnavailable, "ordinary authority refused (#{code || 'invalid_response'}); retain original mutation"
51
+ end
52
+ parts = if download
53
+ codec.receive(ingress, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
54
+ Array.new(input.count) { |index| input.bytes(index: index) }
55
+ end
56
+ end
57
+ @verify_selection.call
58
+ @peer.live!(observed, deadline: deadline)
59
+ unless wire.socket_identity(path, mode: 0o660) == before
60
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint changed before response acceptance"
61
+ end
62
+ Reply.new(data: result.fetch("data"), replayed: result.fetch("transport").fetch("replayed"), parts: parts)
63
+ end
64
+ rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
65
+ raise AttemptErrors::EvidenceUnavailable, "ordinary authority outcome unavailable; retain original mutation; no local fallback"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ordinary_effect_settlement"
4
+ require_relative "../organisms/attempt_coordinator"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Terminal acceptance consumes the submitted canonical receipt. The
10
+ # supervisor cannot replace its bytes, worker, scope or candidate.
11
+ module OrdinaryCompletion
12
+ FINISH_KEYS = %w[assignment_id attempt_id expected_generation pane head result_id receipt_digest].freeze
13
+
14
+ private
15
+
16
+ def finish_result(request, peer, role, deadline)
17
+ params = request.fetch("params")
18
+ strict!(params, FINISH_KEYS)
19
+ %w[assignment_id attempt_id result_id].each { |key| token!(params.fetch(key)) }
20
+ token!(request.fetch("mutation_id"))
21
+ unless role == :supervisor
22
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary completion requires the installed supervisor"
23
+ end
24
+ unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive? &&
25
+ params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/) &&
26
+ params.fetch("receipt_digest").is_a?(String) && params.fetch("receipt_digest").match?(/\A[0-9a-f]{64}\z/)
27
+ raise ArgumentError, "ordinary completion selection is invalid"
28
+ end
29
+ registered = registration(params.fetch("assignment_id"), commit: @journal.ref_value)
30
+ raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
31
+ digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
32
+ @exclusion.with_shared_multi([@exclusion.task_key(registered.fetch("task_id"))], deadline: deadline) do
33
+ @exclusion.with_exclusive(@exclusion.assignment_key(params.fetch("assignment_id")), deadline: deadline) do
34
+ authorize!(request, peer, role, deadline)
35
+ with_result_transport(request, deadline) do |transport|
36
+ @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
37
+ mutation_id: request.fetch("mutation_id"), operation: "finish", parameters_digest: digest,
38
+ expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
39
+ replay_authorizer: -> { authorize!(request, peer, role, deadline) }) do |events, prefix, _generation|
40
+ authorize!(request, peer, role, deadline)
41
+ unless Molecules::CanonicalAttemptState.derive(events) == "running"
42
+ raise AttemptErrors::Conflict, "ordinary completion requires a running attempt"
43
+ end
44
+ result = retained_ordinary_result(params, events, prefix)
45
+ unless result && result.fetch("result_id") == params.fetch("result_id") &&
46
+ result.dig("receipt", "digest") == params.fetch("receipt_digest") && result.dig("binding", "head") == params.fetch("head")
47
+ raise AttemptErrors::ReceiptRejected, "ordinary completion requires its exact submitted result"
48
+ end
49
+ binding = result.fetch("binding")
50
+ observed = @admission.recorded_execution!(binding: binding.fetch("runtime_binding"), head: binding.fetch("head"), deadline: deadline)
51
+ unless observed.fetch(:runtime_binding) == binding.fetch("runtime_binding") && observed.fetch(:checkout) == binding.fetch("checkout")
52
+ raise AttemptErrors::UnauthorizedIdentity, "ordinary result worker checkout changed"
53
+ end
54
+ references = events.select { |event| event["type"] == "inbox_binding" }.map { |event| event.fetch("payload") }
55
+ delivery = transport.call(references: references, attempt_id: params.fetch("attempt_id"), owner: binding.fetch("runtime_binding"))
56
+ scope = events.find { |event| event["type"] == "intent" }.fetch("payload").fetch("scope")
57
+ work = registered_work(registered, prefix, deadline)
58
+ queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(current_queue_record(registered, prefix), prefix))
59
+ verdict = result.dig("receipt", "verdict")
60
+ complete = verdict == "succeeded" ? queue.status.subtree_complete?(scope) : queue.status.subtree_failed?(scope)
61
+ raise AttemptErrors::Conflict, "ordinary result scope has unfinished progress" unless complete
62
+ settlement = OrdinaryEffectSettlement.new(journal: @journal).verify!(project: @project,
63
+ assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), events: events, commit: prefix)
64
+ authorize!(request, peer, role, deadline)
65
+ plan = Organisms::AttemptCoordinator.finished_transition_plan(events: events, receipt: result.fetch("receipt"), authority: :ordinary)
66
+ plan.merge(data: params.slice("assignment_id", "attempt_id", "result_id", "head", "receipt_digest").merge(
67
+ "authority" => "ordinary", "scope" => scope, "state" => verdict, "settlement" => settlement, "inbox_deliveries" => delivery))
68
+ end
69
+ end
70
+ end
71
+ end
72
+ rescue Ace::Herdr::Error => error
73
+ raise AttemptErrors::EvidenceUnavailable, "ordinary transport settlement is unavailable: #{error.message}"
74
+ end
75
+
76
+ def with_result_transport(request, deadline)
77
+ if @journal.mutation_result(request.fetch("mutation_id"), commit: @journal.ref_value)
78
+ # A terminal reply is immutable. Current peer authority is still
79
+ # checked by mutate, but retired transport files are not replay input.
80
+ return yield nil
81
+ end
82
+ if @inbox
83
+ @inbox.with_delivery_settlement(deadline: deadline) { |verify| yield verify }
84
+ else
85
+ yield lambda { |references:, **_|
86
+ raise AttemptErrors::EvidenceUnavailable, "ordinary Inbox owner is not installed" unless references.empty?
87
+ []
88
+ }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end