ace-hitl 0.9.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +40 -1
  4. data/docs/usage.md +153 -13
  5. data/lib/ace/hitl/cli/commands/ask.rb +107 -58
  6. data/lib/ace/hitl/cli/commands/cancel.rb +34 -0
  7. data/lib/ace/hitl/cli/commands/consume.rb +37 -0
  8. data/lib/ace/hitl/cli/commands/deliver.rb +33 -0
  9. data/lib/ace/hitl/cli/commands/duty.rb +29 -0
  10. data/lib/ace/hitl/cli/commands/lifecycle_command.rb +38 -0
  11. data/lib/ace/hitl/cli/commands/overseer_ack.rb +30 -0
  12. data/lib/ace/hitl/cli/commands/overseer_pending.rb +28 -0
  13. data/lib/ace/hitl/cli/commands/overseer_send.rb +35 -0
  14. data/lib/ace/hitl/cli/commands/pending.rb +29 -0
  15. data/lib/ace/hitl/cli/commands/serve.rb +71 -0
  16. data/lib/ace/hitl/cli/commands/states.rb +28 -0
  17. data/lib/ace/hitl/cli/commands/wait.rb +2 -2
  18. data/lib/ace/hitl/cli.rb +30 -1
  19. data/lib/ace/hitl/lifecycle/atomic_json.rb +79 -0
  20. data/lib/ace/hitl/lifecycle/binding.rb +38 -0
  21. data/lib/ace/hitl/lifecycle/client.rb +161 -0
  22. data/lib/ace/hitl/lifecycle/duty.rb +43 -0
  23. data/lib/ace/hitl/lifecycle/effects.rb +279 -0
  24. data/lib/ace/hitl/lifecycle/errors.rb +38 -0
  25. data/lib/ace/hitl/lifecycle/identity.rb +66 -0
  26. data/lib/ace/hitl/lifecycle/kinds.rb +69 -0
  27. data/lib/ace/hitl/lifecycle/otp_vault.rb +131 -0
  28. data/lib/ace/hitl/lifecycle/overseer.rb +111 -0
  29. data/lib/ace/hitl/lifecycle/peer.rb +61 -0
  30. data/lib/ace/hitl/lifecycle/policy.rb +172 -0
  31. data/lib/ace/hitl/lifecycle/protocol.rb +109 -0
  32. data/lib/ace/hitl/lifecycle/service.rb +289 -0
  33. data/lib/ace/hitl/lifecycle/store.rb +890 -0
  34. data/lib/ace/hitl/lifecycle.rb +24 -0
  35. data/lib/ace/hitl/molecules/lab_projection_observer.rb +9 -1
  36. data/lib/ace/hitl/providers/errors.rb +24 -0
  37. data/lib/ace/hitl/providers/lab/assignment_binding.rb +82 -0
  38. data/lib/ace/hitl/providers/lab/composite_binding.rb +47 -0
  39. data/lib/ace/hitl/providers/lab/daemon_binding.rb +210 -0
  40. data/lib/ace/hitl/providers/lab.rb +165 -0
  41. data/lib/ace/hitl/providers/providers.rb +41 -0
  42. data/lib/ace/hitl/providers/ref.rb +62 -0
  43. data/lib/ace/hitl/version.rb +1 -1
  44. data/lib/ace/hitl.rb +3 -0
  45. metadata +50 -3
  46. data/lib/ace/hitl/molecules/lab_request_submitter.rb +0 -82
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c36ea0129ebd4da1a3b54f5f6cb2f1701e38a1c4176403aead696aacc954dae
4
- data.tar.gz: 4c46c595bd2dbe61968429240b6a1770895ea9f531fe84c4fe472c6146bb06df
3
+ metadata.gz: 579408795de68b05baeeb590eac46687706134301423cb6114874c377d8183ed
4
+ data.tar.gz: ba4f300554670fb40acd58c470183d70342626b170889bda9944468aa54df6e8
5
5
  SHA512:
6
- metadata.gz: 54cf3972cd8ada223070d61b8d09ae1fbd6c7f033ad202831e07d2d7fd902f465e424947eb558c85f6aee4eb493d754b54f11c4f6a032a2787b4054b12ebea55
7
- data.tar.gz: 98ffa471017dae7e4671958411e2a4d36b573dfaaa5ef363a39153353466919ff06c76fd6f69108289cb3d35a5781db02f3e7d6855031c38fdc340b2427d2a35
6
+ metadata.gz: f421f0a4df5ea2eb782766c259336f1298844b3e8ebcefad34885c446865538f02d4d7da3bedcbc84c254464e8a589ff4d3007de8fbe14d0158ad23edf6118d3
7
+ data.tar.gz: 5102b14720d671f3252eae1b37b942620128f33df90983877e25354958b147bda0545b86ff946aa35f973210abdea603a342547acf11621b4baf2829c0372a65
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ 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
+
23
+ ## [0.10.0] - 2026-09-27
24
+
25
+ ### Added
26
+ - **Generic HITL request lifecycle (spec 8wm.t.y21, M1 migration from lab-config lab-hitl)**: the generic core now lives natively in the gem under `Ace::Hitl::Lifecycle` — request store (`create`/`pending`/`states`/`deliver`/`consume`/`cancel`), kinds + OTP/secret-shape answer gates, 0400 requester-owned answer relay, 0440 merge-on-write public projection under a per-request `flock`, no time-based expiry (W651: only an answer, its consumption, or an explicit audited `cancel` ends a request), requester-declared effect callbacks executed AS THE REQUESTER (exec-argv with `{answer}`, fullmatch regex gate, bounded timeout, redacted root-only effects log, deduped escalation with `callback-ok`/`callback-escalated` projection states and the pending+escalated duty projection), and the Overseer reverse-address surface (`overseer-send`/`overseer-pending`/`overseer-ack`: bounded, type-tagged `[decyzja]`/`[pytanie]`/`[info]`, no SHA/Work/Attempt/task IDs). New CLI commands: `deliver`, `consume`, `cancel`, `pending`, `states`, `duty`, `overseer-send`, `overseer-pending`, `overseer-ack` (machine output: one JSON line). Store root: `ACE_HITL_STORE_ROOT` (default `/run/lab/hitl`); Overseer channel root: `ACE_HITL_OVERSEER_CHANNEL_ROOT` (default `/lab/state/overseer-channel`).
27
+ - **Binding policy seam**: the generic store requires a fail-closed `Lifecycle::Binding` policy; provider=lab supplies `Providers::Lab::DaemonBinding`, the minimal client of the lab daemon's read-only `hitl_binding` socket op (Work/Attempt binding authority stays lab-side, per audit 8wl.t.gad.6). Escalation spooling stays behind the store's `escalation_sink` seam (the wake/`lab_control` glue stays lab-config).
28
+ - **Provider adapter interface + provider=lab contract (spec 8wm.t.vrz)**: `ace-hitl ask` dispatches through the `Ace::Hitl::Providers` registry (selection: `--provider` flag → `ACE_HITL_PROVIDER` env → `lab`). The ask performs the local-event + transport send in ONE operation and captures the asker's reverse address fail-closed from the herdr environment (`HERDR_SESSION` / `HERDR_PANE`; versioned schema `ace.hitl.ref/v1`), persisting `provider`, `ref_schema`, `ref_session`, `ref_pane` alongside the existing `lab_request_*` fields. Pinned error model: `UnknownProviderError`, `InvalidRefError` (fail closed before any event or transport state), `ProviderUnavailableError` (transport failure; orphan event id message preserved), `UnsupportedOperationError` (`deliver(ref, answer)` lands with ace-herdr push delivery 8wm.t.vs0 + provider=lab integration 8wm.t.vs2; `wait` remains the pane-less CLI path outside the adapter).
29
+
30
+ ### Changed
31
+ - **provider=lab `ask` creates the relay request through the native lifecycle store** (binding + effect declared in-process). The external-binary transport `Providers::Lab::Transport` is DELETED (pre-1.0; supersedes the 8wm.t.vrz §7 re-homing); the orphan-event `ProviderUnavailableError` contract is preserved. File and record formats stay byte-compatible with the deployed lab consumers.
32
+ - **Zero-lab-hitl guard**: the legacy `Molecules::LabRequestSubmitter` was deleted and re-homed (same behavior, provider error model) as `Providers::Lab::Transport`, the sole owner of the lab transport binary reference. A fast guard test keeps every agent-facing ace-hitl path free of direct lab transport references, and agent-facing ask/wait output no longer names the relay binary.
33
+
34
+ ### Removed
35
+ - `Providers::Lab::Transport` and the `ACE_HITL_LAB_BIN` selection: the relay request path no longer shells out to an external binary.
36
+
37
+ ### Fixed
38
+ - **PR#336 review hardening (codex astra high)**: unique atomic-writer temporary files so competing writers can no longer delete each other's in-flight temp (store answer relay included); delivery and cancel re-validate the request incarnation and ownership under the per-request lock (a cancel+recreate of the same id can no longer redirect an answer into the new incarnation); the first projection is initialized inside the lifecycle lock behind a per-incarnation token so a broker delivery can never be regressed to `created`; effect callbacks spawn with forced exec/argv semantics (a single-element declaration can no longer reach a shell) as process group leaders whose whole group is terminated on timeout; `{answer}` substitution is literal (block-form `gsub`, no replacement-string backreferences); answer bounds are enforced on decoded UTF-8 characters with a separate byte bound, so valid multibyte answers through real IO are accepted.
39
+
10
40
  ## [0.9.0] - 2026-09-23
11
41
 
12
42
  ### Added
data/README.md CHANGED
@@ -10,11 +10,23 @@ 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 to the Lab (`--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 (configured transport operation through the boundary); executes the declared effect callback (non-OTP kinds)
19
+ - `ace-hitl consume` consumes one own relay request's answer (indefinite by default; `--timeout` bounds only the local wait)
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` 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.
29
+ - `ace-hitl overseer-send` / `ace-hitl overseer-pending` / `ace-hitl overseer-ack` the Overseer reverse-address response channel
18
30
 
19
31
  `ace-hitl` is a blocker-resolution tool, not a global dashboard:
20
32
 
@@ -23,6 +35,32 @@ Canonical workflow and skill for agents:
23
35
 
24
36
  Use `ace-overseer status` for a global worktree dashboard.
25
37
 
38
+ ## Provider adapters
39
+
40
+ `ace-hitl ask` dispatches through the `Ace::Hitl::Providers` registry
41
+ (selection: `--provider` flag → `ACE_HITL_PROVIDER` env → `lab`).
42
+
43
+ - `ask` is ONE operation: it creates the local HITL event and the relay
44
+ request through the NATIVE generic lifecycle store
45
+ (`Ace::Hitl::Lifecycle`; migration spec 8wm.t.y21), then persists
46
+ `provider`, `ref_schema`, `ref_session`, `ref_pane` plus the existing
47
+ `lab_request_*` fields.
48
+ - The asker's reverse address (`ref`, versioned schema
49
+ `ace.hitl.ref/v1`: herdr session + pane) is captured fail-closed from
50
+ `HERDR_SESSION` / `HERDR_PANE`; absent or invalid values abort the ask
51
+ before any event is created or store state changes.
52
+ - Error model: `UnknownProviderError`, `InvalidRefError`,
53
+ `ProviderUnavailableError` (store-create failure; surfaces the orphan
54
+ event id when one was already created), `UnsupportedOperationError`.
55
+ - `deliver(ref, answer)` (push the answer back to the asker's pane) is
56
+ declared by the interface; provider `lab` raises
57
+ `UnsupportedOperationError` until the ace-herdr push-delivery
58
+ integration lands. `ace-hitl wait` stays the pane-less script path and
59
+ does not go through a provider.
60
+ - The generic lifecycle is provider-agnostic; all lab coupling lives in
61
+ the provider=lab seams (the `Providers::Lab::DaemonBinding` labd
62
+ binding client and the store factory), enforced by guard tests.
63
+
26
64
  ## Examples
27
65
 
28
66
  ```bash
@@ -30,6 +68,7 @@ ace-hitl list
30
68
  ace-hitl list --scope all
31
69
  ace-hitl create "Which auth strategy?" --kind decision --question "JWT or sessions?"
32
70
  ace-hitl ask "Proceed with deploy?" --work W685 --effect-arg /bin/false --effect-cwd /tmp
71
+ ace-hitl ask "Proceed with deploy?" --provider lab --work W685
33
72
  ace-hitl show abc123 --content
34
73
  ace-hitl show abc123 --scope current
35
74
  ace-hitl update abc123 --answer "Use JWT with server-side refresh tokens."
data/docs/usage.md CHANGED
@@ -102,12 +102,14 @@ ace-hitl update abc123 --move-to next
102
102
  ace-hitl update abc123 --answer "close the assignment" --resume
103
103
  ```
104
104
 
105
- ## Ask (Lab request with effect callback)
105
+ ## Ask (Provider adapter with effect callback)
106
106
 
107
- `ace-hitl ask` creates the local HITL event, forwards the question to the Lab
108
- as a HITL request bound to the event via `--ace-hitl-id`, and prints both ids.
109
- Effect declarations are validated client-side (exact bounds) and passed through
110
- verbatim; the Lab tool remains the authority.
107
+ `ace-hitl ask` dispatches through the provider adapter registry
108
+ (`--provider`, default: `ACE_HITL_PROVIDER` env, then `lab`). ONE
109
+ operation: it creates the local HITL event, forwards the question through
110
+ the provider transport bound to the event via `--ace-hitl-id`, and prints
111
+ both ids. Effect declarations are validated client-side (exact bounds)
112
+ and passed through verbatim into the native relay request store.
111
113
 
112
114
  ```bash
113
115
  ace-hitl ask "Proceed with deploy?" \
@@ -118,6 +120,12 @@ ace-hitl ask "Proceed with deploy?" \
118
120
 
119
121
  - `--attempt` defaults to `LAB_ATTEMPT_ID`; `--project` to `ace`;
120
122
  `--harness` to `lab-admin`; `--plan` to `ace-hitl ask`.
123
+ - Reverse address (fail closed): the asker's herdr session + pane are
124
+ read from `HERDR_SESSION` / `HERDR_PANE` and persisted on the event as
125
+ `ref_session` / `ref_pane` with `ref_schema: ace.hitl.ref/v1` and
126
+ `provider: lab`. Absent or invalid values abort the ask before any
127
+ event is created or transport is called — an ask must always know
128
+ where its answer can be delivered.
121
129
  - Effect flags: `--effect-match` (regex, <= 200 chars, must compile),
122
130
  `--effect-arg` (repeatable, 1..16 x 1..512 chars after the lab's
123
131
  strip-then-bounds check; whitespace-only elements fail fast, valid
@@ -126,12 +134,141 @@ ace-hitl ask "Proceed with deploy?" \
126
134
  - Whether an effect was declared is recorded on the event as
127
135
  `lab_request_effect: declared|none` so `wait` can apply the right
128
136
  terminal semantics.
129
- - The answer is always relayed unchanged; consume it with
130
- `lab-hitl consume <request-id>` when ready.
131
- - If the Lab request fails after the local event was created, the error
132
- surfaces the event id as an orphan (created but never bound to a Lab
133
- request); inspect it with `ace-hitl show <id>` and delete or re-ask
134
- as needed.
137
+ - The answer is always relayed unchanged; consumption stays on the
138
+ operator side via `ace-hitl consume`.
139
+ - If the transport send fails after the local event was created, the
140
+ error surfaces the event id as an orphan (created but never bound to a
141
+ relay request); inspect it with `ace-hitl show <id>` and delete or
142
+ re-ask as needed.
143
+ - `deliver(ref, answer)` — pushing the answer back to the asker's pane —
144
+ is declared by the adapter interface; provider `lab` reports it as
145
+ unsupported until the ace-herdr push-delivery integration lands.
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
+
194
+ ## Relay Lifecycle (Generic HITL Request Store)
195
+
196
+ The generic relay request lifecycle is native to the gem
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).
221
+
222
+ Transport side:
223
+
224
+ ```bash
225
+ ace-hitl pending # answerable requests
226
+ ace-hitl states # all public lifecycle projections
227
+ ace-hitl duty # pending + escalated projection
228
+ ace-hitl deliver hitl-0a1b2c3d4e5f6708 <<< "approved"
229
+ ```
230
+
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:
235
+ exec-style argv (never a shell), `{answer}` substituted once per
236
+ element, optional fullmatch regex gate, bounded timeout, attempts
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.
241
+
242
+ Requester side:
243
+
244
+ ```bash
245
+ ace-hitl consume hitl-0a1b2c3d4e5f6708
246
+ ace-hitl consume hitl-0a1b2c3d4e5f6708 --timeout 600
247
+ ace-hitl cancel hitl-0a1b2c3d4e5f6708 --reason "operator stopped the work"
248
+ ```
249
+
250
+ A consume timeout bounds ONLY the local wait — the request stays
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;
255
+ it records `cancelled_by` and the `reason` in the public projection,
256
+ and late answers/consumes fail closed against the committed receipt.
257
+
258
+ Overseer reverse address (bounded, type-tagged responses):
259
+
260
+ ```bash
261
+ ace-hitl overseer-send --reply-to 321 <<< "[decyzja] Rekomendacja: A."
262
+ ace-hitl overseer-pending
263
+ ace-hitl overseer-ack msg-0123456789abcdef
264
+ ```
265
+
266
+ Responses are 1..1200 characters and must open with a type tag
267
+ (`[decyzja]`, `[pytanie]`, or `[info]`); full SHAs, Work/Attempt/task
268
+ IDs, or the word "SHA" are rejected. `overseer-send` is the overseer
269
+ user's operation; `overseer-pending`/`overseer-ack` are host-broker
270
+ (root) operations used by the transport to drain and acknowledge
271
+ relayed responses.
135
272
 
136
273
  ## Wait (Polling Default)
137
274
 
@@ -154,11 +291,14 @@ callback-ok / callback-escalated). Terminal semantics are effect-aware:
154
291
  - Effect-declaring requests keep waiting until the callback verdict
155
292
  (`callback-ok` or `callback-escalated`) appears — they never end
156
293
  silently at answer delivery; `callback-escalated` output points at
157
- `lab-hitl duty` for the escalation.
294
+ the lab duty projection for the escalation.
158
295
  - The event's `lab_request_state` records the effective state, so it
159
296
  never claims plain `answer-delivered` while an effect outcome exists.
160
297
 
161
- Relay consumption stays the agent's choice (`lab-hitl consume`).
298
+ Relay consumption stays on the operator side via `ace-hitl consume`.
299
+ `wait` is the pane-less script path: agents with a herdr pane ask
300
+ through the provider adapter and receive answers delivered back to
301
+ their pane.
162
302
 
163
303
  ## Lifecycle Event Names
164
304
 
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ace/support/cli"
4
+ require_relative "../../lifecycle"
4
5
  require_relative "../../atoms/hitl_effect_validator"
5
- require_relative "../../molecules/lab_request_submitter"
6
+ require_relative "../../providers/providers"
6
7
 
7
8
  module Ace
8
9
  module Hitl
@@ -11,13 +12,22 @@ module Ace
11
12
  class Ask < Ace::Support::Cli::Command
12
13
  include Ace::Support::Cli::Base
13
14
 
14
- desc "Ask a human via HITL and forward the request to the Lab"
15
+ desc "Ask a human via HITL and forward the request through a provider adapter"
15
16
 
16
17
  argument :question, required: true, desc: "Question text for the human"
17
18
 
18
19
  option :title, type: :string, desc: "Local HITL event title (defaults to the question)"
19
- option :work, type: :string, desc: "Lab Work id (W...)"
20
- option :attempt, type: :string, desc: "Lab Attempt id (A-...); defaults to LAB_ATTEMPT_ID"
20
+ option :provider, type: :string, desc: "HITL provider adapter (default: ACE_HITL_PROVIDER or lab)"
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"
21
31
  option :project, type: :string, desc: "Lab project label (default: ace)"
22
32
  option :harness, type: :string, desc: "Lab harness label (default: lab-admin)"
23
33
  option :plan, type: :string, desc: "Lab plan label (default: ace-hitl ask)"
@@ -32,75 +42,114 @@ module Ace
32
42
  option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
33
43
 
34
44
  def call(question:, **options)
35
- effect = {
45
+ effect = build_effect(options)
46
+ validate_effect!(effect)
47
+
48
+ work, assignment = require_binding!(options)
49
+ attempt = require_attempt!(options)
50
+ otp = build_otp_challenge!(options)
51
+ provider = resolve_provider(options[:provider])
52
+ ref = capture_ref
53
+
54
+ result = provider.ask(
55
+ question: question,
56
+ title: options[:title],
57
+ ref: ref,
58
+ work: work,
59
+ assignment: assignment,
60
+ attempt: attempt,
61
+ kind: options[:kind] || "text",
62
+ otp: otp,
63
+ project: options[:project] || Providers::Lab::DEFAULT_PROJECT,
64
+ harness: options[:harness] || Providers::Lab::DEFAULT_HARNESS,
65
+ plan: options[:plan] || Providers::Lab::DEFAULT_PLAN,
66
+ effect: effect
67
+ )
68
+
69
+ puts "HITL event: #{result.event_id}"
70
+ puts "Provider: #{Providers::Lab::PROVIDER_NAME} (ref #{ref.session}/#{ref.pane}, #{Providers::Ref::SCHEMA})"
71
+ puts "Lab request: #{result.request_id}"
72
+ rescue Providers::ProviderUnavailableError => e
73
+ raise_cli_error(e.message)
74
+ end
75
+
76
+ private
77
+
78
+ def build_effect(options)
79
+ {
36
80
  match: options[:"effect-match"],
37
81
  effect_args: Array(options[:"effect-arg"]),
38
82
  effect_cwd: options[:"effect-cwd"],
39
83
  effect_timeout: options[:"effect-timeout-s"]
40
84
  }
41
- begin
42
- Atoms::HitlEffectValidator.validate!(**effect)
43
- rescue Atoms::HitlEffectValidator::ValidationError => e
44
- raise_cli_error(e.message)
45
- end
46
- effect_declared = !!(effect[:match] || effect[:effect_cwd] || effect[:effect_timeout] ||
47
- Array(effect[:effect_args]).any?)
85
+ end
48
86
 
49
- work = require_work!(options)
50
- attempt = options[:attempt] || ENV["LAB_ATTEMPT_ID"]
51
- unless attempt && !attempt.strip.empty?
52
- raise_cli_error("--attempt required (or set LAB_ATTEMPT_ID)")
53
- end
87
+ def validate_effect!(effect)
88
+ Atoms::HitlEffectValidator.validate!(**effect)
89
+ rescue Atoms::HitlEffectValidator::ValidationError => e
90
+ raise_cli_error(e.message)
91
+ end
54
92
 
55
- submitter = Molecules::LabRequestSubmitter.new
56
- request_id = submitter.generate_request_id
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)
98
+ work = options[:work]
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?
57
107
 
58
- manager = Ace::Hitl::Organisms::HitlManager.new
59
- event = manager.create(
60
- options[:title] || question,
61
- questions: [question]
62
- )
108
+ [nil, assignment]
109
+ end
63
110
 
64
- argv = submitter.build_argv(
65
- request_id: request_id,
66
- work: work,
67
- attempt: attempt,
68
- project: options[:project] || "ace",
69
- harness: options[:harness] || "lab-admin",
70
- plan: options[:plan] || "ace-hitl ask",
71
- question: question,
72
- ace_hitl_id: event.id,
73
- **effect
74
- )
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}
129
+ end
75
130
 
76
- begin
77
- lab_request_id = submitter.submit(argv)
78
- rescue Molecules::LabRequestSubmitter::SubmissionError => e
79
- raise_cli_error(
80
- "#{e.message}; local HITL event #{event.id} was created but never bound to a " \
81
- "Lab request (orphan) - inspect it with ace-hitl show #{event.id} and delete " \
82
- "or re-ask as needed"
83
- )
131
+ def require_attempt!(options)
132
+ attempt = options[:attempt]
133
+ unless attempt && !attempt.strip.empty?
134
+ raise_cli_error("--attempt required (the exact active attempt id)")
84
135
  end
85
136
 
86
- manager.update(event.id, set: {
87
- "lab_request_id" => lab_request_id,
88
- "lab_request_state" => "created",
89
- "lab_request_effect" => effect_declared ? "declared" : "none"
90
- })
91
-
92
- puts "HITL event: #{event.id}"
93
- puts "Lab request: #{lab_request_id}"
94
- puts "Answer relay: lab-hitl consume #{lab_request_id}"
137
+ attempt
95
138
  end
96
139
 
97
- private
98
-
99
- def require_work!(options)
100
- work = options[:work]
101
- raise_cli_error("--work required (Lab Work id, e.g. W685)") if work.nil? || work.strip.empty?
140
+ def resolve_provider(raw)
141
+ name = raw || ENV["ACE_HITL_PROVIDER"] || Providers::Lab::PROVIDER_NAME
142
+ Providers.resolve(name)
143
+ rescue Providers::UnknownProviderError => e
144
+ raise_cli_error(e.message)
145
+ end
102
146
 
103
- work
147
+ # Fail closed BEFORE any state is created: the reverse address is
148
+ # required so the answer can be delivered back to this pane.
149
+ def capture_ref
150
+ Providers::Ref.from_env
151
+ rescue Providers::InvalidRefError => e
152
+ raise_cli_error(e.message)
104
153
  end
105
154
  end
106
155
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # The ONLY way to abandon a relay request: explicit, audited
11
+ # cancellation recorded in the public lifecycle projection
12
+ # (spec 8wm.t.y21 §3).
13
+ class Cancel < Ace::Support::Cli::Command
14
+ include Ace::Support::Cli::Base
15
+ include LifecycleCommand
16
+
17
+ desc "Cancel one HITL relay request with an audited reason"
18
+
19
+ argument :id, required: true, desc: "HITL relay request id"
20
+
21
+ option :reason, type: :string, desc: "Audited reason recorded in the public lifecycle record"
22
+
23
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
24
+
25
+ def call(id:, **options)
26
+ emit(lifecycle_client.cancel(id, reason: options[:reason] || ""))
27
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
28
+ raise_lifecycle_error(e.message)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # Requester-side answer consumption. A positive timeout bounds
11
+ # ONLY the local wait and never cancels the request (W651);
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).
15
+ class Consume < Ace::Support::Cli::Command
16
+ include Ace::Support::Cli::Base
17
+ include LifecycleCommand
18
+
19
+ desc "Wait for and consume the answer of one own HITL relay request"
20
+
21
+ argument :id, required: true, desc: "HITL relay request id"
22
+
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)"
25
+
26
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
27
+
28
+ def call(id:, **options)
29
+ emit(lifecycle_client.consume(id, timeout: options[:timeout] || 0, operation: options[:operation]))
30
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
31
+ raise_lifecycle_error(e.message)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # Operator/broker answering of one relay request (the "respond"
11
+ # surface). The answer is read from stdin; the effect callback,
12
+ # if declared, executes after the relay (spec 8wm.t.y21 §3, §5).
13
+ class Deliver < Ace::Support::Cli::Command
14
+ include Ace::Support::Cli::Base
15
+ include LifecycleCommand
16
+
17
+ desc "Deliver an answer (stdin) to a pending HITL relay request"
18
+
19
+ argument :id, required: true, desc: "HITL relay request id"
20
+
21
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
22
+
23
+ def call(id:, **options)
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
27
+ raise_lifecycle_error(e.message)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end