ace-hitl 0.10.0 → 0.11.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: 765d5a3ec0942bb25b92c9e16bf325bc60f5f9c58e72da56e8b62f8c716817e2
4
- data.tar.gz: b3c1015a2037ac173be8cae4ce7527fc0b099bbdf5acd419572c30992a874424
3
+ metadata.gz: 579408795de68b05baeeb590eac46687706134301423cb6114874c377d8183ed
4
+ data.tar.gz: ba4f300554670fb40acd58c470183d70342626b170889bda9944468aa54df6e8
5
5
  SHA512:
6
- metadata.gz: a54fd6f1fc8ac167b6ccd87bb724cd469c733df2a37006e2066a5123f875365ee65ef7d91132cf284167ea359bb57df6147ecc6e75f3f9cab264a9377c31809d
7
- data.tar.gz: 11bd3b6c7a553d5372b9cb2ee10ac87b1586e82ca90ab470e6216f041410db987842605ce7dca8595eed31e07c17c05b0453374e0cf14b2131bd65d9982aa516
6
+ metadata.gz: f421f0a4df5ea2eb782766c259336f1298844b3e8ebcefad34885c446865538f02d4d7da3bedcbc84c254464e8a589ff4d3007de8fbe14d0158ad23edf6118d3
7
+ data.tar.gz: 5102b14720d671f3252eae1b37b942620128f33df90983877e25354958b147bda0545b86ff946aa35f973210abdea603a342547acf11621b4baf2829c0372a65
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.11.0] - 2026-10-04
11
+
12
+ ### Added
13
+ - **Scoped privilege boundary for multi-user HITL state (spec 8wq.t.34i)**: the lifecycle store is now the PRIVATE state of one authenticated boundary service. `ace-hitl serve` runs the boundary over a peer-credential-authenticated UNIX socket (`Ace::Hitl::Lifecycle::Service`): every connection is identified from kernel peer credentials (`getpeereid` — payload fields and environment variables are never authority), clients authenticate the endpoint back (socket must be owned by the trusted service uid from the grants document, not world-writable, connected peer must BE the service uid), and the store root is service-owned (`0711` traverse-only root, `0700` private directories, `0755` public projections with `0440` non-secret records — no chmod-to-world workaround anywhere). Bounded newline-JSON protocol (`Lifecycle::Protocol`) with classified errors: `PermissionError`, `BindingError`, `StateError`, `AnswerError`, and the new `TransportError` for boundary unavailability — transport failures are visible and recoverable, never silent.
14
+ - **Managed assignment binding**: requests bind to the exact ACTIVE MANAGED attempt of the calling actor through the ace-assign coordinator (`Providers::Lab::AssignmentBinding`). `ace-assign` gained `AttemptCoordinator#with_verified_attempt`, which holds the assignment `LifecycleExclusion` (shared side) across the caller's whole locked transition while `finish`/`reconcile` hold the exclusive side — stale, ended, replaced, or unproven (uncertain) attempts cannot acquire authority, and terminal commits can never interleave with a liveness check. CLI: `ace-hitl ask --assignment ID --attempt ID --project ID`.
15
+ - **Transport authorization from trusted grants** (`Lifecycle::GrantsPolicy` + `TrustedFile`): transport uids and the service identity come only from the deployment-owned grants document (same file and traversal trust rules as ace-lab's `GrantResolver`: root-owned, no group/world-writable components, symlink-verified). ace-lab ships the matching `Molecules::HitlAuthorizer` for the lab-side delivery path.
16
+ - **Idempotent terminal receipts**: every consume/cancel commits a durable receipt under the stable per-request lock (lock files live in `locks/` and survive id reuse); retries report the committed outcome instead of failing, conflicting transitions stay classified errors.
17
+ - **OTP exact-operation challenges**: an `otp` request exists only with its non-secret publisher evidence (`{operation, result_ref, input_digest, expires_at}`, bounded to 24h); consumption requires the challenge's authorized operation; duplicate consumes replay the receipt WITHOUT the secret bytes. OTP effects are forbidden (the authorized operation consumes the OTP transiently). The service holds OTP bytes in a bounded, TTL-bound memory vault (`OtpVault::MemoryVault`) — secret bytes persist nowhere, transfer exactly once, and a restart deliberately loses the challenge (prompt again).
18
+
19
+ ### Changed
20
+ - **BREAKING (pre-1.0, ADR-024)**: `ace-hitl ask/deliver/consume/cancel/pending/states/duty` run through the boundary client — the CLI no longer touches shared store files directly, and broker operations no longer require root (the configured transport identity from the grants document does). The admin binding bypass is removed: EVERY request validates against its live attempt. The `LAB_ATTEMPT_ID` environment default is gone (environment is never attempt authority); `--attempt` is required, and binding requires `--assignment` (managed) or `--work` (legacy path, kept until vs2 switches consumers). Answers and store files are service-owned `0600` — requesters receive bytes over the authenticated boundary, not through file ownership. The multi-UID acceptance fixture (`test/edge/lifecycle/`) proves requester isolation, protected ownership, forged-identity rejection, and one-terminal-transition races under real distinct OS accounts (requires root; CI records the run as advisory evidence).
21
+
22
+
10
23
  ## [0.10.0] - 2026-09-27
11
24
 
12
25
  ### Added
data/README.md CHANGED
@@ -10,15 +10,22 @@ Canonical workflow and skill for agents:
10
10
  ## Commands
11
11
 
12
12
  - `ace-hitl create` creates a HITL event
13
- - `ace-hitl ask` asks a human via HITL and forwards the request through a provider adapter (`--provider`, default `lab`); ONE operation: local event + relay request through the native lifecycle store (`--work`, effect callback flags)
13
+ - `ace-hitl ask` asks a human via HITL and forwards the request through a provider adapter (`--provider`, default `lab`); ONE operation: local event + relay request through the authenticated boundary (`--assignment/--attempt/--project` managed binding, `--work` legacy, effect callback flags)
14
14
  - `ace-hitl list` lists HITL events with filters (`--scope current|all`, all statuses by default)
15
15
  - `ace-hitl show` renders event details, path, or raw content (`--scope current|all`)
16
16
  - `ace-hitl update` updates frontmatter, answer content, and folder location
17
17
  - `ace-hitl wait` polls a specific HITL event until answered (`--poll-every`, `--timeout`)
18
- - `ace-hitl deliver` answers a pending relay request from stdin (host-broker operation); executes the declared effect callback as the requester
18
+ - `ace-hitl deliver` answers a pending relay request from stdin (configured transport operation through the boundary); executes the declared effect callback (non-OTP kinds)
19
19
  - `ace-hitl consume` consumes one own relay request's answer (indefinite by default; `--timeout` bounds only the local wait)
20
20
  - `ace-hitl cancel` cancels with an audited reason (the only way to abandon a request)
21
- - `ace-hitl pending` / `ace-hitl states` / `ace-hitl duty` host-broker projections (pending, public lifecycle records, pending + escalated)
21
+ - `ace-hitl pending` / `ace-hitl states` / `ace-hitl duty` transport projections (pending, public lifecycle records, pending + escalated)
22
+ - `ace-hitl serve` runs the authenticated store boundary service (peer-credential UNIX socket; trusted grants authorize the transport)
23
+
24
+ Multi-user HITL state (spec 8wq.t.34i) is the PRIVATE state of the
25
+ `ace-hitl serve` boundary: identity comes from kernel peer
26
+ credentials, transport authority from the trusted grants document,
27
+ and OTP secrets are held in service memory only — never in files,
28
+ logs, or projections.
22
29
  - `ace-hitl overseer-send` / `ace-hitl overseer-pending` / `ace-hitl overseer-ack` the Overseer reverse-address response channel
23
30
 
24
31
  `ace-hitl` is a blocker-resolution tool, not a global dashboard:
data/docs/usage.md CHANGED
@@ -144,17 +144,82 @@ ace-hitl ask "Proceed with deploy?" \
144
144
  is declared by the adapter interface; provider `lab` reports it as
145
145
  unsupported until the ace-herdr push-delivery integration lands.
146
146
 
147
+ ## Scoped Store Boundary (spec 8wq.t.34i)
148
+
149
+ The relay request store is the PRIVATE state of one authenticated
150
+ boundary service. `ace-hitl serve` owns the store and speaks a bounded
151
+ JSON protocol over a UNIX socket; every client operation
152
+ (`ask`/`deliver`/`consume`/`cancel`/`pending`/`states`/`duty`/`read`)
153
+ runs through the authenticated boundary client — the CLI never touches
154
+ shared store files directly.
155
+
156
+ Identity is a kernel fact: the service resolves each connection's peer
157
+ uid from the OS (`getpeereid`), and payload fields or environment
158
+ variables can never name a requester. Clients authenticate the
159
+ endpoint back: the socket must be owned by the trusted service uid,
160
+ must not be world-writable, and the connected peer must BE the service
161
+ uid. Authorization facts (service uid, transport uids, per-project
162
+ Captain visibility) come only from the trusted deployment grants
163
+ document — the same file and traversal trust rules as ace-lab
164
+ (default `ACE_HITL_GRANTS_PATH`, `/etc/lab/ace-lab/authorization.yml`).
165
+
166
+ ```bash
167
+ # Deployment (root runs the service under its own account):
168
+ ace-hitl serve # ACE_HITL_SOCKET, ACE_HITL_STORE_ROOT,
169
+ # ACE_HITL_GRANTS_PATH override paths
170
+ ```
171
+
172
+ Store layout (all service-owned): `0711` traverse-only root;
173
+ `requests/ answers/ secrets/ effects/ locks/ terminals/` are `0700`;
174
+ `public/` is `0755` with `0440` non-secret projections. There is no
175
+ chmod-to-world-writable mode: requesters receive answer bytes over the
176
+ authenticated connection, never through file ownership.
177
+
178
+ Roles:
179
+
180
+ - requester: create, consume and cancel its OWN requests (request
181
+ facts come back from `ask`/`consume`; the boundary `read` protocol
182
+ operation is available to library clients);
183
+ - configured transport (grants `hitl.transport_uids` + principals):
184
+ `deliver`, `pending`, `states`, `duty`;
185
+ - unknown identity is an error, never permission.
186
+
187
+ Failures are classified end to end: `PermissionError`,
188
+ `BindingError`, `StateError`, `AnswerError`, and `TransportError`
189
+ (unavailable/untrusted boundary, deadline, malformed frame) — transport
190
+ failures are visible and recoverable, and duplicate consume/cancel are
191
+ idempotent (the committed receipt replays; a conflicting transition is
192
+ an error).
193
+
147
194
  ## Relay Lifecycle (Generic HITL Request Store)
148
195
 
149
196
  The generic relay request lifecycle is native to the gem
150
- (`Ace::Hitl::Lifecycle`; migration spec 8wm.t.y21). Requests are
151
- file-backed, never expire on a timer, and every terminal transition
152
- shares one per-request lock. The store root is `ACE_HITL_STORE_ROOT`
153
- (default `/run/lab/hitl`); the Overseer channel root is
154
- `ACE_HITL_OVERSEER_CHANNEL_ROOT` (default `/lab/state/overseer-channel`).
155
- Machine output is one JSON line.
197
+ (`Ace::Hitl::Lifecycle`; migration spec 8wm.t.y21, scoped by
198
+ 8wq.t.34i). Requests never expire on a timer, every terminal
199
+ transition shares one stable per-request lock, and the store root is
200
+ `ACE_HITL_STORE_ROOT` (default `/run/lab/hitl`); the Overseer channel
201
+ root is `ACE_HITL_OVERSEER_CHANNEL_ROOT` (default
202
+ `/lab/state/overseer-channel`). Machine output is one JSON line.
203
+
204
+ Managed binding (the default authority):
205
+
206
+ ```bash
207
+ ace-hitl ask "Choose the next scope" \
208
+ --assignment 8x3abc --attempt a1b2c3 --project ace
209
+ ```
210
+
211
+ The request binds to the exact ACTIVE MANAGED attempt of the calling
212
+ identity, verified through the ace-assign coordinator under the
213
+ assignment exclusion: a stale, ended, replaced, or uncertain attempt
214
+ cannot acquire authority, and the exclusion is HELD across every
215
+ consume/deliver transition so an attempt cannot end between the
216
+ liveness check and the commit. `--work W... --attempt A-...` remains
217
+ the legacy binding until the provider=lab integration (vs2) switches
218
+ consumers; the two authorities are mutually exclusive and
219
+ `--attempt` is always required (environment variables are never
220
+ attempt identity).
156
221
 
157
- Operator/broker side (host-broker operations are root-only):
222
+ Transport side:
158
223
 
159
224
  ```bash
160
225
  ace-hitl pending # answerable requests
@@ -163,15 +228,16 @@ ace-hitl duty # pending + escalated projection
163
228
  ace-hitl deliver hitl-0a1b2c3d4e5f6708 <<< "approved"
164
229
  ```
165
230
 
166
- `deliver` reads the answer from stdin, relays it unchanged (0400,
167
- owner = requester) into `secrets/` for OTP kinds or `answers/` for
168
- everything else, re-verifies attempt liveness under the request lock,
169
- and then executes the declared effect callback AS THE REQUESTER:
231
+ `deliver` reads the answer from stdin and relays it unchanged. OTP
232
+ bytes go to the service's memory vault; plain answers to the
233
+ service-owned `0600` answers file. Liveness is re-verified under the
234
+ lock, and the declared effect callback (non-OTP kinds only) executes:
170
235
  exec-style argv (never a shell), `{answer}` substituted once per
171
236
  element, optional fullmatch regex gate, bounded timeout, attempts
172
- logged redacted in the root-only effects log, and one deduped
173
- escalation with `effect_state: callback-escalated` in the public
174
- projection on failure (`callback-ok` on success).
237
+ logged redacted in the effects log, and one deduped escalation with
238
+ `effect_state: callback-escalated` in the public projection on failure
239
+ (`callback-ok` on success). Duplicate deliveries are idempotent: the
240
+ committed answer is reported without re-running any effect.
175
241
 
176
242
  Requester side:
177
243
 
@@ -182,9 +248,12 @@ ace-hitl cancel hitl-0a1b2c3d4e5f6708 --reason "operator stopped the work"
182
248
  ```
183
249
 
184
250
  A consume timeout bounds ONLY the local wait — the request stays
185
- pending and answerable. Cancel is the ONLY way to abandon a request;
251
+ pending and answerable. OTP consumption requires the challenge's
252
+ authorized operation (`--operation <name>`); the secret transfers
253
+ exactly once and a consumed retry replays the receipt WITHOUT the
254
+ bytes. Cancel is the ONLY way to abandon a request, requester-only;
186
255
  it records `cancelled_by` and the `reason` in the public projection,
187
- and a late answer fails closed.
256
+ and late answers/consumes fail closed against the committed receipt.
188
257
 
189
258
  Overseer reverse address (bounded, type-tagged responses):
190
259
 
@@ -18,8 +18,16 @@ module Ace
18
18
 
19
19
  option :title, type: :string, desc: "Local HITL event title (defaults to the question)"
20
20
  option :provider, type: :string, desc: "HITL provider adapter (default: ACE_HITL_PROVIDER or lab)"
21
- option :work, type: :string, desc: "Lab Work id (W...)"
22
- option :attempt, type: :string, desc: "Lab Attempt id (A-...); defaults to LAB_ATTEMPT_ID"
21
+ # Managed binding (spec 8wq.t.34i): the request binds to the
22
+ # exact active managed attempt of the calling identity.
23
+ option :assignment, type: :string, desc: "Managed assignment id (compact id)"
24
+ option :kind, type: :string, desc: "Request kind (default: text; otp requires the challenge evidence)"
25
+ option :"otp-operation", type: :string, desc: "OTP challenge: the ONE authorized operation name"
26
+ option :"otp-result-ref", type: :string, desc: "OTP challenge: OTP-required publisher result reference"
27
+ option :"otp-input-digest", type: :string, desc: "OTP challenge: sha256 input digest of the authorized input"
28
+ option :"otp-expires-at", type: :string, desc: "OTP challenge: expiry as unix seconds (<= 24h ahead)"
29
+ option :work, type: :string, desc: "Lab Work id (W...) - legacy binding until vs2 switches consumers"
30
+ option :attempt, type: :string, desc: "Attempt id of the exact active attempt"
23
31
  option :project, type: :string, desc: "Lab project label (default: ace)"
24
32
  option :harness, type: :string, desc: "Lab harness label (default: lab-admin)"
25
33
  option :plan, type: :string, desc: "Lab plan label (default: ace-hitl ask)"
@@ -37,8 +45,9 @@ module Ace
37
45
  effect = build_effect(options)
38
46
  validate_effect!(effect)
39
47
 
40
- work = require_work!(options)
48
+ work, assignment = require_binding!(options)
41
49
  attempt = require_attempt!(options)
50
+ otp = build_otp_challenge!(options)
42
51
  provider = resolve_provider(options[:provider])
43
52
  ref = capture_ref
44
53
 
@@ -47,7 +56,10 @@ module Ace
47
56
  title: options[:title],
48
57
  ref: ref,
49
58
  work: work,
59
+ assignment: assignment,
50
60
  attempt: attempt,
61
+ kind: options[:kind] || "text",
62
+ otp: otp,
51
63
  project: options[:project] || Providers::Lab::DEFAULT_PROJECT,
52
64
  harness: options[:harness] || Providers::Lab::DEFAULT_HARNESS,
53
65
  plan: options[:plan] || Providers::Lab::DEFAULT_PLAN,
@@ -78,17 +90,48 @@ module Ace
78
90
  raise_cli_error(e.message)
79
91
  end
80
92
 
81
- def require_work!(options)
93
+ # The binding is authority: exactly one of the managed
94
+ # assignment binding or the legacy Work binding. An
95
+ # environment variable never supplies attempt identity
96
+ # (spec 8wq.t.34i).
97
+ def require_binding!(options)
82
98
  work = options[:work]
83
- raise_cli_error("--work required (Lab Work id, e.g. W685)") if work.nil? || work.strip.empty?
99
+ assignment = options[:assignment]
100
+ if work && assignment
101
+ raise_cli_error("--assignment and --work are mutually exclusive binding authorities")
102
+ end
103
+ if work.nil? && assignment.nil?
104
+ raise_cli_error("--assignment required (managed binding), or --work for the legacy Work binding")
105
+ end
106
+ return [work, nil] unless work.nil?
107
+
108
+ [nil, assignment]
109
+ end
84
110
 
85
- work
111
+ # The OTP challenge evidence is non-secret, structurally
112
+ # validated store-side; the CLI only assembles it (spec
113
+ # 8wq.t.34i: no publisher result, no OTP request).
114
+ def build_otp_challenge!(options)
115
+ kind = options[:kind] || "text"
116
+ return nil unless kind == "otp"
117
+
118
+ operation = options[:"otp-operation"]
119
+ result_ref = options[:"otp-result-ref"]
120
+ input_digest = options[:"otp-input-digest"]
121
+ expires_at = options[:"otp-expires-at"]
122
+ missing = %w[--otp-operation --otp-result-ref --otp-input-digest --otp-expires-at]
123
+ .zip([operation, result_ref, input_digest, expires_at])
124
+ .select { |_flag, value| value.nil? || value.to_s.strip.empty? }
125
+ .map(&:first)
126
+ raise_cli_error("OTP requests require #{missing.join(", ")} (the OTP-required publisher evidence)") unless missing.empty?
127
+
128
+ {operation: operation, result_ref: result_ref, input_digest: input_digest, expires_at: expires_at}
86
129
  end
87
130
 
88
131
  def require_attempt!(options)
89
- attempt = options[:attempt] || ENV["LAB_ATTEMPT_ID"]
132
+ attempt = options[:attempt]
90
133
  unless attempt && !attempt.strip.empty?
91
- raise_cli_error("--attempt required (or set LAB_ATTEMPT_ID)")
134
+ raise_cli_error("--attempt required (the exact active attempt id)")
92
135
  end
93
136
 
94
137
  attempt
@@ -23,8 +23,8 @@ module Ace
23
23
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
24
24
 
25
25
  def call(id:, **options)
26
- emit(lifecycle_store.cancel(id, reason: options[:reason] || ""))
27
- rescue Lifecycle::Error => e
26
+ emit(lifecycle_client.cancel(id, reason: options[:reason] || ""))
27
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
28
28
  raise_lifecycle_error(e.message)
29
29
  end
30
30
  end
@@ -9,7 +9,9 @@ module Ace
9
9
  module Commands
10
10
  # Requester-side answer consumption. A positive timeout bounds
11
11
  # ONLY the local wait and never cancels the request (W651);
12
- # without it the wait is indefinite (spec 8wm.t.y21 §3).
12
+ # without it the wait is indefinite (spec 8wm.t.y21 §3). OTP
13
+ # answers require the challenge's authorized operation
14
+ # (spec 8wq.t.34i).
13
15
  class Consume < Ace::Support::Cli::Command
14
16
  include Ace::Support::Cli::Base
15
17
  include LifecycleCommand
@@ -19,12 +21,13 @@ module Ace
19
21
  argument :id, required: true, desc: "HITL relay request id"
20
22
 
21
23
  option :timeout, type: :integer, desc: "Local wait bound in seconds; 0 (default) waits indefinitely"
24
+ option :operation, type: :string, desc: "Authorized operation (required for OTP challenges)"
22
25
 
23
26
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
24
27
 
25
28
  def call(id:, **options)
26
- emit(lifecycle_store.consume(id, timeout: options[:timeout] || 0))
27
- rescue Lifecycle::Error => e
29
+ emit(lifecycle_client.consume(id, timeout: options[:timeout] || 0, operation: options[:operation]))
30
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
28
31
  raise_lifecycle_error(e.message)
29
32
  end
30
33
  end
@@ -21,8 +21,9 @@ module Ace
21
21
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
22
22
 
23
23
  def call(id:, **options)
24
- emit(lifecycle_store.deliver(id, LifecycleCommand::STDIN_READER))
25
- rescue Lifecycle::Error => e
24
+ answer = $stdin.read(Lifecycle::Store::MAX_ANSWER_BYTES + 1).to_s
25
+ emit(lifecycle_client.deliver(id, answer))
26
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
26
27
  raise_lifecycle_error(e.message)
27
28
  end
28
29
  end
@@ -13,13 +13,13 @@ module Ace
13
13
  include Ace::Support::Cli::Base
14
14
  include LifecycleCommand
15
15
 
16
- desc "Project pending and escalated HITL requests (host-broker operation)"
16
+ desc "Project pending and escalated HITL requests (transport operation)"
17
17
 
18
18
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
19
19
 
20
20
  def call(**options)
21
- emit(Lifecycle::Duty.project(lifecycle_store))
22
- rescue Lifecycle::Error => e
21
+ emit(Lifecycle::Duty.project(pending: lifecycle_client.pending, states: lifecycle_client.states))
22
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
23
23
  raise_lifecycle_error(e.message)
24
24
  end
25
25
  end
@@ -6,15 +6,17 @@ module Ace
6
6
  module Hitl
7
7
  module CLI
8
8
  module Commands
9
- # Shared plumbing for the operator/broker lifecycle commands
10
- # (spec 8wm.t.y21 §8): the store is built through the provider=lab
11
- # seam and machine outputs are one JSON line, byte-compatible
12
- # with the migrated CLI contract.
9
+ # Shared plumbing for the lifecycle commands (spec 8wm.t.y21 §8,
10
+ # scoped by 8wq.t.34i): every lifecycle operation reaches the
11
+ # store through the AUTHENTICATED BOUNDARY CLIENT — direct
12
+ # shared-store access is not a CLI option — and machine outputs
13
+ # are one JSON line, byte-compatible with the migrated CLI
14
+ # contract.
13
15
  module LifecycleCommand
14
16
  STDIN_READER = ->(limit) { $stdin.read(limit) }.freeze
15
17
 
16
- def lifecycle_store(store: nil)
17
- Providers::Lab.lifecycle_store(store: store)
18
+ def lifecycle_client
19
+ Providers::Lab.boundary_client
18
20
  end
19
21
 
20
22
  def overseer
@@ -13,13 +13,13 @@ module Ace
13
13
  include Ace::Support::Cli::Base
14
14
  include LifecycleCommand
15
15
 
16
- desc "List answerable HITL relay requests (host-broker operation)"
16
+ desc "List answerable HITL relay requests (transport operation)"
17
17
 
18
18
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
19
19
 
20
20
  def call(**options)
21
- emit(lifecycle_store.pending)
22
- rescue Lifecycle::Error => e
21
+ emit(lifecycle_client.pending)
22
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
23
23
  raise_lifecycle_error(e.message)
24
24
  end
25
25
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "../../lifecycle"
5
+ require_relative "../../providers/providers"
6
+
7
+ module Ace
8
+ module Hitl
9
+ module CLI
10
+ module Commands
11
+ # The installed scoped store boundary (spec 8wq.t.34i): serves
12
+ # the private HITL store over a peer-authenticated UNIX socket.
13
+ # Authorization facts come only from the trusted grants
14
+ # document; the store root and socket paths come from the
15
+ # deployment (options/environment), never from caller payloads.
16
+ class Serve < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+
19
+ desc "Run the authenticated HITL store boundary service (peer-credential access)"
20
+
21
+ option :socket, type: :string, desc: "Boundary socket path (default: ACE_HITL_SOCKET or /run/lab/hitl.sock)"
22
+ option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
23
+ option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
24
+ option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
25
+
26
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
27
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
28
+
29
+ def call(**options)
30
+ policy = Providers::Lab.grants_policy(grants_path: grants_path(options))
31
+ unless policy.service_uid
32
+ raise_cli_error(
33
+ "the HITL service requires trusted grants with hitl.service_uid " \
34
+ "(#{grants_path(options)}); serving without a trusted service identity would " \
35
+ "fail every client endpoint verification"
36
+ )
37
+ end
38
+ binding_policy = Providers::Lab::CompositeBinding.new(
39
+ assignment: Providers::Lab.assignment_binding(repo_root: options[:repo_root]),
40
+ work: Providers::Lab::DaemonBinding.new
41
+ )
42
+ service = Lifecycle::Service.new(
43
+ root: store_root(options),
44
+ binding: binding_policy,
45
+ policy: policy,
46
+ socket_path: socket_path(options),
47
+ logger: options[:verbose] ? ->(message) { warn message } : nil
48
+ )
49
+ service.run
50
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
51
+ raise_cli_error(e.message)
52
+ end
53
+
54
+ private
55
+
56
+ def socket_path(options)
57
+ options[:socket] || ENV[Providers::Lab::SOCKET_PATH_ENV] || Providers::Lab::DEFAULT_SOCKET_PATH
58
+ end
59
+
60
+ def store_root(options)
61
+ options[:store_root] || ENV[Providers::Lab::STORE_ROOT_ENV] || Providers::Lab::DEFAULT_STORE_ROOT
62
+ end
63
+
64
+ def grants_path(options)
65
+ options[:grants_path] || ENV[Providers::Lab::GRANTS_PATH_ENV] || Providers::Lab::DEFAULT_GRANTS_PATH
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -12,13 +12,13 @@ module Ace
12
12
  include Ace::Support::Cli::Base
13
13
  include LifecycleCommand
14
14
 
15
- desc "List public HITL lifecycle projections (host-broker operation)"
15
+ desc "List public HITL lifecycle projections (transport operation)"
16
16
 
17
17
  option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
18
18
 
19
19
  def call(**options)
20
- emit(lifecycle_store.states)
21
- rescue Lifecycle::Error => e
20
+ emit(lifecycle_client.states)
21
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
22
22
  raise_lifecycle_error(e.message)
23
23
  end
24
24
  end
data/lib/ace/hitl/cli.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "cli/commands/cancel"
14
14
  require_relative "cli/commands/pending"
15
15
  require_relative "cli/commands/states"
16
16
  require_relative "cli/commands/duty"
17
+ require_relative "cli/commands/serve"
17
18
  require_relative "cli/commands/overseer_send"
18
19
  require_relative "cli/commands/overseer_pending"
19
20
  require_relative "cli/commands/overseer_ack"
@@ -35,9 +36,9 @@ module Ace
35
36
  ["deliver", "Deliver an answer (stdin) to a pending HITL relay request"],
36
37
  ["consume", "Wait for and consume the answer of one own HITL relay request"],
37
38
  ["cancel", "Cancel one HITL relay request with an audited reason"],
38
- ["pending", "List answerable HITL relay requests (host-broker)"],
39
- ["states", "List public HITL lifecycle projections (host-broker)"],
40
- ["duty", "Project pending and escalated HITL requests (host-broker)"],
39
+ ["pending", "List answerable HITL relay requests (transport)"],
40
+ ["states", "List public HITL lifecycle projections (transport)"],
41
+ ["duty", "Project pending and escalated HITL requests (transport)"],
41
42
  ["overseer-send", "Queue a bounded, type-tagged Overseer response (stdin)"],
42
43
  ["overseer-pending", "List queued Overseer responses (host-broker)"],
43
44
  ["overseer-ack", "Acknowledge one relayed Overseer response"]
@@ -64,6 +65,7 @@ module Ace
64
65
  register "pending", CLI::Commands::Pending
65
66
  register "states", CLI::Commands::States
66
67
  register "duty", CLI::Commands::Duty
68
+ register "serve", CLI::Commands::Serve
67
69
  register "overseer-send", CLI::Commands::OverseerSend
68
70
  register "overseer-pending", CLI::Commands::OverseerPending
69
71
  register "overseer-ack", CLI::Commands::OverseerAck
@@ -12,7 +12,7 @@ module Ace
12
12
  class Binding
13
13
  # Validate a request creation against the requester's exact live
14
14
  # attempt. Raises BindingError on any violation or unavailability.
15
- def validate_request(work:, attempt:, project:, requester:)
15
+ def validate_request(work: nil, assignment: nil, attempt:, project:, requester:)
16
16
  raise NotImplementedError
17
17
  end
18
18
 
@@ -21,6 +21,17 @@ module Ace
21
21
  def require_active(work:, attempt:)
22
22
  raise NotImplementedError
23
23
  end
24
+
25
+ # Hold verified live authority across ONE whole transition: the
26
+ # check and the caller's locked commit happen inside the same
27
+ # exclusion, so a concurrent attempt termination cannot land
28
+ # between them (spec 8wq.t.34i). The yielded value is opaque.
29
+ # Implementations without a holdable exclusion verify and yield
30
+ # (the legacy gap); the assignment authority holds its lock.
31
+ def with_active(work: nil, assignment: nil, attempt:, project: nil, requester: nil)
32
+ require_active(work: work, attempt: attempt)
33
+ yield
34
+ end
24
35
  end
25
36
  end
26
37
  end