ace-hitl 0.12.0 → 0.13.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: f29e133df951a7ba9c8781bc983cea5b72efe298b6544bae9c7a60626cadcdd2
4
- data.tar.gz: e3078f625d062203d1dd94b946b3fa4503daf49c6029201682dc659c9cc8463e
3
+ metadata.gz: 2e0e3e52b0322d7e5b585bf30728ebaae32337e06b4a8be12e96dcba1c0d498b
4
+ data.tar.gz: 4b79e7e2d5efd486c794be95cf515d147b17f322227d3cd6d6583382f3e6fb7e
5
5
  SHA512:
6
- metadata.gz: edf72377db41e27f64075763543fcb4886357b9647400b634c7e06cf4ccb9285aeb87506e1ffde60f2b594e7e337f826a7beecfef3e8e32d0e28521da984679d
7
- data.tar.gz: a71bc89e5841a1fa25b2256f7989d1ee2fb4cc830e61ae27935bb84e4f38deed4a9bd51cb5370de61633cecf911309b11219af5798798551d9dd7c7331d17a30
6
+ metadata.gz: a7abb0c60530c467efbb7a2b99d514e23a716bd61fce8d67c65966d629bb70969785043d63dd36e60eabd1d708d7d850b58eca6b31e5135c071e2791147c3bd7
7
+ data.tar.gz: 339b0dee9be68ff3ef82b4de954e6642a58b4ccdf8cad109524b66991423f39f543a59f75e8e957c1be23e263975a3448009a3558aa3e622415059d14ad036bd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - Bind scoped publication to the original canonical HITL challenge and authorization; keep OTP material out of agent messages and ordinary transport.
15
+
16
+ ### Changed
17
+
18
+ - Read delivery history from the original Inbox owner. Complete human attention on terminal submission without requiring an agent-read receipt or querying recovery caches.
19
+
20
+ ### Fixed
21
+
22
+ - Route proposals through the retained project journal and preserve exact protected attempt identity across lifecycle calls.
23
+ - Exercise concurrent creation under the actual lifecycle lock.
24
+
25
+ ### Fixed
26
+
27
+ - Accept exact protected launch attempt identifiers for HITL requests while preserving assignment syntax and authoritative binding checks.
28
+
10
29
  ## [0.12.0] - 2026-10-05
11
30
 
12
31
  ### Added
data/docs/usage.md CHANGED
@@ -127,35 +127,22 @@ client.status(request: request_id)
127
127
  ```
128
128
 
129
129
  `deliver` consumes an authorized ordinary answer, enqueues one incarnation-bound
130
- Herdr event, registers its digest/key with Assign, and attempts exact native
131
- submission. The accepted owner's terminal, agent and immutable native session ID
132
- are captured before consumption and checked under the Inbox event lock. A thread
133
- restart in the same pane refuses delivery rather than changing that original
134
- identity. Repeated calls reuse the same event and never resubmit an uncertain
135
- intent. A stopped watcher leaves the request/native intent visible in
136
- `pending --project ace`; it never chooses a new pane or launches a replacement
137
- watcher. The configured Hermes transport publishes created requests for its
138
- explicitly registered project channels and owns their Telegram polling.
139
-
140
- Queue acceptance (`delivered`) and wake are transport facts. Business effects
141
- run once through the scoped service under the requester's declaration; their
142
- separate receipt reference cannot be inferred from native delivery. Actual
143
- consumption requires the existing trusted supervisor/observer signing context:
144
-
145
- ```ruby
146
- client.reconcile(request: request_id, receipt_path: signed_receipt_path)
147
- # Explicit retry only after verified supersession/non-consumption:
148
- client.reconcile(request: request_id, receipt_path: signed_receipt_path, retry_delivery: true)
149
- ```
150
-
151
- Herdr verifies the signature, exact event/attempt/digest/generation/native binding
152
- and accepted registration under the event lock. Assign journals the verified
153
- observation. Missing authority or signer, wrong key, changed target and stale
154
- proof stay refused/unknown. Signed supersession leaves ordinary `deliver` and
155
- `watch` calls queued; only `reconcile(..., retry_delivery: true)` submits again.
156
- No elapsed-time rule establishes success or retries.
157
- Keep the original trusted verification/signing context for unresolved events or
158
- defer key rotation; a replacement fingerprint cannot rebind an existing event.
130
+ Herdr event, registers its original assignment/attempt attribution with Assign,
131
+ and sends to the exact selected terminal. The accepted owner's terminal, agent
132
+ and native session ID are checked against the live Herdr pane before sending.
133
+ A replaced agent in the same pane refuses rather than selecting another target.
134
+ Repeated calls reuse the same event; uncertain sends are not automatically
135
+ repeated. The configured Hermes transport owns Telegram request/reply routing.
136
+
137
+ A delivered acknowledgement means submitted to the terminal, not read by the
138
+ agent. LiveClient.status reads delivery history from the original Inbox owner;
139
+ delivered answers no longer appear as pending human attention. Current agent
140
+ progress/output comes from direct Herdr/tmux panel capture when needed.
141
+
142
+ Business effects still run through their scoped owner with their own result
143
+ and authorization. Neither a sent message nor panel output replaces verification
144
+ of those effects. No dedicated Codex app-server, message-read signer/key or
145
+ signed consumption/supersession reconciliation API is required.
159
146
 
160
147
  The shared versioned envelope is semantically owned by HITL and packaged in
161
148
  `ace-hitl-contract` to preserve the acyclic HITL → Assign → Herdr → contract
@@ -315,9 +302,9 @@ operation and returns its bytes only over protected IPC. Local event polling
315
302
  remains available for ordinary unbound local events. Managed events cannot use
316
303
  `update --resume` to launch unscoped session/shell delivery.
317
304
 
318
- Installed acceptance still requires real registered Telegram ingress, native
319
- owner observation and trusted signer/OS-user evidence in Lab. Controlled local
320
- transport and synthetic native observations do not satisfy those gates.
305
+ Installed acceptance uses actual registered Telegram ingress, the selected
306
+ live Herdr/tmux terminal and the real scoped users in Lab. Controlled local
307
+ transport tests do not replace launching and observing the actual task.
321
308
 
322
309
  ## Lifecycle Event Names
323
310
 
@@ -370,11 +357,17 @@ publishing, deployment and access/privilege expansion. Fixed service scope, curr
370
357
  independently executed review/tests, bound running attempt and operation-specific OTP gates
371
358
  still apply at execution. Proposal authorization never supplies credentials or runs a callback.
372
359
 
373
- `ace-hitl proposal show ID --format json` shows decision, deadline, actual Assign claim/outcome
360
+ All proposal reads, revisions and transport acknowledgements/replies carry an explicit project.
361
+ The authority selects that project's canonical Assign journal; a proposal ID does not select a journal.
362
+ The managed request's existing project supplies this selector for ordinary `read`; Hermes uses the
363
+ accepted envelope's project for acknowledgement, reply and reconciliation. Missing selectors are
364
+ rejected, and an ID from another project's journal cannot be read or changed through the selected project.
365
+
366
+ `ace-hitl proposal show ID --project PROJECT --format json` shows decision, deadline, actual Assign claim/outcome
374
367
  and a bounded history page; continue with `--history-after HISTORY_NEXT`.
375
368
  `ace-hitl proposal history --project ID --query TEXT` retrieves relevant prior decisions;
376
369
  continue with `--after NEXT`. Current project grants and exact requester identity gate history and show; losing project access refuses reads and excludes history, including lifecycle proposal request reads.
377
- `ace-hitl proposal revise ID --expected-revision N --operation-id revision-abcdef0123456789abcdef01 --file changed.json` supersedes the prior decision and creates a
370
+ `ace-hitl proposal revise ID --project PROJECT --expected-revision N --operation-id revision-abcdef0123456789abcdef01 --file changed.json` supersedes the prior decision and creates a
378
371
  new request with a fresh full window after acknowledgement. An unresolved claimed effect
379
372
  must be reconciled before revision; known successful or proven no-effect settlement can be
380
373
  followed by a new revision. Persist the proposal ID, expected source revision, stable revision operation ID and exact file before invocation. The operation ID is `revision-` followed by 24 lowercase hex digits and is unique across proposals. Exact retry returns that committed revision, including after acknowledged delivery, approval or a later revision, without creating another request or resetting the window. Changed proposal/source/content/caller bindings and stale-source new operations are refused. Supersession and new prepared revision commit atomically against effect claims. Generic lifecycle `create` rejects proposal kind; use this canonical proposal interface.
@@ -36,12 +36,14 @@ module Ace
36
36
  project: options[:project], document: load_document(options[:file]))
37
37
  when "show"
38
38
  raise_lifecycle_error("proposal ID required") if id.to_s.empty?
39
- lifecycle_client.proposal_show(id, history_after: options[:"history-after"] || 0)
39
+ raise_lifecycle_error("--project required") if options[:project].to_s.empty?
40
+ lifecycle_client.proposal_show(id, project: options[:project], history_after: options[:"history-after"] || 0)
40
41
  when "revise"
41
42
  raise_lifecycle_error("proposal ID required") if id.to_s.empty?
42
43
  raise_lifecycle_error("--expected-revision required") unless options[:"expected-revision"]
43
44
  raise_lifecycle_error("--operation-id required") if options[:"operation-id"].to_s.empty?
44
- lifecycle_client.proposal_revise(id, expected_revision: options[:"expected-revision"],
45
+ raise_lifecycle_error("--project required") if options[:project].to_s.empty?
46
+ lifecycle_client.proposal_revise(id, project: options[:project], expected_revision: options[:"expected-revision"],
45
47
  operation_id: options[:"operation-id"], document: load_document(options[:file]))
46
48
  when "resolve-due"
47
49
  raise_lifecycle_error("--project required") if options[:project].to_s.empty?
@@ -16,7 +16,7 @@ module Ace
16
16
  raise NotImplementedError
17
17
  end
18
18
 
19
- def reverse_address(attempt:, caller_pid:)
19
+ def reverse_address(assignment:, attempt:, project:, caller_pid:)
20
20
  raise NotImplementedError
21
21
  end
22
22
 
@@ -27,32 +27,32 @@ module Ace
27
27
  request("proposal-create", params)
28
28
  end
29
29
 
30
- def proposal_show(id, history_after: 0)
31
- request("proposal-show", {"id" => id, "history_after" => history_after})
30
+ def proposal_show(id, project:, history_after: 0)
31
+ request("proposal-show", {"id" => id, "project" => project, "history_after" => history_after})
32
32
  end
33
33
 
34
- def proposal_revise(id, expected_revision:, operation_id:, document:)
35
- request("proposal-revise", {"id" => id, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
34
+ def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
35
+ request("proposal-revise", {"id" => id, "project" => project, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
36
36
  end
37
37
 
38
- def proposal_acknowledge(id, submitted_at:)
39
- request("proposal-ack", {"id" => id, "submitted_at" => submitted_at})
38
+ def proposal_acknowledge(id, project:, submitted_at:)
39
+ request("proposal-ack", {"id" => id, "project" => project, "submitted_at" => submitted_at})
40
40
  end
41
41
 
42
- def proposal_reply(id, **params)
43
- request("proposal-reply", {"id" => id}.merge(params))
42
+ def proposal_reply(id, project:, **params)
43
+ request("proposal-reply", {"id" => id, "project" => project}.merge(params))
44
44
  end
45
45
 
46
- def proposal_reconcile(id, checkpoint:)
47
- request("proposal-reconcile", {"id" => id, "checkpoint" => checkpoint})
46
+ def proposal_reconcile(id, project:, checkpoint:)
47
+ request("proposal-reconcile", {"id" => id, "project" => project, "checkpoint" => checkpoint})
48
48
  end
49
49
 
50
50
  def proposal_wake(project:, after: nil)
51
51
  request("proposal-wake", {"project" => project, "after" => after}.compact)
52
52
  end
53
53
 
54
- def proposal_due(after: nil)
55
- request("proposal-due", {"after" => after}.compact)
54
+ def proposal_due(project:, after: nil)
55
+ request("proposal-due", {"project" => project, "after" => after}.compact)
56
56
  end
57
57
 
58
58
  def proposal_history(project:, query: "", after: nil)
@@ -66,14 +66,15 @@ module Ace
66
66
  end
67
67
 
68
68
  # @return [Hash] the result frame
69
- def request(op, params = {})
69
+ def request(op, params = {}, deadline: nil)
70
+ deadline = request_deadline(op, params, deadline)
70
71
  verify_endpoint!
71
- socket = UNIXSocket.open(@socket_path)
72
+ socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM, 0)
72
73
  begin
74
+ connect!(socket, deadline)
73
75
  verify_service_peer!(socket)
74
- socket.write(Protocol.encode_request(op, params))
75
- socket.flush
76
- line = read_line(socket, deadline_for(op, params))
76
+ write_frame!(socket, Protocol.encode_request(op, params), deadline)
77
+ line = read_line(socket, deadline)
77
78
  Protocol.decode_response(line)
78
79
  ensure
79
80
  socket.close
@@ -84,27 +85,27 @@ module Ace
84
85
  raise TransportError, "HITL boundary request is not serializable (#{e.message})"
85
86
  end
86
87
 
87
- def create(**params)
88
- request("create", params.transform_keys(&:to_s))
88
+ def create(deadline: nil, **params)
89
+ request("create", params.transform_keys(&:to_s), deadline: deadline)
89
90
  end
90
91
 
91
- def read(id)
92
- request("read", {"id" => id})
92
+ def read(id, deadline: nil)
93
+ request("read", {"id" => id}, deadline: deadline)
93
94
  end
94
95
 
95
96
  def deliver(id, answer)
96
97
  request("deliver", {"id" => id, "answer" => answer.to_s})
97
98
  end
98
99
 
99
- def consume(id, timeout: 0, operation: nil, native_delivery: false)
100
+ def consume(id, timeout: 0, operation: nil, native_delivery: false, deadline: nil)
100
101
  params = {"id" => id, "timeout" => Integer(timeout)}
101
102
  params["operation"] = operation if operation
102
103
  params["native_delivery"] = true if native_delivery
103
- request("consume", params)
104
+ request("consume", params, deadline: deadline)
104
105
  end
105
106
 
106
- def cancel(id, reason: "")
107
- request("cancel", {"id" => id, "reason" => reason.to_s})
107
+ def cancel(id, reason: "", deadline: nil)
108
+ request("cancel", {"id" => id, "reason" => reason.to_s}, deadline: deadline)
108
109
  end
109
110
 
110
111
  def pending(project: nil)
@@ -177,6 +178,43 @@ module Ace
177
178
  # A consume without a timeout waits indefinitely at the client
178
179
  # boundary too; every bounded call gets a deadline slightly
179
180
  # beyond its own wait so the server result wins the race.
181
+ def request_deadline(op, params, selected)
182
+ if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
183
+ raise TransportError, "HITL boundary deadline is invalid"
184
+ end
185
+ local = deadline_for(op, params)
186
+ [selected, local].compact.min
187
+ end
188
+
189
+ def await!(socket, direction, deadline)
190
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
+ raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
192
+ readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
193
+ raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
194
+ end
195
+
196
+ def connect!(socket, deadline)
197
+ if deadline && deadline <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
198
+ raise TransportError, "HITL boundary deadline exceeded"
199
+ end
200
+ result = socket.connect_nonblock(Socket.sockaddr_un(@socket_path), exception: false)
201
+ return if result == 0
202
+ await!(socket, :write, deadline)
203
+ error = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int
204
+ raise SystemCallError.new("HITL boundary connection failed", error) unless error.zero?
205
+ end
206
+
207
+ def write_frame!(socket, frame, deadline)
208
+ offset = 0
209
+ while offset < frame.bytesize
210
+ await!(socket, :write, deadline)
211
+ written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
212
+ next if written == :wait_writable
213
+ raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
214
+ offset += written
215
+ end
216
+ end
217
+
180
218
  def deadline_for(op, params)
181
219
  if op == "consume" && Integer(params["timeout"]) <= 0
182
220
  nil
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ace/hitl/contract/secret_gate"
4
+ require "ace/hitl/contract/managed_envelope"
4
5
 
5
6
  module Ace
6
7
  module Hitl
@@ -16,8 +17,10 @@ module Ace
16
17
 
17
18
  REQUEST_ID = /\A[A-Za-z0-9_-]{6,64}\z/
18
19
  # Compact managed identifiers (ADR-030): ace-assign assignment and
19
- # attempt ids are Base36 timestamp ids plus optional local suffix.
20
+ # standalone attempt ids are Base36 timestamp ids plus optional local suffix.
21
+ # Protected attempts retain the exact LaunchLifecycle launch identity.
20
22
  COMPACT_ID = /\A[0-9a-z][0-9a-z]{4,63}\z/
23
+ PROTECTED_ATTEMPT_ID = Contract::ManagedEnvelope::PROTECTED_ATTEMPT
21
24
  SAFE_LABEL = /\A[A-Za-z0-9_. -]{1,48}\z/
22
25
  OTP_ANSWER = /\A[0-9]{6}\z/
23
26
 
@@ -12,7 +12,7 @@ module Ace
12
12
  unless id.is_a?(String) && id.match?(/\Aproposal-[0-9a-f]{24}\z/)
13
13
  raise StateError, "create requires a stable proposal ID"
14
14
  end
15
- existing = proposal_journal.proposal_record(id)
15
+ existing = proposal_journal(project).proposal_record(id)
16
16
  if existing && existing.values_at("assignment_id", "attempt_id", "project_id", "requester", "caller_uid", "content_digest") !=
17
17
  [assignment, attempt, project, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
18
18
  raise StateError, "proposal retry changed immutable content or caller"
@@ -20,15 +20,15 @@ module Ace
20
20
  proposal_revision(id, 1, assignment: assignment, attempt: attempt, project: project, content: content)
21
21
  end
22
22
 
23
- def proposal_revise(id, expected_revision:, operation_id:, document:)
24
- old = proposal_access!(id, requester: true)
23
+ def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
24
+ old = proposal_access!(id, project: project, requester: true)
25
25
  require_proposer!(old["project_id"])
26
26
  unless expected_revision.is_a?(Integer) && expected_revision.positive? &&
27
27
  operation_id.is_a?(String) && operation_id.match?(/\Arevision-[0-9a-f]{24}\z/)
28
28
  raise StateError, "revise requires an expected source revision and stable revision operation ID"
29
29
  end
30
30
  content = Ace::Hitl::Proposals::Policy.document(document)
31
- replay = proposal_revision_replay(id, expected_revision, operation_id, content)
31
+ replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
32
32
  if replay
33
33
  project_proposal_request(replay)
34
34
  return replay
@@ -40,18 +40,18 @@ module Ace
40
40
  raise StateError, "Proposal revision refused (#{e.message})"
41
41
  end
42
42
 
43
- def proposal_show(id, history_after: 0)
44
- record = proposal_access!(id)
43
+ def proposal_show(id, project:, history_after: 0)
44
+ record = proposal_access!(id, project: project)
45
45
  unless history_after.is_a?(Integer) && history_after >= 0
46
46
  raise StateError, "history cursor must be a non-negative offset"
47
47
  end
48
- history = proposal_journal.proposal_history(id)
48
+ history = proposal_journal(project).proposal_history(id)
49
49
  entries = (history.slice(history_after, 3) || []).map do |entry|
50
- outcome = proposal_journal.proposal_claims(entry["revision_id"]).last
50
+ outcome = proposal_journal(project).proposal_claims(entry["revision_id"]).last
51
51
  entry.merge("actual_outcome" => outcome && outcome["state"],
52
52
  "effect_request_id" => outcome && outcome["request_id"])
53
53
  end
54
- claims = proposal_journal.proposal_claims(record["revision_id"])
54
+ claims = proposal_journal(project).proposal_claims(record["revision_id"])
55
55
  claim = claims.last
56
56
  outcome = case claim && claim["state"]
57
57
  when "accepted" then "executing"
@@ -64,19 +64,19 @@ module Ace
64
64
  "history_next" => history_after + entries.length < history.length ? history_after + entries.length : nil)
65
65
  end
66
66
 
67
- def proposal_acknowledge(request, submitted_at:)
68
- record = proposal_for_request!(request)
67
+ def proposal_acknowledge(request, project:, submitted_at:)
68
+ record = proposal_for_request!(request, project: project)
69
69
  require_transport!("proposal delivery", {"project" => record["project_id"]})
70
70
  proposal_transition(record) { |current| Ace::Hitl::Proposals::Policy.acknowledge(current, submitted_at) }
71
71
  end
72
72
 
73
- def proposal_reply(request, answer:, received_at:, sequence:)
74
- record = proposal_for_request!(request)
73
+ def proposal_reply(request, project:, answer:, received_at:, sequence:)
74
+ record = proposal_for_request!(request, project: project)
75
75
  require_transport!("proposal reply", {"project" => record["project_id"]})
76
76
  proposal_transition(record) do |current|
77
77
  # Claims and this transition share one canonical ref lock/CAS.
78
- claimed = proposal_journal.proposal_claims(current["revision_id"]).any?
79
- applied = proposal_journal.proposal_history(current["proposal_id"]).find do |entry|
78
+ claimed = proposal_journal(project).proposal_claims(current["revision_id"]).any?
79
+ applied = proposal_journal(project).proposal_history(current["proposal_id"]).find do |entry|
80
80
  entry["revision_id"] == current["revision_id"] && entry["reply_sequence"] == sequence
81
81
  end
82
82
  Ace::Hitl::Proposals::Policy.reply(current, applied: applied, answer: answer, received_at: received_at,
@@ -84,8 +84,8 @@ module Ace
84
84
  end
85
85
  end
86
86
 
87
- def proposal_reconcile(request, checkpoint:)
88
- record = proposal_for_request!(request)
87
+ def proposal_reconcile(request, project:, checkpoint:)
88
+ record = proposal_for_request!(request, project: project)
89
89
  require_transport!("proposal reconciliation", {"project" => record["project_id"]})
90
90
  proposal_transition(record) do |current|
91
91
  Ace::Hitl::Proposals::Policy.resolve(current, checkpoint: checkpoint, now: proposal_now)
@@ -95,7 +95,7 @@ module Ace
95
95
  def proposal_wake(project:, after: nil)
96
96
  raise StateError, "invalid proposal wake project" unless project.is_a?(String) && Kinds::SAFE_LABEL.match?(project)
97
97
  require_proposer!(project)
98
- records = proposal_journal.proposals.sort_by { |record| record["proposal_id"] }.select do |record|
98
+ records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
99
99
  record["project_id"] == project && (!after || record["proposal_id"] > after) &&
100
100
  record["state"] == "awaiting-decision" && record["deadline"] <= proposal_now
101
101
  end
@@ -107,10 +107,10 @@ module Ace
107
107
  {"items" => items, "next" => records.length > 8 ? selected.last["proposal_id"] : nil}
108
108
  end
109
109
 
110
- def proposal_due(after: nil)
111
- require_transport!("proposal deadlines")
112
- records = proposal_journal.proposals.sort_by { |record| record["proposal_id"] }.select do |record|
113
- (!after || record["proposal_id"] > after) &&
110
+ def proposal_due(project:, after: nil)
111
+ require_transport!("proposal deadlines", {"project" => project})
112
+ records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
113
+ record["project_id"] == project && (!after || record["proposal_id"] > after) &&
114
114
  @policy.transport?(@identity, project: record["project_id"]) &&
115
115
  record["state"] == "awaiting-decision" && record["deadline"] <= proposal_now && record["reconcile_requested_at"]
116
116
  end
@@ -123,7 +123,7 @@ module Ace
123
123
  unless query.is_a?(String) && query.bytesize <= 240 && project.is_a?(String)
124
124
  raise StateError, "history requires a project and bounded query"
125
125
  end
126
- records = proposal_journal.proposals.sort_by { |record| record["proposal_id"] }.select do |record|
126
+ records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
127
127
  next false unless record["project_id"] == project && (!after || record["proposal_id"] > after)
128
128
  visible = (record["requester"] == @identity.username && record["caller_uid"] == @identity.uid &&
129
129
  @policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: project)) ||
@@ -146,18 +146,21 @@ module Ace
146
146
  @proposal_clock.call.utc.iso8601
147
147
  end
148
148
 
149
- def proposal_journal
150
- @binding.proposal_journal
149
+ def proposal_journal(project)
150
+ unless project.is_a?(String) && Kinds::SAFE_LABEL.match?(project)
151
+ raise StateError, "proposal requires an explicit project"
152
+ end
153
+ @binding.proposal_journal(project: project)
151
154
  rescue NoMethodError
152
155
  raise BindingError, "Managed proposal authority is unavailable"
153
156
  end
154
157
 
155
- def proposal_access!(id, requester: false)
158
+ def proposal_access!(id, project:, requester: false)
156
159
  unless id.is_a?(String) && id.match?(/\Aproposal-[0-9a-f]{24}\z/)
157
160
  raise StateError, "invalid proposal ID"
158
161
  end
159
- record = proposal_journal.proposal_record(id)
160
- raise StateError, "unknown proposal" unless record
162
+ record = proposal_journal(project).proposal_record(id)
163
+ raise StateError, "unknown proposal" unless record && record["project_id"] == project
161
164
  if record["requester"] == @identity.username && record["caller_uid"] == @identity.uid &&
162
165
  @policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: record["project_id"])
163
166
  return record
@@ -172,19 +175,19 @@ module Ace
172
175
  record
173
176
  end
174
177
 
175
- def proposal_for_request!(request)
178
+ def proposal_for_request!(request, project:)
176
179
  unless request.is_a?(String) && request.match?(/\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/)
177
180
  raise StateError, "invalid proposal revision request"
178
181
  end
179
182
  id = request.sub(/-r[1-9][0-9]*\z/, "")
180
- proposal_access!(id)
181
- record = proposal_journal.proposal_history(id).reverse.find { |entry| entry["request_id"] == request }
183
+ proposal_access!(id, project: project)
184
+ record = proposal_journal(project).proposal_history(id).reverse.find { |entry| entry["request_id"] == request }
182
185
  raise StateError, "unknown proposal revision" unless record
183
186
  record
184
187
  end
185
188
 
186
189
  def proposal_transition(record, &policy)
187
- proposal_journal.change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
190
+ proposal_journal(record.fetch("project_id")).change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
188
191
  attempt_id: record["attempt_id"]) do |current|
189
192
  # A reply to an older revision cannot modify the new decision.
190
193
  next current unless current["request_id"] == record["request_id"]
@@ -199,9 +202,9 @@ module Ace
199
202
  question = "Proposal #{request}: #{JSON.generate(content)}"
200
203
  replay = nil
201
204
  record = @binding.with_active(assignment: assignment, attempt: attempt, project: project, requester: @identity.username) do
202
- proposal_journal.change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
205
+ proposal_journal(project).change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
203
206
  if operation_id
204
- replay = proposal_revision_replay(id, expected_revision, operation_id, content)
207
+ replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
205
208
  next previous if replay
206
209
  unless previous && previous["revision"] == expected_revision
207
210
  raise StateError, "proposal source revision changed concurrently"
@@ -238,11 +241,11 @@ module Ace
238
241
  record
239
242
  end
240
243
 
241
- def proposal_revision_replay(id, expected_revision, operation_id, content)
242
- replay = proposal_journal.proposal_operation_record(operation_id)
244
+ def proposal_revision_replay(id, expected_revision, operation_id, content, project:)
245
+ replay = proposal_journal(project).proposal_operation_record(operation_id)
243
246
  return unless replay
244
- unless replay.values_at("proposal_id", "expected_source_revision", "requester", "caller_uid", "content_digest") ==
245
- [id, expected_revision, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
247
+ unless replay.values_at("proposal_id", "project_id", "expected_source_revision", "requester", "caller_uid", "content_digest") ==
248
+ [id, project, expected_revision, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
246
249
  raise StateError, "revision retry changed immutable source, content or caller"
247
250
  end
248
251
  replay
@@ -267,10 +270,13 @@ module Ace
267
270
 
268
271
  def recover_proposal_projections(project:)
269
272
  return unless @binding.respond_to?(:proposal_journal)
270
- proposal_journal.proposals.each do |record|
271
- next if project && record["project_id"] != project
272
- next unless @policy.transport?(@identity, project: record["project_id"])
273
- project_proposal_request(record)
273
+ selected_projects = project ? [project] : @binding.proposal_projects
274
+ selected_projects.each do |selected|
275
+ next unless @policy.transport?(@identity, project: selected)
276
+ proposal_journal(selected).proposals.each do |record|
277
+ next unless record["project_id"] == selected
278
+ project_proposal_request(record)
279
+ end
274
280
  end
275
281
  end
276
282
  end