ace-lab 0.6.0 → 0.7.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: 1ad491380d732534c8ae2cbabe68bb796cf997adb19ff6dbdb4b6b66353470c6
4
- data.tar.gz: c1ba284a9cf628746450b50e1ecf6dcc03589a771fc6f1d44c2eded6afa24861
3
+ metadata.gz: d3799161a4575055b78e316deb18a4fa952fb1d84fa9fb10cd6e5c081a36acee
4
+ data.tar.gz: dd2d0fa5764e5bce423396e93e4fd1751fddd880649219d6a130306d0ec49667
5
5
  SHA512:
6
- metadata.gz: d6c8605dabf1a3f4bdac9f39a8afeac4c606c30832380f4b93921fc91d9bcd3ac364d1056fbb426130d25bf23410abd29cbd1dd80ec31ad99a563fe224f2c3bc
7
- data.tar.gz: d8ca70d0072169a318b7bf2cc57d291f6d9db14250c5af774920e0fb212735570ede41fdc360c1e099f8a41d4de43355bd1e7e6a7aca8a86fef8a21289ba04de
6
+ metadata.gz: 1556efc67f0fa45a412b40430570355578c7e61000bda7af07982345631f023c3f56c709d2d02a25c3faf84495b510d6fb171a904d8f910e7c336b4b8113cd44
7
+ data.tar.gz: a78076debb811ce94046ff8cb056648b6455f8dbe30514a5d575ac417bcf98adce21bb42c3f6f712f0d0e2d6d4bbb3c67ba3764dfb9e4138cb6eb17e6dd6a113
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Run the ordinary merge listener and receiver through `service listen`, with authenticated admission, canonical Assign acknowledgement/status/receipts, restart reconciliation and bounded fixed execution.
15
+ - Invoke and inspect real Forgejo merges under the installed scoped service identity; revalidate authorization per request and bind the provider mutation to the approved candidate head.
16
+
17
+ ### Changed
18
+
19
+ - Require ace-assign 0.69 and ace-git-forgejo 0.7 for the receiver record-listing and scoped provider APIs.
20
+
21
+ - Move the Forgejo merge provider ownership out of ace-lab (xz9.5): `ForgejoMergeAdapter` is now a thin delegation to the `ace-git-forgejo` provider (`Ace::Git::Forgejo::PullRequestApi` over its `HttpClient`, under the host-installed scoped service identity) — HTTP transport, authentication, payload shape, and failure classification live in the provider package, while the adapter keeps the receiver's closed outcome translation (`:invoked`/`:refused`/`:ambiguous`/`Unavailable`), the exact-head binding guard, and the normalized readback/nil-absence contract unchanged.
22
+
10
23
  ## [0.6.0] - 2026-10-14
11
24
 
12
25
  ### Added
@@ -6,6 +6,9 @@ require "ace/assign"
6
6
  require_relative "support"
7
7
  require_relative "../../organisms/protected_service_request"
8
8
  require_relative "../../organisms/ordinary_merge_request"
9
+ require_relative "../../organisms/ordinary_merge_receiver"
10
+ require_relative "../../molecules/ordinary_listener"
11
+ require_relative "../../molecules/ordinary_receiver_config"
9
12
 
10
13
  module Ace
11
14
  module Lab
@@ -169,6 +172,83 @@ module Ace
169
172
  @ordinary_producer ||= Organisms::OrdinaryMergeRequest.new
170
173
  end
171
174
  end
175
+
176
+ # The ordinary receiver serve entrypoint (spec 8x3.t.xz9.4
177
+ # execution clauses; 8x3.t.xz9.5 integration handoff): binds the
178
+ # configured unix socket, mutually authenticates every connection
179
+ # through SO_PEERCRED, admits through the canonical Assign
180
+ # coordinator, executes the authorized fixed operation — one REAL
181
+ # Forgejo merge under the scoped host-installed service identity —
182
+ # and settles the exact terminal receipt proven by real forge
183
+ # readback. Configuration is one explicit deployment-owned file
184
+ # plus the host-installed authorization and forge-merge documents;
185
+ # the host owns their placement and credentials. Blocks until
186
+ # stopped.
187
+ class Listen < Ace::Support::Cli::Command
188
+ include Ace::Support::Cli::Base
189
+ include Output
190
+
191
+ desc "Serve the ordinary fixed merge receiver on its configured socket"
192
+ option :mode, type: :string, required: true,
193
+ desc: "ordinary-merge selects the ordinary fixed merge receiver"
194
+ option :project, type: :string, required: true, desc: "Project stable ID"
195
+ option :config, type: :string, required: true,
196
+ desc: "Deployment-owned receiver configuration file (YAML)"
197
+
198
+ def call(mode:, project:, config:, **options)
199
+ unless options.empty?
200
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
201
+ "ordinary_merge_invalid_request",
202
+ "unknown listen selector: #{options.keys.sort.join(", ")}",
203
+ request_id: "unknown", input_digest: nil
204
+ ))
205
+ end
206
+ unless mode == Organisms::OrdinaryMergeRequest::MODE
207
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
208
+ "ordinary_merge_invalid_request", "unknown service mode",
209
+ request_id: "unknown", input_digest: nil
210
+ ))
211
+ end
212
+ receiver_config = Molecules::OrdinaryReceiverConfig.load(config)
213
+ unless receiver_config.project == project
214
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
215
+ "ordinary_merge_selection_mismatch",
216
+ "configured project does not match the requested project",
217
+ request_id: "unknown", input_digest: nil
218
+ ))
219
+ end
220
+ policy_document = Molecules::GrantResolver.trusted_document(
221
+ receiver_config.policy_path
222
+ )
223
+ # The ONE canonical Assign journal: the coordinator binds the
224
+ # evidence ref of the canonical repository the receiver is
225
+ # installed for (config repo_path — the producer project's
226
+ # canonical checkout, the same authoritative journal the
227
+ # producer coordinator uses). Never an integration-checkout
228
+ # duplicate; the service identity's reviewed permissions are
229
+ # journal ref read/append, evidence checkout and no-effect
230
+ # attestation writes inside that repository.
231
+ coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(
232
+ repo_root: receiver_config.repo_path
233
+ )
234
+ receiver = Organisms::OrdinaryMergeReceiver.new(
235
+ config: receiver_config, coordinator: coordinator,
236
+ policy_document: policy_document
237
+ )
238
+ listener = Molecules::OrdinaryListener.new(
239
+ socket_path: receiver_config.socket_path,
240
+ peer_uid: receiver_config.principal_uid,
241
+ handler: receiver
242
+ )
243
+ receiver.recover_pending!
244
+ listener.serve
245
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::AttemptErrors::EvidenceUnavailable => e
246
+ emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
247
+ "ordinary_merge_unavailable", e.message,
248
+ request_id: "unknown", input_digest: nil
249
+ ))
250
+ end
251
+ end
172
252
  end
173
253
  end
174
254
  end
data/lib/ace/lab/cli.rb CHANGED
@@ -26,7 +26,8 @@ module Ace
26
26
  ["resolve", "Resolve one lab entry by exact stable ID"],
27
27
  ["route", "Select a configured capable service in a project"],
28
28
  ["service request", "Request one authorized service operation"],
29
- ["service status", "Read a service request outcome"]
29
+ ["service status", "Read a service request outcome"],
30
+ ["service listen", "Serve the ordinary fixed merge receiver on its configured socket"]
30
31
  ].freeze
31
32
 
32
33
  HELP_EXAMPLES = [
@@ -52,6 +53,7 @@ module Ace
52
53
  register "route", CLI::Commands::Route.new
53
54
  register "service request", CLI::Commands::Service::Request.new
54
55
  register "service status", CLI::Commands::Service::Status.new
56
+ register "service listen", CLI::Commands::Service::Listen.new
55
57
 
56
58
  # Register version command
57
59
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../errors"
5
+ require_relative "grant_resolver"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # The host-installed forge merge configuration for the ordinary merge
11
+ # receiver (spec 8x3.t.xz9.5 integration handoff). The scoped service
12
+ # identity that may merge pull requests is provisioned by the HOST in
13
+ # one root-owned file at a fixed path; this loader reads it through the
14
+ # same trusted-storage verification as the authorization grants and
15
+ # fails closed on anything else. An absent file is an immediate
16
+ # actionable refusal naming the path and its owner — never a silent
17
+ # local fallback and never an invented credential: a receiver without
18
+ # the host-installed identity cannot merge and must say so.
19
+ #
20
+ # Contract (host installs, root-owned 0640, service-readable):
21
+ #
22
+ # base_url: https://forgejo.example.net
23
+ # owner: cs3b
24
+ # repo: ace
25
+ # token: <scoped: PR read + PR merge + PR comment on that repo only>
26
+ # merge_timeout_seconds: 900
27
+ # escalation_owner: host-integrator
28
+ # review_attachment:
29
+ # enabled: true
30
+ class ForgeMergeConfig
31
+ DEFAULT_PATH = "/etc/lab/ace-lab/forge-merge.yml"
32
+ DEFAULT_ESCALATION_OWNER = "host-integrator"
33
+ KEYS = %w[base_url owner repo token merge_timeout_seconds escalation_owner
34
+ review_attachment].freeze
35
+
36
+ # @raise [Ace::Lab::InvalidConfigurationError] absent file ⇒ the
37
+ # actionable `config missing:` message naming the path and owner;
38
+ # every other trust/shape failure also fails closed.
39
+ def self.load(path = DEFAULT_PATH)
40
+ content = GrantResolver.read_verified(path)
41
+ unless content
42
+ raise Ace::Lab::InvalidConfigurationError,
43
+ "config missing: #{path} (owner: #{DEFAULT_ESCALATION_OWNER} — install the " \
44
+ "root-owned 0640 forge merge configuration for the scoped service identity)"
45
+ end
46
+ document = YAML.safe_load(content, permitted_classes: [Date], aliases: false)
47
+ new(document, source: path)
48
+ rescue Psych::Exception
49
+ raise Ace::Lab::InvalidConfigurationError,
50
+ "invalid lab configuration: forge merge configuration #{path} could not be parsed"
51
+ end
52
+
53
+ attr_reader :base_url, :owner, :repo, :token, :merge_timeout_seconds, :escalation_owner,
54
+ :review_attachment_enabled, :source
55
+
56
+ def initialize(document, source: nil)
57
+ unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
58
+ (KEYS - document.keys).empty?
59
+ raise Ace::Lab::InvalidConfigurationError,
60
+ "forge merge configuration must contain exactly: #{KEYS.join(", ")}"
61
+ end
62
+ @source = source
63
+ @base_url = base_url!(document.fetch("base_url"))
64
+ @owner = name!(document.fetch("owner"), "owner")
65
+ @repo = name!(document.fetch("repo"), "repo")
66
+ @token = token!(document.fetch("token"))
67
+ @merge_timeout_seconds = timeout!(document.fetch("merge_timeout_seconds"))
68
+ @escalation_owner = name!(document.fetch("escalation_owner"), "escalation_owner")
69
+ attachment = document.fetch("review_attachment")
70
+ unless attachment.is_a?(Hash) && attachment.keys == %w[enabled] &&
71
+ [true, false].include?(attachment.fetch("enabled"))
72
+ raise Ace::Lab::InvalidConfigurationError,
73
+ "forge merge review_attachment must be exactly {enabled: true|false}"
74
+ end
75
+ @review_attachment_enabled = attachment.fetch("enabled")
76
+ freeze
77
+ end
78
+
79
+ # The token is a live credential: never rendered, never logged.
80
+ def inspect
81
+ "#<#{self.class.name} base_url=#{@base_url} owner=#{@owner} repo=#{@repo} " \
82
+ "token=[REDACTED] merge_timeout_seconds=#{@merge_timeout_seconds} " \
83
+ "escalation_owner=#{@escalation_owner} " \
84
+ "review_attachment_enabled=#{@review_attachment_enabled}>"
85
+ end
86
+
87
+ private
88
+
89
+ def base_url!(value)
90
+ uri = URI.parse(value.to_s)
91
+ unless uri.is_a?(URI::HTTPS) && uri.host && uri.path.empty? && uri.to_s == value
92
+ raise Ace::Lab::InvalidConfigurationError,
93
+ "forge merge base_url must be an https origin without path, query or fragment"
94
+ end
95
+ value
96
+ rescue URI::Error
97
+ raise Ace::Lab::InvalidConfigurationError,
98
+ "forge merge base_url must be an https origin without path, query or fragment"
99
+ end
100
+
101
+ def name!(value, name)
102
+ unless value.is_a?(String) && !value.empty? && value.length <= 256 &&
103
+ value.match?(%r{\A[A-Za-z0-9._-]+\z})
104
+ raise Ace::Lab::InvalidConfigurationError, "forge merge #{name} is invalid"
105
+ end
106
+ value
107
+ end
108
+
109
+ def token!(value)
110
+ unless value.is_a?(String) && value.length.between?(8, 512) &&
111
+ value.match?(%r{\A[A-Za-z0-9._~+/=-]+\z})
112
+ raise Ace::Lab::InvalidConfigurationError, "forge merge token is invalid"
113
+ end
114
+ value
115
+ end
116
+
117
+ def timeout!(value)
118
+ unless value.is_a?(Integer) && value.positive? && value <= 86_400
119
+ raise Ace::Lab::InvalidConfigurationError,
120
+ "forge merge merge_timeout_seconds must be a positive integer"
121
+ end
122
+ value
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git/forgejo"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # The receiver's forge merge boundary (spec 8x3.t.xz9.5 integration
9
+ # handoff): a THIN delegation to the `ace-git-forgejo` provider, which
10
+ # owns every Forgejo API/PR/review/merge/readback responsibility —
11
+ # HTTP transport, authentication, payload shape, and failure
12
+ # classification live behind `Ace::Git::Forgejo::PullRequestApi` and
13
+ # its `HttpClient`. This molecule holds no HTTP stack of its own: it
14
+ # binds the host-installed scoped service identity from
15
+ # {ForgeMergeConfig} to a repository-scoped provider client and
16
+ # translates the provider's closed outcomes into the receiver's
17
+ # closed outcome set (`:invoked`/`:refused`/`:ambiguous`/`Unavailable`).
18
+ #
19
+ # The receiver's one authorized effect is a real pull request merge on
20
+ # the real forge, invoked under the scoped service identity, and every
21
+ # terminal outcome is proven by a real readback — a local git effect is
22
+ # never presented as a forge result.
23
+ #
24
+ # Outcome translation (provider closed world → receiver closed world):
25
+ #
26
+ # - `2xx` mutation outcome → `:invoked`; a provider refusal outcome
27
+ # (404/405/409/422 — including the server-side exact-head refusal
28
+ # for a moved head) → `:refused`; an authentication error (the
29
+ # provider raises the shared taxonomy for 401/403) → `:refused` —
30
+ # a definitive refusal can never have merged.
31
+ # - An unknown mutation outcome (5xx responses and transport failures
32
+ # on the POST — the forge may or may not have processed it) →
33
+ # `:ambiguous`; the receiver reconciles by readback, never by
34
+ # repeating the merge.
35
+ # - Every unverifiable read outcome (unreachable forge, malformed
36
+ # evidence, misrouted payload, unusable endpoint) raises
37
+ # `Unavailable`, because the true state cannot be proven. The token
38
+ # is never rendered in any error (provider-sanitized messages only).
39
+ #
40
+ # The exact-head contract is carried by the provider's native merge
41
+ # signature: the POST body binds `head_commit_id` to the approved
42
+ # candidate server-side, so a race between the state readback and the
43
+ # merge POST can never merge an unreviewed head. This molecule never
44
+ # substitutes another merge strategy and validates the binding before
45
+ # the provider is constructed.
46
+ class ForgejoMergeAdapter
47
+ # The single authorized merge strategy on the wire.
48
+ MERGE_METHOD = "fast-forward-only"
49
+
50
+ class Unavailable < ::StandardError; end
51
+
52
+ # outcome: :invoked | :refused | :ambiguous; status: Integer|nil;
53
+ # reason: closed refuse/ambiguity detail (token-free).
54
+ Result = Struct.new(:outcome, :status, :reason, keyword_init: true)
55
+
56
+ def initialize(base_url:, token:, timeout_seconds: 900)
57
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
58
+ @token = token
59
+ @timeout_seconds = timeout_seconds
60
+ freeze
61
+ end
62
+
63
+ # Invoke the eligible merge bound to the approved candidate head.
64
+ # `head_commit_id` is required and validated (full 40-character hex
65
+ # sha) BEFORE any provider construction: the forge server-side
66
+ # refuses to merge any head other than the bound one. Returns a
67
+ # Result; never raises for HTTP outcomes (ambiguity is a result, not
68
+ # an exception).
69
+ def merge_pull_request!(owner:, repo:, index:, head_commit_id:)
70
+ unless head_commit_id.is_a?(String) && head_commit_id.match?(/\A[0-9a-f]{40}\z/)
71
+ raise ArgumentError,
72
+ "head_commit_id must be a full 40-character lowercase hex sha " \
73
+ "(got: #{head_commit_id.inspect})"
74
+ end
75
+ translate_mutation do
76
+ provider(owner, repo).merge_pull_request(index,
77
+ do_method: MERGE_METHOD, head_commit_id: head_commit_id)
78
+ end
79
+ end
80
+
81
+ # The PR state readback, normalized to the receiver's closed
82
+ # PR-state shape (`merged`, `merge_commit_sha`, head/base shas) or
83
+ # nil when the forge definitively reports the PR absent (404 — the
84
+ # provider raises the absence taxonomy). Raises Unavailable for
85
+ # every unverifiable outcome.
86
+ def pull_request(owner:, repo:, index:)
87
+ payload = provider(owner, repo).pull_request(index)
88
+ {
89
+ "merged" => payload["merged"] == true,
90
+ "merge_commit_sha" => payload["merge_commit_sha"].is_a?(String) ?
91
+ payload["merge_commit_sha"] : nil,
92
+ "head" => {"sha" => payload.dig("head", "sha").is_a?(String) ?
93
+ payload.dig("head", "sha") : nil},
94
+ "base" => {"sha" => payload.dig("base", "sha").is_a?(String) ?
95
+ payload.dig("base", "sha") : nil}
96
+ }
97
+ rescue Ace::Git::ProviderObjectNotFoundError
98
+ nil
99
+ rescue Ace::Git::Error => e
100
+ raise Unavailable, e.message
101
+ end
102
+
103
+ # Attach one comment to the PR (the independent final-head review
104
+ # body handed over by the request — never the executor's own
105
+ # approval).
106
+ def create_comment(owner:, repo:, index:, body:)
107
+ translate_mutation do
108
+ provider(owner, repo).create_comment(index, body)
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ # One repository-scoped provider client per exchange: the origin is
115
+ # narrowed with the forge identity this receiver already pinned
116
+ # against the host-installed configuration, and the scoped token
117
+ # rides the provider transport instead of any local credential.
118
+ def provider(owner, repo)
119
+ Ace::Git::Forgejo::PullRequestApi.new(
120
+ server: Ace::Git::ResolvedServer.new(name: "lab-forge-merge",
121
+ provider: :forgejo, url: "#{@base_url}/#{owner}/#{repo}"),
122
+ timeout: @timeout_seconds, token: @token
123
+ )
124
+ end
125
+
126
+ # Closed mutation translation: the provider returns an outcome only
127
+ # for statuses whose meaning is endpoint-specific (2xx success and
128
+ # the refusal family); everything else it raises as the shared
129
+ # taxonomy, which maps to :refused (authentication — a definitive
130
+ # refusal), :ambiguous (unknown outcome), or Unavailable (a read
131
+ # classified as unreachable).
132
+ def translate_mutation
133
+ outcome = yield
134
+ if (200..299).cover?(outcome.status)
135
+ Result.new(outcome: :invoked, status: outcome.status, reason: nil)
136
+ else
137
+ Result.new(outcome: :refused, status: outcome.status,
138
+ reason: outcome.message || "HTTP #{outcome.status}")
139
+ end
140
+ rescue Ace::Git::ProviderAuthenticationError => e
141
+ Result.new(outcome: :refused, status: nil, reason: e.message)
142
+ rescue Ace::Git::ProviderUnknownOutcomeError => e
143
+ Result.new(outcome: :ambiguous, status: nil, reason: e.message)
144
+ rescue Ace::Git::Error => e
145
+ raise Unavailable, e.message
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -64,19 +64,13 @@ module Ace
64
64
  parse_grants(content, trusted_path)
65
65
  end
66
66
 
67
- private
68
-
69
- def verify_error(path)
70
- "invalid lab configuration: trusted authorization file #{path} failed the deployment " \
71
- "ownership verification or could not be read; failing closed " \
72
- "(required: every traversed element root-owned and not group/world-writable)"
73
- end
74
-
75
- # Read the grants document only after proving the deployment owns
76
- # the storage along the original path. Genuine absence is nil
77
- # (nobody authorized); every other filesystem failure — including
78
- # races between lstat and open — classifies fail-closed
79
- # (review rounds 6-7, F2).
67
+ # Read a deployment-owned file only after proving the deployment
68
+ # owns the storage along the original path (same trust rules as
69
+ # `resolve`). Genuine absence is nil; every other filesystem
70
+ # failure — including races between lstat and open — classifies
71
+ # fail-closed (review rounds 6-7, F2). Public so other trusted
72
+ # host-installed documents (e.g. the forge merge service identity)
73
+ # read through the same verification.
80
74
  # @return [String, nil] file content, or nil when absent
81
75
  def read_verified(path)
82
76
  candidate = begin
@@ -104,6 +98,14 @@ module Ace
104
98
  end
105
99
  end
106
100
 
101
+ private
102
+
103
+ def verify_error(path)
104
+ "invalid lab configuration: trusted authorization file #{path} failed the deployment " \
105
+ "ownership verification or could not be read; failing closed " \
106
+ "(required: every traversed element root-owned and not group/world-writable)"
107
+ end
108
+
107
109
  # Walk the ORIGINAL path component by component. Symlinks are
108
110
  # followed only when the link itself is root-owned (a caller-writable
109
111
  # redirect is rejected); symlink permission bits are ignored because
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "service_policy"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Molecules
9
+ # The scoped ordinary-merge decision/admission resolver (spec 8x3.t.xz9.4,
10
+ # execution clauses). The deployment-owned YAML authorization file remains
11
+ # the sole source of truth; this resolver only makes the exact root
12
+ # authorizations natively executable inside the receiver: one per-request
13
+ # authorization selector resolves to a typed decision within the closed
14
+ # refusal-code family — or it refuses. Scoping rules:
15
+ #
16
+ # - only `operation: merge` decisions of the ordinary shape resolve;
17
+ # anything else (missing reference, shape mismatch, non-merge
18
+ # operation, any binding mismatch, expiry) is a typed
19
+ # `ordinary_merge_authorization_invalid` decision;
20
+ # - exact binding match on every closed key; there are NO wildcards,
21
+ # defaults, prefix grants or broad grants of any kind — a decision
22
+ # authorizes exactly one bound request and nothing else;
23
+ # - expiry bounds the decision itself.
24
+ #
25
+ # Per-request refresh (spec 8x3.t.xz9.5 integration handoff): a
26
+ # long-lived receiver would otherwise decide every sequential candidate
27
+ # against its boot-time snapshot of the authorization document. When a
28
+ # trusted path is installed, every decision re-reads the document
29
+ # through the trusted loader and resolves against the FRESH grants —
30
+ # a root-revoked or expired decision fails closed at the next request
31
+ # without any receiver restart. The refresh is scoped: it reloads and
32
+ # re-validates the exact-request grant only; it never broadens
33
+ # authorization beyond the resolved reference.
34
+ class OrdinaryAdmissionResolver
35
+ BINDING_KEYS = ServicePolicy::ORDINARY_BINDING_KEYS.freeze
36
+ DECISION_KEYS = ServicePolicy::ORDINARY_AUTHORIZATION_KEYS.freeze
37
+ OPERATION = "merge"
38
+
39
+ Decision = Struct.new(:authorized?, :decision, :code, :message, keyword_init: true)
40
+
41
+ # @param policy_document [Hash] boot-time trusted document (the
42
+ # configuration digest is derived from it once, upstream)
43
+ # @param path [String, nil] trusted policy path to re-read per decision
44
+ # @param loader [Proc, nil] trusted reader (default: GrantResolver);
45
+ # the only override point, for non-privileged test documents
46
+ def initialize(policy_document, path: nil, loader: nil)
47
+ unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
48
+ raise Ace::Lab::InvalidConfigurationError,
49
+ "trusted policy document must contain an authorizations mapping"
50
+ end
51
+ @authorizations = policy_document.fetch("authorizations")
52
+ @path = path
53
+ @loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
54
+ freeze
55
+ end
56
+
57
+ # Resolve one authorization selector against the exact ordinary merge
58
+ # request binding. Returns a typed Decision; `authorized?` is true only
59
+ # for an exact, unexpired, ordinary-merge-shaped match.
60
+ def decide(reference, binding)
61
+ authorizations = current_authorizations
62
+ unless reference.is_a?(String) && reference.match?(ServicePolicy::ID)
63
+ return refuse("invalid authorization reference")
64
+ end
65
+ unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
66
+ return refuse("only the ordinary merge operation is resolvable")
67
+ end
68
+ decision = authorizations[reference]
69
+ return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
70
+ unless DECISION_KEYS.all? { |key| decision.key?(key) }
71
+ return refuse("authorization reference is not an ordinary merge decision")
72
+ end
73
+ unless decision.fetch("operation") == OPERATION
74
+ return refuse("authorization does not cover the ordinary merge operation")
75
+ end
76
+ mismatch = BINDING_KEYS.find { |key| decision.fetch(key) != binding.fetch(key, nil) }
77
+ return refuse("authorization does not match the exact ordinary merge request") if mismatch
78
+ begin
79
+ expired = Time.iso8601(decision.fetch("expires_at").to_s) <= Time.now.utc
80
+ rescue ArgumentError
81
+ return refuse("authorization decision has an invalid expiry")
82
+ end
83
+ return refuse("authorization has expired") if expired
84
+ Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
85
+ end
86
+
87
+ private
88
+
89
+ # The authorizations mapping the CURRENT decision resolves against:
90
+ # the boot snapshot when no trusted path is installed, otherwise the
91
+ # freshly re-read document. A failed trusted re-read fails closed
92
+ # (InvalidConfigurationError — the unavailable family), never falls
93
+ # back to the stale snapshot.
94
+ def current_authorizations
95
+ return @authorizations unless @path
96
+ document = @loader.call(@path)
97
+ unless document.is_a?(Hash) && document["authorizations"].is_a?(Hash)
98
+ raise Ace::Lab::InvalidConfigurationError,
99
+ "invalid lab configuration: trusted authorization refresh at #{@path} " \
100
+ "did not yield an authorizations mapping"
101
+ end
102
+ document.fetch("authorizations")
103
+ end
104
+
105
+ def refuse(message)
106
+ Decision.new(authorized?: false, decision: nil,
107
+ code: "ordinary_merge_authorization_invalid", message: message)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # The fixed merge method executor (spec 8x3.t.xz9.4, merge-method
9
+ # contract). The closed request schema admits literal `method=merge`;
10
+ # the installed executor gives that method FAST-FORWARD-ONLY semantics:
11
+ # the candidate head must be a descendant of the current base head.
12
+ # A non-fast-forwardable candidate is NOT merged silently and never
13
+ # falls back to a merge commit — it reports `:non_fast_forward` so the
14
+ # receiver answers in the moved-target/needs-rebase refusal family.
15
+ # The mutation itself is one atomic compare-and-swap on the base ref
16
+ # (`git update-ref <base> <candidate> <expected>`), so concurrent
17
+ # writers can never both move the ref: the loser provably performed
18
+ # no effect. Under this strategy the resulting base head equals the
19
+ # candidate head, and the receipt joins `merge_commit ==
20
+ # resulting_target_head == merged_candidate_head` — that join is the
21
+ # receipt's fast-forward proof. Any future non-ff strategy is a
22
+ # separate reviewed contract extension, never implicit.
23
+ class OrdinaryFfMerge
24
+ STRATEGY = "ff"
25
+
26
+ Result = Struct.new(:outcome, :resulting_head, :previous_head, :reason, keyword_init: true)
27
+
28
+ def initialize(repo_path:, base_ref:)
29
+ unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
30
+ base_ref.is_a?(String) && base_ref.start_with?("refs/")
31
+ raise ArgumentError, "ordinary ff merge needs an absolute repo path and a full base ref"
32
+ end
33
+ @repo_path = repo_path
34
+ @base_ref = base_ref
35
+ end
36
+
37
+ def base_head
38
+ out, stderr, status = git("rev-parse", @base_ref)
39
+ return out.strip if status.success?
40
+ raise GitUnavailable, "base ref #{@base_ref} is unreadable: #{stderr.strip[0, 200]}"
41
+ end
42
+
43
+ def candidate_commit?(sha)
44
+ git("cat-file", "-e", "#{sha}^{commit}").last.success?
45
+ end
46
+
47
+ # True only when the candidate is a descendant of (or equal to) the
48
+ # current base head — the fast-forward precondition.
49
+ def fast_forwardable?(candidate_head)
50
+ git("merge-base", "--is-ancestor", @base_ref, candidate_head).last.success?
51
+ end
52
+
53
+ # The single effect: one compare-and-swap ref update. Returns a
54
+ # Result with outcome `:merged`, `:already_merged`, or `:race_lost`
55
+ # (the expected head moved before the swap — provably no effect from
56
+ # this invocation). Raises GitUnavailable when git itself fails.
57
+ def merge!(candidate_head, expected_head)
58
+ current = base_head
59
+ return Result.new(outcome: :already_merged, resulting_head: current,
60
+ previous_head: current, reason: nil) if current == candidate_head
61
+ unless current == expected_head
62
+ return Result.new(outcome: :race_lost, resulting_head: current,
63
+ previous_head: current, reason: "base head moved before the swap")
64
+ end
65
+ out, stderr, status = git("update-ref", @base_ref, candidate_head, expected_head)
66
+ unless status.success?
67
+ return Result.new(outcome: :race_lost, resulting_head: base_head,
68
+ previous_head: base_head, reason: "compare-and-swap lost: #{stderr.strip[0, 200]}")
69
+ end
70
+ Result.new(outcome: :merged, resulting_head: candidate_head,
71
+ previous_head: expected_head, reason: nil)
72
+ end
73
+
74
+ class GitUnavailable < ::StandardError; end
75
+
76
+ private
77
+
78
+ def git(*argv)
79
+ Open3.capture3("git", *argv, chdir: @repo_path)
80
+ rescue Errno::ENOENT => e
81
+ raise GitUnavailable, "git is unavailable: #{e.class}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end