ace-git 0.29.0 → 0.30.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecb7e3224f26cacff289f62d5199d90740ee07a4fbaa378649fc948133e6d05c
4
- data.tar.gz: 13ac60da2a81dda68242bc0c9f5f21f5e44f764562f24877426e873b59137bbf
3
+ metadata.gz: ecccf931f808308fde6ab00654e43fdcbe7b90e0f0c929ade43a25a427302756
4
+ data.tar.gz: 03af540433b453863a3d399a67565724c68288c0a4a2b283462f613293a4a6a0
5
5
  SHA512:
6
- metadata.gz: 19e1dfe5f0315b38572c0c8464852bfb7f2c25477f92f646c73df44d4b0c0f06d72c32cd6e961ef8185ed1ced37c57cd111a4980dec8f698d1eba3ad7596e8c7
7
- data.tar.gz: 16ff4c84f2f86b690116792e97cfcbe323c5dd2209d63e5148f6d4aa35fc3725a99dc84b87695cb4d00b9187816ab6652f1194ba7940caf11f6d96dd065b4fdf
6
+ metadata.gz: c999f3855347dda4bfac0ce490ec94666f1e9e2cf00b2907d0ad72fa5c083e00495e0f4296036770d106bb1decddca02a37b495e71d87552344899a4321cba08
7
+ data.tar.gz: 0decf0ad84220579f16b3606d9c6ee3d6193e0a4dfbee22474cd4ee8008b5a75c6fc4bfc9f77bb8d27ae14fbdf65a3c95c957644ac318e08caffe03f7d0e768c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.30.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - Execute protected draft create/update, ready and merge through named forge providers, binding exact repository provenance, candidate SHA, input and current campaign acceptance.
15
+
16
+ ### Fixed
17
+
18
+ - Resolve receiver provider identity from the authenticated PR URL and preserve uncertain effects without replay.
19
+
20
+ - Verify protected request, status and delivery refuse actual authority endpoint loss without replacing the original claim or dispatching another effect.
21
+
22
+ - Restore process-wide provider registrations and loader state after registry test fixtures, preserving real adapter tests across randomized single-batch execution.
23
+
24
+ - Resolve receiver-side merge provider identity from the authenticated PR URL when the prepared candidate has no remote, refusing mismatched or changed pinned servers.
25
+
26
+ - Route protected delivery through original worker merge request, canonical status and exact result consumption while preserving ordinary delivery and unresolved protected PR policy.
27
+
28
+
29
+ ### Added
30
+
31
+ - Fixed receiver-side `service merge` validates the claimed candidate/input, uses the selected neutral provider once, and publishes bounded evidence only for a verified exact-head merged outcome.
32
+
10
33
  ## [0.29.0] - 2026-10-05
11
34
 
12
35
  ### Removed
data/docs/usage.md CHANGED
@@ -289,6 +289,23 @@ Note: `test/**/*` and `spec/**/*` are NOT excluded by default - test changes are
289
289
 
290
290
  ## Provider Packages
291
291
 
292
+ `ace-git service create`, `service update`, `service ready` and `service merge`
293
+ are fixed protected receiver handlers. It accepts no
294
+ options; the receiver supplies its claimed JSON envelope on stdin and its
295
+ private materialized candidate directory as the working directory. The handler
296
+ checks the exact input digest, executor, candidate and forge provenance, calls
297
+ the selected neutral operation once, and emits the existing bounded evidence
298
+ response only after verifying the exact PR outcome (and merge commit for merge). An unverified or lost
299
+ outcome emits no success and never retries. This command does not authorize a
300
+ operation or replace the protected worker's canonical receipt acceptance.
301
+ Create/update use an exact `{target,delivery,title,body}` input and remain draft;
302
+ ready uses `{target,delivery}`, merge uses `{target,delivery,method}`. Create targets
303
+ the base repository; all other operations target the exact PR URL. The original
304
+ title remains part of the accepted input; the provider owns its draft encoding
305
+ (Forgejo's WIP prefix). Updating a draft preserves that encoding. Each handler
306
+ publishes the fixed `pr-result.txt` evidence only after its observed content,
307
+ identity, head and draft/merge outcome agree.
308
+
292
309
  `ace-git` is forge-neutral. It defines the server registry (`Ace::Git::ServerRegistry`),
293
310
  the provider contract (`Ace::Git::Providers::Base`), and normalized evidence types;
294
311
  provider packages own all forge CLI behavior:
@@ -22,6 +22,40 @@ Use `forge_server` or `forge_default: true`, mutually exclusive. Neither means r
22
22
  {"forge_server":"forge-lab","pr_provenance":{"mode":"canonical","head_repository_url":"https://forge.example/team/repo","head_ref":"feature","base_repository_url":"https://forge.example/team/repo","base_ref":"main"}}
23
23
  ```
24
24
 
25
+ ## Protected worker boundary
26
+
27
+ The steps below are ordinary managed delivery only. An installed protected
28
+ participant uses the configured receiver and the original canonical authority,
29
+ with no caller-local journal or direct-provider fallback.
30
+
31
+ The authenticated worker may create/update a draft before independent review.
32
+ Save a nonsecret input object with exactly `target`, `delivery`, `title`, `body`;
33
+ `delivery` is the normalized original forge/provenance mapping. `target` has
34
+ `resource` and nullable `artifact_digest`: the selected base repository URL for
35
+ create, the exact recorded PR URL for update. Title/body are bounded UTF-8 text.
36
+ Request the installed receiver with:
37
+
38
+ ```sh
39
+ ace-lab service request --project PROJECT --assignment ID --attempt ATTEMPT \
40
+ --mapping MAPPING --scope SCOPE --service SERVICE --operation OPERATION \
41
+ --candidate-head SHA --candidate-generation CANDIDATE-GENERATION \
42
+ --expected-generation AUTHORITY-GENERATION --authorization AUTHORIZATION \
43
+ --request-id REQUEST --input INPUT.json
44
+ ```
45
+
46
+ Use `create` or `update` as OPERATION; each requires its own exact scoped grant.
47
+ The receiver runs the fixed `ace-git service create` or `service update` handler.
48
+ A claim acknowledges admission, not PR success. Retain its complete selection
49
+ and observe `ace-lab service status` as described in
50
+ `wfi://handbook/perform-delivery`. Lost replies stay uncertain; status does not
51
+ repeat an effect. Only canonical completion supplies the created PR URL.
52
+
53
+ For ready, use the same request with OPERATION `ready` and input containing only
54
+ `target` and `delivery`. Ready and merge require executed checks, accepted
55
+ independent review for the current revision and the matching authorization.
56
+ Local report/reference files cannot replace those canonical proofs. Draft
57
+ creation/update does not grant readiness, merge or publication.
58
+
25
59
  ## Steps
26
60
 
27
61
  1. Read the task and actual committed diff; prepare a concrete title and description explaining behavior and executed validation. Save the description as a local file.
@@ -14,6 +14,40 @@ Update the recorded PR description for the current candidate and preserve provid
14
14
 
15
15
  A managed assignment with a recorded PR and active delivery attempt. Reuse the original forge selection and complete fork/canonical provenance; changed configuration or identity is a conflict. A new committed candidate, including task-only changes, requires renewed tests and independent review.
16
16
 
17
+ ## Protected worker boundary
18
+
19
+ The steps below are ordinary managed delivery only. An installed protected
20
+ participant uses the configured receiver and the original canonical authority,
21
+ with no caller-local journal or direct-provider fallback.
22
+
23
+ The authenticated worker may create/update a draft before independent review.
24
+ Save a nonsecret input object with exactly `target`, `delivery`, `title`, `body`;
25
+ `delivery` is the normalized original forge/provenance mapping. `target` has
26
+ `resource` and nullable `artifact_digest`: the selected base repository URL for
27
+ create, the exact recorded PR URL for update. Title/body are bounded UTF-8 text.
28
+ Request the installed receiver with:
29
+
30
+ ```sh
31
+ ace-lab service request --project PROJECT --assignment ID --attempt ATTEMPT \
32
+ --mapping MAPPING --scope SCOPE --service SERVICE --operation OPERATION \
33
+ --candidate-head SHA --candidate-generation CANDIDATE-GENERATION \
34
+ --expected-generation AUTHORITY-GENERATION --authorization AUTHORIZATION \
35
+ --request-id REQUEST --input INPUT.json
36
+ ```
37
+
38
+ Use `create` or `update` as OPERATION; each requires its own exact scoped grant.
39
+ The receiver runs the fixed `ace-git service create` or `service update` handler.
40
+ A claim acknowledges admission, not PR success. Retain its complete selection
41
+ and observe `ace-lab service status` as described in
42
+ `wfi://handbook/perform-delivery`. Lost replies stay uncertain; status does not
43
+ repeat an effect. Only canonical completion supplies the created PR URL.
44
+
45
+ For ready, use the same request with OPERATION `ready` and input containing only
46
+ `target` and `delivery`. Ready and merge require executed checks, accepted
47
+ independent review for the current revision and the matching authorization.
48
+ Local report/reference files cannot replace those canonical proofs. Draft
49
+ creation/update does not grant readiness, merge or publication.
50
+
17
51
  ## Steps
18
52
 
19
53
  1. Inspect the committed changes and accepted test/review evidence. Prepare the final description with the concrete behavior, executed test results, independent verdict, and actual limitations. Save it as a local file; do not put credentials or terminal dumps in the attempt journal.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ require_relative "server_url"
3
+
4
+ module Ace
5
+ module Git
6
+ module Atoms
7
+ # Explicit non-secret remote delivery inputs; local recipes need not call this validator.
8
+ module DeliveryParameters
9
+ PROVENANCE_FIELDS = %w[mode head_repository_url head_ref base_repository_url base_ref].freeze
10
+ def self.validate(input)
11
+ raise ArgumentError, "Delivery parameters must be a mapping" unless input.is_a?(Hash)
12
+ unless (input.keys - %w[forge_server forge_default pr_provenance]).empty?
13
+ raise ArgumentError, "Unexpected delivery parameter fields"
14
+ end
15
+ default = input.fetch("forge_default", false)
16
+ raise ArgumentError, "forge_default must be boolean" unless [true, false].include?(default)
17
+ server = input["forge_server"]
18
+ if server && (!server.is_a?(String) || !server.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/))
19
+ raise ArgumentError, "Invalid forge_server"
20
+ end
21
+ raise ArgumentError, "forge_server and forge_default are mutually exclusive" if server && default
22
+ provenance = input["pr_provenance"]
23
+ unless provenance.is_a?(Hash) && provenance.keys.sort == PROVENANCE_FIELDS.sort &&
24
+ %w[fork canonical].include?(provenance["mode"])
25
+ raise ArgumentError, "Explicit complete fork or canonical PR provenance is required"
26
+ end
27
+ %w[head_repository_url base_repository_url].each { |key| validate_url!(provenance[key]) }
28
+ %w[head_ref base_ref].each do |key|
29
+ ref = provenance[key]
30
+ unless ref.is_a?(String) && ref.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_\/.\-]{0,255}\z/) &&
31
+ !ref.include?("..") && !ref.include?("//") && !ref.end_with?("/", ".", ".lock")
32
+ raise ArgumentError, "Invalid PR ref"
33
+ end
34
+ end
35
+ if provenance["mode"] == "canonical" && !Ace::Git::Atoms::ServerUrl.match?(
36
+ provenance["head_repository_url"], provenance["base_repository_url"])
37
+ raise ArgumentError, "Canonical provenance requires matching head and base repositories"
38
+ end
39
+ {"forge_server" => server, "forge_default" => default,
40
+ "pr_provenance" => provenance.transform_values(&:dup)}
41
+ end
42
+
43
+ def self.validate_url!(url)
44
+ unless url.is_a?(String) && url.length <= 512 &&
45
+ (url.match?(%r{\Ahttps?://[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}) ||
46
+ url.match?(%r{\Agit@[a-zA-Z0-9.-]+:[a-zA-Z0-9_./-]+\z}) ||
47
+ url.match?(%r{\Assh://git@[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}))
48
+ raise ArgumentError, "PR repository URL must contain a repository and no credentials or query"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "delivery_parameters"
4
+ require_relative "pr_reference"
5
+ require_relative "../providers/evidence"
6
+
7
+ module Ace
8
+ module Git
9
+ module Atoms
10
+ # Parse executor-owned evidence; authority supplies the original binding
11
+ # and separately checks the reconstructed input's canonical digest.
12
+ module ServiceMergeEvidence
13
+ MAX_BYTES = 64 * 1024
14
+ def self.validate(bytes, request_id:, input_digest:, target:, head:)
15
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_BYTES) &&
16
+ bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
17
+ raise ArgumentError, "merge evidence is invalid or oversized"
18
+ end
19
+ marker, payload = bytes.split("\n", 2)
20
+ unless marker == "ace-service-attestation request:#{request_id} input:#{input_digest} outcome:succeeded"
21
+ raise ArgumentError, "merge evidence original marker differs"
22
+ end
23
+ value = JSON.parse(payload.to_s, max_nesting: 16, create_additions: false,
24
+ allow_duplicate_key: false, allow_comments: false, allow_nan: false)
25
+ closed!(value, %w[server delivery method candidate_head receipt])
26
+ delivery = DeliveryParameters.validate(value.fetch("delivery"))
27
+ unless value["delivery"] == delivery && value["delivery"].keys.sort == %w[forge_default forge_server pr_provenance] &&
28
+ %w[squash merge rebase].include?(value["method"]) && value["candidate_head"] == head
29
+ raise ArgumentError, "merge evidence selection/method/head differs"
30
+ end
31
+ server, receipt = value.values_at("server", "receipt")
32
+ closed!(server, %w[name provider url])
33
+ closed!(receipt, %w[server_name operation pull_request idempotency])
34
+ unless server["name"].is_a?(String) && server["name"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
35
+ server["provider"].is_a?(String) && server["provider"].match?(/\A[a-zA-Z0-9_-]{1,128}\z/) &&
36
+ receipt["server_name"] == server["name"] && receipt["operation"] == "merge"
37
+ raise ArgumentError, "merge evidence provider receipt differs"
38
+ end
39
+ DeliveryParameters.validate_url!(server["url"])
40
+ pr = receipt.fetch("pull_request")
41
+ closed!(pr, ProviderPullRequest.members.map(&:to_s))
42
+ provenance = delivery.fetch("pr_provenance")
43
+ reference = target.is_a?(Hash) && PrReference.parse(target["resource"])
44
+ unless reference && reference.repository_url && pr["number"].is_a?(Integer) && pr["number"].positive? &&
45
+ pr["number"] == reference.number && pr["url"] == target["resource"] && pr["server_name"] == server["name"] &&
46
+ pr["head_sha"] == head && head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) &&
47
+ pr["state"] == "merged" && [true, false].include?(pr["draft"]) &&
48
+ pr.except("number", "draft").values.all? { |value| value.nil? || value.is_a?(String) } &&
49
+ pr["merge_commit_sha"].is_a?(String) && pr["merge_commit_sha"].match?(/\A[0-9a-f]{40}\z/) &&
50
+ pr["head_ref"] == provenance["head_ref"] && pr["base_ref"] == provenance["base_ref"] &&
51
+ ServerUrl.match?(reference.repository_url, provenance["base_repository_url"]) &&
52
+ ServerUrl.match?(server["url"], provenance["base_repository_url"]) &&
53
+ ServerUrl.match?(pr["head_repository_url"], provenance["head_repository_url"]) &&
54
+ ServerUrl.match?(pr["base_repository_url"], provenance["base_repository_url"]) &&
55
+ (!delivery["forge_server"] || delivery["forge_server"] == server["name"])
56
+ raise ArgumentError, "merge evidence PR identity or outcome differs"
57
+ end
58
+ {input: {"target" => target, "delivery" => delivery, "method" => value.fetch("method")},
59
+ pr: pr, server: server}
60
+ rescue JSON::ParserError, KeyError, TypeError
61
+ raise ArgumentError, "merge evidence is malformed"
62
+ end
63
+
64
+ def self.closed!(value, fields)
65
+ raise ArgumentError, "merge evidence fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
66
+ end
67
+ private_class_method :closed!
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "service_pr_input"
4
+ require_relative "../providers"
5
+ require_relative "../server_registry"
6
+
7
+ module Ace
8
+ module Git
9
+ module Atoms
10
+ module ServicePrEvidence
11
+ def self.validate(bytes, operation:, request_id:, input_digest:, target:, head:)
12
+ unless ServicePrInput::DRAFT_OPERATIONS.include?(operation) || operation == "ready"
13
+ raise ArgumentError, "unsupported PR evidence operation"
14
+ end
15
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 64 * 1024) &&
16
+ bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
17
+ raise ArgumentError, "PR evidence is invalid or oversized"
18
+ end
19
+ marker, payload = bytes.split("\n", 2)
20
+ unless marker == "ace-service-attestation request:#{request_id} input:#{input_digest} outcome:succeeded"
21
+ raise ArgumentError, "PR evidence original marker differs"
22
+ end
23
+ value = JSON.parse(payload.to_s, max_nesting: 16, create_additions: false,
24
+ allow_duplicate_key: false, allow_comments: false, allow_nan: false)
25
+ closed!(value, %w[server operation input candidate_head receipt])
26
+ input = ServicePrInput.validate(value.fetch("input"), operation: operation)
27
+ unless value["operation"] == operation && value["candidate_head"] == head && input["target"] == target
28
+ raise ArgumentError, "PR evidence operation/input/head differs"
29
+ end
30
+ server, receipt = value.values_at("server", "receipt")
31
+ closed!(server, %w[name provider url])
32
+ closed!(receipt, %w[server_name operation pull_request idempotency])
33
+ pr = receipt.fetch("pull_request")
34
+ closed!(pr, ProviderPullRequest.members.map(&:to_s))
35
+ delivery = input.fetch("delivery")
36
+ provenance = delivery.fetch("pr_provenance")
37
+ reference = PrReference.parse(pr["url"])
38
+ unless server["name"].is_a?(String) && server["provider"].is_a?(String) && server["url"].is_a?(String)
39
+ raise ArgumentError, "PR evidence provider identity is malformed"
40
+ end
41
+ selected = ResolvedServer.new(name: server["name"], provider: server["provider"], url: server["url"])
42
+ expected_title = Providers.for(selected).class.pull_request_title(title: input["title"], draft: true) if ServicePrInput::DRAFT_OPERATIONS.include?(operation)
43
+ unless reference && reference.repository_url && pr["number"] == reference.number &&
44
+ pr["number"].is_a?(Integer) && pr["number"].positive? &&
45
+ server["name"].is_a?(String) && server["name"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
46
+ server["provider"].is_a?(String) && server["provider"].match?(/\A[a-zA-Z0-9_-]{1,128}\z/) &&
47
+ receipt["server_name"] == server["name"] && receipt["operation"] == operation &&
48
+ pr["server_name"] == server["name"] && pr["head_sha"] == head &&
49
+ head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) && pr["state"] == "open" &&
50
+ pr["draft"] == ServicePrInput::DRAFT_OPERATIONS.include?(operation) &&
51
+ pr.except("number", "draft").values.all? { |item| item.nil? || item.is_a?(String) } &&
52
+ (operation == "create" ? %w[created existing].include?(receipt["idempotency"]) : receipt["idempotency"].nil?) &&
53
+ pr["head_ref"] == provenance["head_ref"] && pr["base_ref"] == provenance["base_ref"] &&
54
+ ServerUrl.match?(reference.repository_url, provenance["base_repository_url"]) &&
55
+ ServerUrl.match?(server["url"], provenance["base_repository_url"]) &&
56
+ ServerUrl.match?(pr["head_repository_url"], provenance["head_repository_url"]) &&
57
+ ServerUrl.match?(pr["base_repository_url"], provenance["base_repository_url"]) &&
58
+ (!delivery["forge_server"] || delivery["forge_server"] == server["name"]) &&
59
+ (operation == "create" || pr["url"] == target["resource"]) &&
60
+ (operation == "ready" || pr["title"] == expected_title && pr["body"] == input["body"])
61
+ raise ArgumentError, "PR evidence selected identity or outcome differs"
62
+ end
63
+ DeliveryParameters.validate_url!(server["url"])
64
+ {input: input, pr: pr, server: server}
65
+ rescue JSON::ParserError, KeyError, TypeError, Ace::Git::Error
66
+ raise ArgumentError, "PR evidence is malformed"
67
+ end
68
+
69
+ def self.closed!(value, fields)
70
+ raise ArgumentError, "PR evidence fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
71
+ end
72
+ private_class_method :closed!
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ require_relative "delivery_parameters"
3
+ require_relative "pr_reference"
4
+
5
+ module Ace
6
+ module Git
7
+ module Atoms
8
+ # Fixed receiver input vocabulary; draft is source policy, not a flag.
9
+ module ServicePrInput
10
+ OPERATIONS = %w[create update ready merge].freeze
11
+ DRAFT_OPERATIONS = %w[create update].freeze
12
+
13
+ def self.validate(input, operation:)
14
+ fields = case operation
15
+ when "create", "update" then %w[target delivery title body]
16
+ when "ready" then %w[target delivery]
17
+ when "merge" then %w[target delivery method]
18
+ else raise ArgumentError, "unsupported protected PR operation"
19
+ end
20
+ unless input.is_a?(Hash) && input.keys.sort == fields.sort &&
21
+ input["target"].is_a?(Hash) && input["target"].keys.sort == %w[artifact_digest resource]
22
+ raise ArgumentError, "protected PR input fields differ"
23
+ end
24
+ parameters = DeliveryParameters.validate(input.fetch("delivery"))
25
+ unless input["delivery"].keys.sort == %w[forge_default forge_server pr_provenance] && input["delivery"] == parameters
26
+ raise ArgumentError, "protected PR selection must be normalized"
27
+ end
28
+ resource = input.fetch("target").fetch("resource")
29
+ if operation == "create"
30
+ DeliveryParameters.validate_url!(resource)
31
+ unless ServerUrl.match?(resource, parameters.fetch("pr_provenance").fetch("base_repository_url"))
32
+ raise ArgumentError, "draft create target differs from base repository"
33
+ end
34
+ else
35
+ reference = resource.is_a?(String) && PrReference.parse(resource)
36
+ raise ArgumentError, "protected PR target requires an exact URL" unless reference && reference.repository_url
37
+ end
38
+ digest = input.fetch("target").fetch("artifact_digest")
39
+ unless digest.nil? || digest.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(digest)
40
+ raise ArgumentError, "protected PR target artifact digest differs"
41
+ end
42
+ if DRAFT_OPERATIONS.include?(operation)
43
+ unless input["title"].is_a?(String) && input["title"].bytesize.between?(1, 1024) &&
44
+ input["body"].is_a?(String) && input["body"].bytesize <= 32 * 1024 &&
45
+ [input["title"], input["body"]].all? { |text| text.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !text.include?("\0") }
46
+ raise ArgumentError, "draft PR requires bounded UTF-8 title/body"
47
+ end
48
+ elsif operation == "merge" && !%w[squash merge rebase].include?(input["method"])
49
+ raise ArgumentError, "protected merge requires an exact method"
50
+ end
51
+ input
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../organisms/service_merge"
3
+
4
+ module Ace
5
+ module Git
6
+ module CLI
7
+ module Commands
8
+ class ServiceMerge < Ace::Support::Cli::Command
9
+ desc "Execute a fixed receiver-owned exact-head PR envelope"
10
+
11
+ def initialize(input: nil, producer: nil, root: nil, operation: "merge")
12
+ @input, @producer, @root, @operation = input, producer, root, operation
13
+ end
14
+
15
+ def call(**options)
16
+ raise ArgumentError, "fixed PR entry accepts no options" unless options.empty?
17
+ bytes = (@input || $stdin).read(Organisms::ServiceMerge::MAX_INPUT + 1)
18
+ result = (@producer || Organisms::ServiceMerge.new(operation: @operation)).call(bytes: bytes, root: @root || Dir.pwd)
19
+ puts JSON.generate(result)
20
+ rescue Ace::Git::Error, ArgumentError, SystemCallError, IOError => error
21
+ # No stdout receipt on an unverified/uncertain effect. The existing
22
+ # receiver retains its original dispatch instead of retrying it.
23
+ raise Ace::Support::Cli::Error, error.message
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/ace/git/cli.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "cli/commands/diff"
7
7
  require_relative "cli/commands/status"
8
8
  require_relative "cli/commands/branch"
9
9
  require_relative "cli/commands/pr"
10
+ require_relative "cli/commands/service_merge"
10
11
 
11
12
  module Ace
12
13
  module Git
@@ -52,7 +53,11 @@ module Ace
52
53
  ["diff", "Show filtered git diff output"],
53
54
  ["status", "Show repository status and PR context"],
54
55
  ["branch", "Show current branch information"],
55
- ["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"]
56
+ ["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"],
57
+ ["service merge", "Fixed receiver-owned exact-head merge envelope"],
58
+ ["service create", "Fixed receiver-owned draft PR creation"],
59
+ ["service update", "Fixed receiver-owned draft PR update"],
60
+ ["service ready", "Fixed receiver-owned reviewed PR readiness"]
56
61
  ].freeze
57
62
 
58
63
  HELP_EXAMPLES = [
@@ -70,6 +75,10 @@ module Ace
70
75
  register "pr update", Commands::Pr::Update.new
71
76
  register "pr ready", Commands::Pr::Ready.new
72
77
  register "pr merge", Commands::Pr::Merge.new
78
+ register "service merge", Commands::ServiceMerge.new
79
+ %w[create update ready].each do |operation|
80
+ register "service #{operation}", Commands::ServiceMerge.new(operation: operation)
81
+ end
73
82
 
74
83
  version_cmd = Ace::Support::Cli::VersionCommand.build(
75
84
  gem_name: "ace-git",
@@ -196,6 +196,11 @@ module Ace
196
196
  )
197
197
  end
198
198
 
199
+ # Pure provider-owned encoding; selection is pinned by the caller.
200
+ def draft_title(title)
201
+ provider_for(resolve_selected_server).class.pull_request_title(title: title, draft: true)
202
+ end
203
+
199
204
  # Update title/body after exact-head verification.
200
205
  #
201
206
  # @return [ProviderMutationReceipt]
@@ -237,8 +242,23 @@ module Ace
237
242
  end
238
243
 
239
244
  # Freeze the selected provider identity for an assignment attempt.
240
- def resolved_identity
241
- resolve_selected_server.to_h.transform_keys(&:to_s).transform_values(&:to_s)
245
+ def resolved_identity(identifier = nil)
246
+ selected = identifier.nil? ? resolve_selected_server : resolve_server_for(parse_identifier(identifier))
247
+ if @pinned_server && selected != @pinned_server
248
+ raise ProviderIdentityMismatchError, "Configured server identity changed from the pinned delivery identity"
249
+ end
250
+ selected.to_h.transform_keys(&:to_s).transform_values(&:to_s)
251
+ end
252
+
253
+ # Protected create has no PR identifier or materialized origin remote.
254
+ # Resolve the retained base repository through the same exact registry.
255
+ def resolved_repository_identity(repository_url)
256
+ Atoms::DeliveryParameters.validate_url!(repository_url)
257
+ selected = resolve_matching_server(ServerRegistry.matching_servers(repository_url), repository_url)
258
+ if @pinned_server && selected != @pinned_server
259
+ raise ProviderIdentityMismatchError, "Configured server identity changed from the pinned delivery identity"
260
+ end
261
+ selected.to_h.transform_keys(&:to_s).transform_values(&:to_s)
242
262
  end
243
263
 
244
264
  # Read-only reconciliation of a possibly completed create. Absence
@@ -291,10 +311,13 @@ module Ace
291
311
  ServerRegistry.servers_for_owner_repo(reference.owner_repo)
292
312
  end
293
313
 
314
+ resolve_matching_server(candidates, reference.repository_url || reference.owner_repo)
315
+ end
316
+
317
+ def resolve_matching_server(candidates, identity)
294
318
  if @selection[:server_name] || @selection[:use_default]
295
319
  selected = resolve_selected_server
296
320
  unless candidates.any? { |candidate| candidate.name == selected.name }
297
- identity = reference.repository_url || reference.owner_repo
298
321
  raise ProviderIdentityMismatchError,
299
322
  "PR identifier (#{identity}) does not match the selected server '#{selected.name}'"
300
323
  end
@@ -303,12 +326,10 @@ module Ace
303
326
  elsif candidates.size == 1
304
327
  candidates.first
305
328
  elsif candidates.empty?
306
- identity = reference.repository_url || reference.owner_repo
307
329
  raise AmbiguousRemoteError,
308
330
  "PR repository '#{identity}' matches no configured server (configured: " \
309
331
  "#{ServerRegistry.servers.map(&:name).join(", ")})"
310
332
  else
311
- identity = reference.repository_url || reference.owner_repo
312
333
  raise AmbiguousRemoteError,
313
334
  "PR repository '#{identity}' matches multiple configured servers " \
314
335
  "(#{candidates.map(&:name).join(", ")}); select one explicitly"
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "digest"
4
+ require_relative "pull_request_lifecycle"
5
+ require_relative "../atoms/delivery_parameters"
6
+ require_relative "../atoms/service_pr_input"
7
+
8
+ module Ace
9
+ module Git
10
+ module Organisms
11
+ # A fixed receiver-side operation, never a grant or a worker fallback.
12
+ class ServiceMerge
13
+ MAX_INPUT = 128 * 1024
14
+ MAX_SERVICE_INPUT = 64 * 1024
15
+ MAX_ARTIFACT = 64 * 1024
16
+ ARTIFACT = "pr-result.txt"
17
+ REQUEST = %w[request_id assignment_id attempt_id project_id operation input_digest target candidate_head
18
+ executor_uid transport authorization service_id caller_uid].freeze
19
+ EXECUTION = %w[executor_uid authority_id claim_binding candidate_generation head staging_id].freeze
20
+ TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
21
+ SHA = /\A[0-9a-f]{40}\z/
22
+ DIGEST = /\A[0-9a-f]{64}\z/
23
+
24
+ def initialize(operation: "merge", lifecycle_factory: nil, uid: -> { Process.uid }, euid: -> { Process.euid })
25
+ raise ArgumentError, "unsupported fixed PR operation" unless Atoms::ServicePrInput::OPERATIONS.include?(operation)
26
+ @operation = operation
27
+ @factory = lifecycle_factory || ->(**selection) { PullRequestLifecycle.new(**selection) }
28
+ @uid, @euid = uid, euid
29
+ end
30
+
31
+ def call(bytes:, root:)
32
+ envelope = parse!(bytes)
33
+ request, execution, input = envelope.values_at("request", "execution", "input")
34
+ parameters = Atoms::DeliveryParameters.validate(input.fetch("delivery"))
35
+ Atoms::ServicePrInput.validate(input, operation: @operation)
36
+ directory, held = private_root!(root)
37
+ begin
38
+ lifecycle = @factory.call(server_name: parameters["forge_server"], use_default: parameters.fetch("forge_default"), repo_root: directory)
39
+ server = if @operation == "create"
40
+ lifecycle.resolved_repository_identity(request.fetch("target").fetch("resource"))
41
+ else
42
+ lifecycle.resolved_identity(request.fetch("target").fetch("resource"))
43
+ end
44
+ provenance = parameters.fetch("pr_provenance")
45
+ unless Atoms::ServerUrl.match?(server.fetch("url"), provenance.fetch("base_repository_url"))
46
+ raise ProviderIdentityMismatchError, "merge forge differs from accepted provenance"
47
+ end
48
+ pinned = ResolvedServer.new(name: server.fetch("name"), provider: server.fetch("provider").to_sym, url: server.fetch("url"))
49
+ lifecycle = @factory.call(server_name: pinned.name, use_default: false, repo_root: directory, resolved_server: pinned)
50
+ resource, head = request.fetch("target").fetch("resource"), execution.fetch("head")
51
+ if @operation != "create"
52
+ before = lifecycle.show(resource)
53
+ verify_pr!(before, resource, head, server, provenance)
54
+ if %w[update ready].include?(@operation) && before.state.to_s != "open"
55
+ raise ProviderExpectedHeadConflictError, "protected PR edit requires an open PR"
56
+ end
57
+ if @operation == "update" && !before.draft
58
+ raise ProviderExpectedHeadConflictError, "protected update requires a draft PR"
59
+ end
60
+ end
61
+ title = lifecycle.draft_title(input.fetch("title")) if Atoms::ServicePrInput::DRAFT_OPERATIONS.include?(@operation)
62
+ receipt = case @operation
63
+ when "create"
64
+ lifecycle.create(head_ref: provenance.fetch("head_ref"), base_ref: provenance.fetch("base_ref"),
65
+ head_repository_url: provenance.fetch("head_repository_url"), expected_head: head,
66
+ title: title, body: input.fetch("body"), draft: true)
67
+ when "update"
68
+ lifecycle.update(resource, expected_head: head, title: title, body: input.fetch("body"))
69
+ when "ready" then lifecycle.ready(resource, expected_head: head)
70
+ when "merge" then lifecycle.merge(resource, expected_head: head, method: input.fetch("method"))
71
+ end
72
+ unless receipt.is_a?(ProviderMutationReceipt) && receipt.server_name == pinned.name && receipt.operation.to_s == @operation
73
+ raise ProviderUnknownOutcomeError, "PR response does not identify the selected operation"
74
+ end
75
+ after = receipt.pull_request
76
+ raise ProviderUnknownOutcomeError, "PR response lacks its exact result" unless after.is_a?(ProviderPullRequest)
77
+ verify_pr!(after, @operation == "create" ? after.url : resource, head, server, provenance)
78
+ if @operation == "merge"
79
+ unless after.state.to_s == "merged" && after.merge_commit_sha.is_a?(String) && after.merge_commit_sha.match?(SHA)
80
+ raise ProviderUnknownOutcomeError, "merge outcome is not verified"
81
+ end
82
+ payload = {"server" => server, "delivery" => parameters, "method" => input.fetch("method"),
83
+ "candidate_head" => head, "receipt" => receipt.to_h}
84
+ else
85
+ expected_draft = Atoms::ServicePrInput::DRAFT_OPERATIONS.include?(@operation)
86
+ unless after.state.to_s == "open" && after.draft == expected_draft &&
87
+ (!expected_draft || after.title == title && after.body == input.fetch("body"))
88
+ raise ProviderUnknownOutcomeError, "PR draft/content outcome is not verified"
89
+ end
90
+ payload = {"server" => server, "operation" => @operation, "input" => input,
91
+ "candidate_head" => head, "receipt" => receipt.to_h}
92
+ end
93
+ artifact = "ace-service-attestation request:#{request.fetch('request_id')} input:#{request.fetch('input_digest')} outcome:succeeded\n" + JSON.generate(payload) + "\n"
94
+ digest = publish!(directory, held, artifact)
95
+ {"request_id" => request.fetch("request_id"), "input_digest" => request.fetch("input_digest"),
96
+ "outcome" => "succeeded", "evidence" => [{"ref" => ARTIFACT, "sha256" => digest}]}
97
+ ensure
98
+ held.close
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def parse!(bytes)
105
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_INPUT) && bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
106
+ raise ArgumentError, "merge envelope is invalid or oversized"
107
+ end
108
+ envelope = JSON.parse(bytes, max_nesting: 16, create_additions: false,
109
+ allow_duplicate_key: false, allow_comments: false, allow_nan: false)
110
+ closed!(envelope, %w[version request input execution])
111
+ raise ArgumentError, "merge envelope version differs" unless envelope["version"].is_a?(Integer) && envelope["version"] == 1
112
+ request, execution, input = envelope.values_at("request", "execution", "input")
113
+ closed!(request, REQUEST)
114
+ closed!(execution, EXECUTION)
115
+ Atoms::ServicePrInput.validate(input, operation: @operation)
116
+ raise ArgumentError, "merge service input exceeds receiver bound" if JSON.generate(input).bytesize > MAX_SERVICE_INPUT
117
+ closed!(input["target"], %w[resource artifact_digest])
118
+ closed!(request["target"], %w[resource artifact_digest])
119
+ %w[request_id assignment_id attempt_id project_id authorization service_id].each do |key|
120
+ raise ArgumentError, "merge request identifier differs" unless request[key].is_a?(String) && request[key].match?(TOKEN)
121
+ end
122
+ %w[authority_id staging_id].each do |key|
123
+ raise ArgumentError, "merge execution identifier differs" unless execution[key].is_a?(String) && execution[key].match?(TOKEN)
124
+ end
125
+ unless request["operation"] == @operation && request["transport"] == "unix" &&
126
+ %w[executor_uid caller_uid].all? { |key| request[key].is_a?(Integer) && request[key].positive? } &&
127
+ request["executor_uid"] != request["caller_uid"] && request["executor_uid"] == execution["executor_uid"] &&
128
+ execution["executor_uid"].is_a?(Integer) &&
129
+ request["executor_uid"] == @uid.call && request["executor_uid"] == @euid.call &&
130
+ execution["candidate_generation"].is_a?(Integer) && execution["candidate_generation"].positive? &&
131
+ execution["head"].is_a?(String) && execution["head"].match?(SHA) && request["candidate_head"] == execution["head"] &&
132
+ execution["claim_binding"].is_a?(String) && execution["claim_binding"].match?(DIGEST) &&
133
+ request["input_digest"].is_a?(String) && request["input_digest"].match?(DIGEST) &&
134
+ request["input_digest"] == Digest::SHA256.hexdigest(JSON.generate(canonical(input))) && request["target"] == input["target"]
135
+ raise ArgumentError, "merge original claim/input/executor join differs"
136
+ end
137
+ target = input.fetch("target")
138
+ reference = target["resource"].is_a?(String) && Atoms::PrReference.parse(target["resource"])
139
+ unless (@operation == "create" || reference && reference.repository_url) && target["resource"].is_a?(String) && target["resource"].bytesize.between?(1, 256) &&
140
+ (target["artifact_digest"].nil? || target["artifact_digest"].is_a?(String) && target["artifact_digest"].match?(DIGEST))
141
+ raise ArgumentError, "merge target requires an exact PR URL"
142
+ end
143
+ envelope
144
+ rescue JSON::ParserError, JSON::NestingError, KeyError
145
+ raise ArgumentError, "merge envelope is malformed"
146
+ end
147
+
148
+ def closed!(value, fields)
149
+ raise ArgumentError, "merge fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
150
+ end
151
+
152
+ def canonical(value)
153
+ case value
154
+ when Hash then value.keys.sort.to_h { |key| [key, canonical(value.fetch(key))] }
155
+ when Array then value.map { |item| canonical(item) }
156
+ else value
157
+ end
158
+ end
159
+
160
+ def verify_pr!(pr, resource, head, server, provenance)
161
+ reference = Atoms::PrReference.parse(resource)
162
+ unless pr.is_a?(ProviderPullRequest) && reference && reference.repository_url && pr.url == resource && pr.number == reference.number &&
163
+ pr.server_name == server.fetch("name") && pr.head_sha == head &&
164
+ pr.head_ref == provenance.fetch("head_ref") && pr.base_ref == provenance.fetch("base_ref") &&
165
+ Atoms::ServerUrl.match?(reference.repository_url, provenance.fetch("base_repository_url")) &&
166
+ Atoms::ServerUrl.match?(pr.head_repository_url, provenance.fetch("head_repository_url")) &&
167
+ Atoms::ServerUrl.match?(pr.base_repository_url, provenance.fetch("base_repository_url"))
168
+ raise ProviderExpectedHeadConflictError, "merge PR identity/head/provenance differs"
169
+ end
170
+ end
171
+
172
+ def private_root!(root)
173
+ raise ArgumentError, "merge staging root must be absolute" unless root.is_a?(String) && root.start_with?("/")
174
+ held = File.open(root, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
175
+ stat = held.stat
176
+ unless stat.directory? && stat.uid == @uid.call && (stat.mode & 0o7777) == 0o700 &&
177
+ [stat.dev, stat.ino] == File.lstat(root).then { |value| [value.dev, value.ino] }
178
+ raise ArgumentError, "merge staging root is not private"
179
+ end
180
+ [root, held]
181
+ rescue StandardError
182
+ held&.close
183
+ raise
184
+ end
185
+
186
+ def publish!(root, held, bytes)
187
+ raise ProviderUnknownOutcomeError, "merge result exceeds evidence bound" unless bytes.bytesize <= MAX_ARTIFACT
188
+ stat = held.stat
189
+ identity = [stat.dev, stat.ino]
190
+ unless stat.directory? && stat.uid == @uid.call && (stat.mode & 0o7777) == 0o700 &&
191
+ identity == File.lstat(root).then { |value| [value.dev, value.ino] }
192
+ raise ProviderUnknownOutcomeError, "merge staging root changed"
193
+ end
194
+ File.open(File.join(root, ARTIFACT), File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600) do |file|
195
+ file.write(bytes); file.flush; file.fsync; file.chmod(0o400)
196
+ before = file.stat
197
+ file.rewind
198
+ captured = file.read(MAX_ARTIFACT + 1)
199
+ after = file.stat
200
+ unless before.file? && before.uid == @uid.call && captured == bytes &&
201
+ [before.dev, before.ino, before.size, before.mtime, before.ctime] == [after.dev, after.ino, after.size, after.mtime, after.ctime]
202
+ raise ProviderUnknownOutcomeError, "merge held evidence changed"
203
+ end
204
+ held.fsync
205
+ raise ProviderUnknownOutcomeError, "merge staging root changed" unless identity == File.lstat(root).then { |value| [value.dev, value.ino] }
206
+ Digest::SHA256.hexdigest(captured)
207
+ end
208
+ rescue SystemCallError, IOError
209
+ raise ProviderUnknownOutcomeError, "merge evidence publication unavailable"
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -17,6 +17,12 @@ module Ace
17
17
  # keyword arguments (args:, timeout:, env:) and returning a Hash with
18
18
  # :stdout, :stderr, :exit_code
19
19
  class Base
20
+ # Pure transport title for the requested draft state. Providers own
21
+ # server-specific draft encodings; callers retain their original title.
22
+ def self.pull_request_title(title:, draft:)
23
+ title
24
+ end
25
+
20
26
  attr_reader :server, :timeout
21
27
 
22
28
  # @param server [ResolvedServer] resolved server identity
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = "0.29.0"
5
+ VERSION = "0.30.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.0
4
+ version: 0.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-05 00:00:00.000000000 Z
10
+ date: 2026-10-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-config
@@ -141,6 +141,7 @@ files:
141
141
  - lib/ace/git.rb
142
142
  - lib/ace/git/atoms/command_executor.rb
143
143
  - lib/ace/git/atoms/date_resolver.rb
144
+ - lib/ace/git/atoms/delivery_parameters.rb
144
145
  - lib/ace/git/atoms/diff_numstat_parser.rb
145
146
  - lib/ace/git/atoms/diff_parser.rb
146
147
  - lib/ace/git/atoms/file_grouper.rb
@@ -154,6 +155,9 @@ files:
154
155
  - lib/ace/git/atoms/repository_checker.rb
155
156
  - lib/ace/git/atoms/repository_state_detector.rb
156
157
  - lib/ace/git/atoms/server_url.rb
158
+ - lib/ace/git/atoms/service_merge_evidence.rb
159
+ - lib/ace/git/atoms/service_pr_evidence.rb
160
+ - lib/ace/git/atoms/service_pr_input.rb
157
161
  - lib/ace/git/atoms/stale_lock_cleaner.rb
158
162
  - lib/ace/git/atoms/status_formatter.rb
159
163
  - lib/ace/git/atoms/task_pattern_extractor.rb
@@ -162,6 +166,7 @@ files:
162
166
  - lib/ace/git/cli/commands/branch.rb
163
167
  - lib/ace/git/cli/commands/diff.rb
164
168
  - lib/ace/git/cli/commands/pr.rb
169
+ - lib/ace/git/cli/commands/service_merge.rb
165
170
  - lib/ace/git/cli/commands/status.rb
166
171
  - lib/ace/git/errors.rb
167
172
  - lib/ace/git/models/diff_config.rb
@@ -177,6 +182,7 @@ files:
177
182
  - lib/ace/git/organisms/issue_tracking.rb
178
183
  - lib/ace/git/organisms/pull_request_lifecycle.rb
179
184
  - lib/ace/git/organisms/repo_status_loader.rb
185
+ - lib/ace/git/organisms/service_merge.rb
180
186
  - lib/ace/git/providers.rb
181
187
  - lib/ace/git/providers/base.rb
182
188
  - lib/ace/git/providers/evidence.rb