ace-hitl 0.15.0 → 0.17.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/hitl/config.yml +0 -0
  3. data/.ace-defaults/nav/protocols/skill-sources/ace-hitl.yml +0 -0
  4. data/.ace-defaults/nav/protocols/wfi-sources/ace-hitl.yml +0 -0
  5. data/CHANGELOG.md +24 -0
  6. data/README.md +48 -0
  7. data/Rakefile +0 -0
  8. data/docs/demo/ace-hitl-scope-and-answer.tape.yml +0 -0
  9. data/docs/demo/fixtures/demo-context.md +0 -0
  10. data/docs/usage.md +12 -2
  11. data/handbook/README.md +0 -0
  12. data/handbook/skills/as-hitl/SKILL.md +0 -0
  13. data/handbook/workflow-instructions/hitl.wf.md +0 -0
  14. data/lib/ace/hitl/atoms/hitl_effect_validator.rb +0 -0
  15. data/lib/ace/hitl/atoms/hitl_file_pattern.rb +0 -0
  16. data/lib/ace/hitl/atoms/hitl_id_formatter.rb +0 -0
  17. data/lib/ace/hitl/cli/commands/create.rb +0 -0
  18. data/lib/ace/hitl/cli/commands/list.rb +0 -0
  19. data/lib/ace/hitl/cli/commands/operator_question.rb +27 -0
  20. data/lib/ace/hitl/cli/commands/read.rb +22 -0
  21. data/lib/ace/hitl/cli/commands/serve.rb +12 -1
  22. data/lib/ace/hitl/cli/commands/show.rb +0 -0
  23. data/lib/ace/hitl/cli.rb +6 -0
  24. data/lib/ace/hitl/lifecycle/binding.rb +13 -0
  25. data/lib/ace/hitl/lifecycle/frame_serving.rb +2 -1
  26. data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +4 -0
  27. data/lib/ace/hitl/lifecycle/operator_questions.rb +84 -0
  28. data/lib/ace/hitl/lifecycle/policy.rb +49 -3
  29. data/lib/ace/hitl/lifecycle/proposals.rb +12 -0
  30. data/lib/ace/hitl/lifecycle/protocol.rb +1 -1
  31. data/lib/ace/hitl/lifecycle/service.rb +5 -3
  32. data/lib/ace/hitl/lifecycle/store.rb +66 -7
  33. data/lib/ace/hitl/lifecycle/tls_access_policy.rb +16 -1
  34. data/lib/ace/hitl/lifecycle/tls_authority.rb +14 -5
  35. data/lib/ace/hitl/lifecycle/tls_service.rb +4 -3
  36. data/lib/ace/hitl/models/hitl_event.rb +0 -0
  37. data/lib/ace/hitl/molecules/hitl_answer_editor.rb +0 -0
  38. data/lib/ace/hitl/molecules/hitl_config_loader.rb +0 -0
  39. data/lib/ace/hitl/molecules/hitl_creator.rb +0 -0
  40. data/lib/ace/hitl/molecules/hitl_display_formatter.rb +0 -0
  41. data/lib/ace/hitl/molecules/hitl_loader.rb +0 -0
  42. data/lib/ace/hitl/molecules/hitl_resolver.rb +0 -0
  43. data/lib/ace/hitl/molecules/hitl_scanner.rb +0 -0
  44. data/lib/ace/hitl/molecules/resume_dispatcher.rb +0 -0
  45. data/lib/ace/hitl/molecules/worktree_scope_resolver.rb +0 -0
  46. data/lib/ace/hitl/proposals/policy.rb +2 -0
  47. data/lib/ace/hitl/providers/lab/assignment_binding.rb +20 -1
  48. data/lib/ace/hitl/version.rb +1 -1
  49. metadata +25 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0827bd8ade04c613021128ac335a1d0fea74d5954e718122bd243a780bebb675'
4
- data.tar.gz: df48a9612ca6a87670142ee8f511a837e206af2294a1a609be8045bfc14c1830
3
+ metadata.gz: 8ab176a502d7a751a22e89f461245046caa44d71943897b7f27b6d5ffdd10c84
4
+ data.tar.gz: f789375f072b07f087462b78fd2d2fb7a9ae45e167eedbd413a11fe09e9ead0e
5
5
  SHA512:
6
- metadata.gz: b0f0b827b307e8baf05a25c9503455fde43c8734e121f7d3f5f222a4d0a0aa201653bb346366fd6b8445c700031113068ff133cf24e4566b825e4bf59633f622
7
- data.tar.gz: 462e2cef1a68a7a4a3975b96b324977223101a53ba384940875197e61c103054d3e52cbb83d4337f98aea0f5c92dfd306d0c372caa61e2bd8ab20517aca73cf3
6
+ metadata.gz: b6f1c00953d56deac3fe8e7099a1aac527681b1314aeda336c7691de04bf7ab40af33c8815ddcac76777e02151609c555cb0f4f0be2276e8a7b8ab0359b3a9ae
7
+ data.tar.gz: 19e978992b0041ae887a26d743555fe69b8292fd01c763c488bef96e6feecb00c3437f58909d015eeaefa21884b552b203af47f94e583b2feef4e8bc3f4e06fc
File without changes
File without changes
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Add explicit unassigned operator questions with kernel requester UID, exact installed project grants, stable retries and existing request/terminal recovery. Expose public client/CLI creation/read and an operator-only service binding that refuses managed execution, secrets and effects.
15
+
16
+ ### Changed
17
+
18
+ - Require the ace-hitl-contract 0.3 operator-question envelope for the tagged request union.
19
+
20
+ ### Fixed
21
+
22
+ - Require a reconciliation checkpoint to name the canonical proposal project before silence can authorize its effect.
23
+
24
+ ## [0.16.0] - 2026-10-16
25
+
26
+ ### Added
27
+
28
+ - Require explicit predelegation for sixteen-hour proposal decisions and bind ordinary project proposals to the selected canonical owner. Refresh project and owner grants at transitions and replay; compose existing Unix/TLS listeners under the authority lifetime.
29
+
30
+ ### Fixed
31
+
32
+ - Read TLS authority configuration through bounded protected descriptors, rejecting symlinks, unsafe ancestors, special files and concurrent replacement before startup. Declare the direct Runtime dependency used by this reader.
33
+
10
34
  ## [0.15.0] - 2026-10-14
11
35
 
12
36
  ### Added
data/README.md CHANGED
@@ -87,3 +87,51 @@ Verification commands:
87
87
  `ace-hitl` owns HITL-specific event semantics and markdown contract.
88
88
 
89
89
  `ace-support-items` remains generic support infrastructure and should not absorb HITL-specific domain behavior.
90
+
91
+ ## Unassigned operator questions
92
+
93
+ `ace-hitl operator-question ID --project lab-config --question "Which option?"`
94
+ creates a nonsecret question under an explicit installed operator grant. An
95
+ optional `--plan` adds question context; `--options A,B` supplies bounded choices.
96
+ The owner binds the exact Captain-visible body including this context and choices. It does not create an Assign execution,
97
+ request a command, grant a decision, or permit OTP/effects. Stable IDs support
98
+ exact retries after lost responses, restart and consumption; changed content,
99
+ principal, project or context is refused. `ace-hitl read ID` returns authenticated
100
+ nonsecret facts; existing `consume ID` retrieves the requester's answer and
101
+ replays its terminal receipt.
102
+
103
+ The public Ruby client exposes `operator_question(id:, project:, question:,
104
+ plan: "", options: [])`, `read`, and the existing pending/deliver/consume/cancel
105
+ methods. The request records the actual kernel peer UID/name. No caller can supply
106
+ requester attribution, assignment, attempt, OTP, effect or publication fields.
107
+ Secret-shaped questions, context, choices and answers are refused.
108
+
109
+ An operator-only service uses the existing owner and Unix protocol:
110
+
111
+ ```sh
112
+ ace-hitl serve --operator-only --group ace-hitl-project \
113
+ --socket /run/ace-hitl-project/lifecycle.sock --store-root /var/lib/ace-hitl-project \
114
+ --grants-path /etc/ace-hitl-project/authorization.yml
115
+ ```
116
+
117
+ The root-controlled grants document must explicitly name `hitl.service_uid`,
118
+ `hitl.operator_uids`, `hitl.transport_uids` and each UID's exact project list in
119
+ `authorization.principals`. Operator grants never interpret an empty project list
120
+ as all projects. Existing trusted-file ownership/traversal checks apply. The
121
+ operator-only binding refuses managed requests and requires no Assign journal.
122
+ Admission and requester grants are checked again on reads, answer transitions,
123
+ retries and terminal replay; transport delivery retains its independent project
124
+ grant. Revocation preserves request and terminal history. Operator questions
125
+ cannot be consumed as native execution or authorized-operation claims.
126
+
127
+ `ping` advertises the supported `request_contexts` (`managed` and/or
128
+ `operator-question`), not a grant to create or execute work. Hermes project
129
+ routes may require `request_contexts: ["operator-question"]` at startup. The
130
+ shared contract uses a closed tagged union of managed execution requests and
131
+ operator questions; neither missing IDs nor an unknown schema select a fallback.
132
+
133
+ One Unix UID with both roles is one OS trust domain. Use a distinct transport
134
+ principal where Captain reply isolation is required. Source tests using one UID
135
+ for both roles do not prove that isolation. Domain adapters must separately
136
+ preserve legacy unresolved host questions and prove the installed group handoff;
137
+ this API never migrates or deletes that legacy history.
data/Rakefile CHANGED
File without changes
File without changes
File without changes
data/docs/usage.md CHANGED
@@ -358,8 +358,11 @@ The JSON proposal requires operation, target (`resource` and optional `artifact_
358
358
  candidate_head, input_digest, context, options, recommendation and prerequisites; rationale
359
359
  is optional. Contents are bounded and non-secret. Input digest uses the canonical service
360
360
  input digest. Candidate head is separate from base head and the evidence journal commit.
361
- Every precisely presented operation is eligible for sixteen-hour silence, including
362
- publishing, deployment and access/privilege expansion. Fixed service scope, current head,
361
+ Only operation classes explicitly delegated in trusted project policy are eligible
362
+ for sixteen-hour silence. Delegation must exist when the revision is created and
363
+ remain current at the deadline; a later grant cannot approve an older revision.
364
+ Without delegation the proposal still accepts an explicit correlated decision.
365
+ Fixed service scope, current head,
363
366
  independently executed review/tests, bound running attempt and operation-specific OTP gates
364
367
  still apply at execution. Proposal authorization never supplies credentials or runs a callback.
365
368
 
@@ -423,6 +426,8 @@ hitl:
423
426
  service_uid: 1200
424
427
  transport_uids: [1201]
425
428
  proposal_uids: [1202]
429
+ silence_operations:
430
+ ace: [merge]
426
431
  authorization:
427
432
  principals:
428
433
  "1201":
@@ -438,6 +443,11 @@ resolve silence. Missing role or project admission refuses the operation,
438
443
  including direct library calls. This is a source configuration contract;
439
444
  installed deployment adoption remains part of gad.8 acceptance.
440
445
 
446
+ `silence_operations` is an exact project-to-operation list, with no wildcards or
447
+ implicit publish/deploy/access grants. It never expands command or credential
448
+ authority. TLS authorities read the same trusted grants document selected by
449
+ `--grants-path`; certificate authentication alone grants no silence decision.
450
+
441
451
  ### Controlled installed proposal verification
442
452
 
443
453
  From an ACE checkout with Ruby, tmux and the complete dependency archives cached locally:
data/handbook/README.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require "ace/support/cli"
3
+ require_relative "lifecycle_command"
4
+ module Ace
5
+ module Hitl
6
+ module CLI
7
+ module Commands
8
+ class OperatorQuestion < Ace::Support::Cli::Command
9
+ include Ace::Support::Cli::Base
10
+ include LifecycleCommand
11
+ desc "Create a nonsecret operator question without an execution or effect grant"
12
+ argument :id, required: true, desc: "Stable question ID; reuse for an exact retry"
13
+ option :project, type: :string, required: true, desc: "Exact installed operator project scope"
14
+ option :question, type: :string, required: true, desc: "Nonsecret question"
15
+ option :plan, type: :string, desc: "Nonsecret question context"
16
+ option :options, type: :array, desc: "Nonsecret choices (comma-separated)"
17
+ def call(id:, **options)
18
+ emit(lifecycle_client.operator_question(id: id, project: options.fetch(:project),
19
+ question: options.fetch(:question), plan: options.fetch(:plan, ""), options: options.fetch(:options, [])))
20
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
21
+ raise_lifecycle_error(e.message)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ require "ace/support/cli"
3
+ require_relative "lifecycle_command"
4
+ module Ace
5
+ module Hitl
6
+ module CLI
7
+ module Commands
8
+ class Read < Ace::Support::Cli::Command
9
+ include Ace::Support::Cli::Base
10
+ include LifecycleCommand
11
+ desc "Read authenticated nonsecret lifecycle facts for one request"
12
+ argument :id, required: true, desc: "Request ID"
13
+ def call(id:, **)
14
+ emit(lifecycle_client.read(id))
15
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
16
+ raise_lifecycle_error(e.message)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -23,6 +23,8 @@ module Ace
23
23
  desc: "Dedicated lifecycle group allowed to connect (required; root and lab-control are refused)"
24
24
  option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
25
25
  option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
26
+ option :operator_only, type: :boolean,
27
+ desc: "Serve explicitly granted nonsecret operator questions without a managed execution authority"
26
28
  option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
27
29
  option :tls_authority, type: :string, desc: "Serve the host/Incus mTLS crossing from this authority config (root:<service-group> 0640; loopback TCP + verified project client identity)"
28
30
 
@@ -30,6 +32,9 @@ module Ace
30
32
  option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
31
33
 
32
34
  def call(**options)
35
+ if options[:tls_authority] && options[:operator_only]
36
+ raise_cli_error("operator questions require a local kernel requester; operator-only TLS is not supported")
37
+ end
33
38
  return serve_tls_authority(options) if options[:tls_authority]
34
39
 
35
40
  group = dedicated_group(options)
@@ -41,7 +46,12 @@ module Ace
41
46
  "fail every client endpoint verification"
42
47
  )
43
48
  end
44
- binding_policy = Providers::Lab.assignment_binding(repo_root: options[:repo_root])
49
+ binding_policy = if options[:operator_only]
50
+ raise_cli_error("operator-only serve cannot select a managed repository") if options[:repo_root]
51
+ Lifecycle::OperatorOnlyBinding.new
52
+ else
53
+ Providers::Lab.assignment_binding(repo_root: options[:repo_root])
54
+ end
45
55
  service = Lifecycle::Service.new(
46
56
  root: store_root(options),
47
57
  binding: binding_policy,
@@ -69,6 +79,7 @@ module Ace
69
79
  binding: binding_policy,
70
80
  project: authority.project_id,
71
81
  credential: authority.credential,
82
+ decision_policy: Providers::Lab.grants_policy(grants_path: grants_path(options)),
72
83
  ssl_context: authority.ssl_context!,
73
84
  listen: authority.listen,
74
85
  port: authority.port,
File without changes
data/lib/ace/hitl/cli.rb CHANGED
@@ -4,6 +4,8 @@ require "ace/support/cli"
4
4
  require_relative "../hitl/version"
5
5
  require_relative "cli/commands/create"
6
6
  require_relative "cli/commands/ask"
7
+ require_relative "cli/commands/operator_question"
8
+ require_relative "cli/commands/read"
7
9
  require_relative "cli/commands/show"
8
10
  require_relative "cli/commands/list"
9
11
  require_relative "cli/commands/update"
@@ -30,6 +32,8 @@ module Ace
30
32
  REGISTERED_COMMANDS = [
31
33
  ["proposal", "Create, inspect, revise or resolve an immutable second-commander proposal"],
32
34
  ["create", "Create HITL event"],
35
+ ["operator-question", "Create a nonsecret question under an installed operator project grant"],
36
+ ["read", "Read authenticated lifecycle request facts"],
33
37
  ["ask", "Ask a human via HITL and forward the request to the Lab"],
34
38
  ["show", "Show HITL event details"],
35
39
  ["list", "List HITL events"],
@@ -58,6 +62,8 @@ module Ace
58
62
  register "create", CLI::Commands::Create
59
63
  register "proposal", CLI::Commands::Proposal
60
64
  register "ask", CLI::Commands::Ask
65
+ register "operator-question", CLI::Commands::OperatorQuestion
66
+ register "read", CLI::Commands::Read
61
67
  register "show", CLI::Commands::Show
62
68
  register "list", CLI::Commands::List
63
69
  register "update", CLI::Commands::Update
@@ -31,6 +31,19 @@ module Ace
31
31
  raise NotImplementedError
32
32
  end
33
33
  end
34
+ # Explicit operator-only authority. Managed requests remain fail closed
35
+ # without creating or consulting a pretend Assign execution.
36
+ class OperatorOnlyBinding < Binding
37
+ def validate_request(**)
38
+ raise BindingError, "operator-only authority does not admit managed requests"
39
+ end
40
+ def reverse_address(**)
41
+ raise BindingError, "operator questions have no native execution address"
42
+ end
43
+ def with_active(**)
44
+ raise BindingError, "operator-only authority does not admit managed requests"
45
+ end
46
+ end
34
47
  end
35
48
  end
36
49
  end
@@ -42,7 +42,7 @@ module Ace
42
42
 
43
43
  def execute(store, op, params)
44
44
  case op
45
- when "ping" then {"pong" => true}
45
+ when "ping" then store.capabilities
46
46
  when "proposal-create" then store.proposal_create(**symbolize(params))
47
47
  when "proposal-show" then store.proposal_show(required(params, "id"), project: required(params, "project"), history_after: params.fetch("history_after", 0))
48
48
  when "proposal-revise" then store.proposal_revise(required(params, "id"), project: required(params, "project"), expected_revision: required(params, "expected_revision"),
@@ -58,6 +58,7 @@ module Ace
58
58
  when "proposal-due" then store.proposal_due(project: required(params, "project"), after: params["after"])
59
59
  when "proposal-history" then store.proposal_history(project: required(params, "project"),
60
60
  query: params.fetch("query", ""), after: params["after"])
61
+ when "operator-question" then store.operator_question(**symbolize(params))
61
62
  when "create" then store.create(**symbolize(params))
62
63
  when "read" then store.read(required(params, "id"))
63
64
  when "deliver" then store.deliver(required(params, "id"), answer_reader(params))
@@ -9,6 +9,10 @@ module Ace
9
9
  # lifecycle frames are exchanged unchanged on both transports.
10
10
  # The includer implements `#request(op, params, deadline:)`.
11
11
  module LifecycleRequests
12
+ def operator_question(**params)
13
+ request("operator-question", params.transform_keys(&:to_s))
14
+ end
15
+
12
16
  def proposal_create(**params)
13
17
  request("proposal-create", params)
14
18
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+ require "ace/hitl/contract/request_envelope"
3
+
4
+ module Ace
5
+ module Hitl
6
+ module Lifecycle
7
+ module OperatorQuestions
8
+ def operator_question(id:, project:, question:, plan: "", options: [])
9
+ unless @identity.uid.is_a?(Integer) && @policy.operator_question?(uid: @identity.uid, project: project)
10
+ raise PermissionError, "operator question requires an installed UID and exact project grant"
11
+ end
12
+ unless project.is_a?(String) && Contract::ManagedEnvelope::TOKEN.match?(project) &&
13
+ question.is_a?(String) && question.valid_encoding? && !question.strip.empty? &&
14
+ question.length <= Store::MAX_PLAN_QUESTION && !question.include?("\0") &&
15
+ plan.is_a?(String) && plan.valid_encoding? && plan.length <= Store::MAX_PLAN_QUESTION &&
16
+ !plan.include?("\0") && options.is_a?(Array) && options.length <= Store::MAX_OPTIONS &&
17
+ options.all? { |item| item.is_a?(String) && item.valid_encoding? && !item.strip.empty? &&
18
+ item.length <= Store::MAX_OPTION && !item.include?("\0") }
19
+ raise StateError, "invalid nonsecret operator question content"
20
+ end
21
+ [question, plan, *options].each { |text| Kinds.check_answer!("question", text) }
22
+ question, plan = question.strip, plan.strip
23
+ options = options.map(&:strip)
24
+ input_digest = Digest::SHA256.hexdigest(JSON.generate([plan, question, options]))
25
+ # The authority binds the actual Captain-visible question, including
26
+ # its optional context/choices. Hermes publishes these exact bytes.
27
+ question += "\n\nContext: #{plan}" unless plan.empty?
28
+ unless options.empty?
29
+ question += "\n\nOptions:\n" + options.each_with_index.map { |item, index| "#{index + 1}. #{item}" }.join("\n")
30
+ end
31
+ request_id = safe_id(id)
32
+ incarnation = SecureRandom.hex(8)
33
+ envelope = Contract::OperatorEnvelope.load({
34
+ "schema" => Contract::OperatorEnvelope::SCHEMA, "context" => "operator-question",
35
+ "request_id" => request_id, "request_incarnation" => incarnation, "project" => project,
36
+ "requester" => @identity.username, "requester_uid" => @identity.uid,
37
+ "correlation_id" => request_id, "kind" => "question",
38
+ "payload_sha256" => Digest::SHA256.hexdigest(question),
39
+ "input_sha256" => input_digest})
40
+ value = {"id" => request_id, "project" => project, "requester" => @identity.username,
41
+ "requester_uid" => @identity.uid, "harness" => "operator", "kind" => "question", "sensitive" => false,
42
+ "plan" => plan, "question" => question, "options" => options, "created_at" => Time.now.to_i,
43
+ "incarnation" => incarnation, "envelope" => envelope}
44
+ with_request_lock(request_id) do
45
+ operator_authority!(value)
46
+ terminal = load_terminal(request_id)
47
+ previous = terminal
48
+ previous ||= load_request(request_id) if request_path(request_id).exist?
49
+ if previous
50
+ requester_gate!(previous)
51
+ old = Contract::RequestEnvelope.from_facts(previous)
52
+ unless old["schema"] == Contract::OperatorEnvelope::SCHEMA &&
53
+ old.except("request_incarnation") == envelope.except("request_incarnation")
54
+ raise StateError, "operator question retry changed immutable content or context"
55
+ end
56
+ initialize_projection_locked!(previous) unless terminal
57
+ return {"id" => request_id, "requested" => true, "replay" => true, "envelope" => old}
58
+ end
59
+ persist_request_locked!(value)
60
+ end
61
+ rescue Contract::InvalidEnvelope => e
62
+ raise StateError, e.message
63
+ end
64
+
65
+ private
66
+
67
+ def operator_request?(value)
68
+ value.dig("envelope", "schema") == Contract::OperatorEnvelope::SCHEMA
69
+ end
70
+
71
+ def operator_authority!(value)
72
+ envelope = Contract::RequestEnvelope.from_facts(value)
73
+ unless envelope["schema"] == Contract::OperatorEnvelope::SCHEMA &&
74
+ @policy.operator_question?(uid: envelope.fetch("requester_uid"), project: envelope.fetch("project"))
75
+ raise PermissionError, "operator requester project grant is unavailable or revoked"
76
+ end
77
+ envelope
78
+ rescue Contract::InvalidEnvelope => e
79
+ raise StateError, e.message
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -102,9 +102,21 @@ module Ace
102
102
  # the contract. The default denies everything — direct-library use
103
103
  # gets requester-only semantics and no transport authority.
104
104
  class AccessPolicy
105
+ def operator_question?(uid:, project:)
106
+ false
107
+ end
108
+
105
109
  def proposal?(_peer, project:)
106
110
  false
107
111
  end
112
+
113
+ def proposal_silence?(caller_uid:, requester:, project:, operation:)
114
+ false
115
+ end
116
+
117
+ def silence_operation?(project:, operation:)
118
+ false
119
+ end
108
120
  # @return [Boolean] true when the peer may deliver answers and
109
121
  # observe pending/states for the given project
110
122
  def transport?(_peer, project: nil)
@@ -129,6 +141,17 @@ module Ace
129
141
  @document = document
130
142
  end
131
143
 
144
+ # Operator questions have exact installed project scope, never the
145
+ # historical empty-list transport wildcard. UID is taken from the kernel
146
+ # peer on creation and retained in the protected request on transitions.
147
+ def operator_question?(uid:, project:)
148
+ current = document
149
+ uids = hitl_facts(current)["operator_uids"]
150
+ projects = authorized_projects(uid, current)
151
+ uid.is_a?(Integer) && uids.is_a?(Array) && uids.include?(uid) &&
152
+ projects.is_a?(Array) && projects.include?(project)
153
+ end
154
+
132
155
  def transport?(peer, project: nil)
133
156
  current = document
134
157
  facts = hitl_facts(current)
@@ -151,13 +174,36 @@ module Ace
151
174
  # an ordinary requester or transport grant.
152
175
  def proposal?(peer, project:)
153
176
  current = document
177
+ proposal_principal?(peer.uid, project, current)
178
+ end
179
+
180
+ # A proposer grant allows asking. Only this separate, exact project
181
+ # operation list delegates a decision after the acknowledged window.
182
+ def proposal_silence?(caller_uid:, requester:, project:, operation:)
183
+ current = document
184
+ proposal_principal?(caller_uid, project, current) &&
185
+ delegated_operation?(project, operation, current)
186
+ end
187
+
188
+ def silence_operation?(project:, operation:)
189
+ delegated_operation?(project, operation, document)
190
+ end
191
+
192
+ private
193
+
194
+ def proposal_principal?(uid, project, current)
154
195
  uids = hitl_facts(current)["proposal_uids"]
155
- projects = authorized_projects(peer.uid, current)
156
- uids.is_a?(Array) && uids.include?(peer.uid) && projects &&
196
+ projects = authorized_projects(uid, current)
197
+ uid.is_a?(Integer) && uids.is_a?(Array) && uids.include?(uid) && projects &&
157
198
  (projects.empty? || projects.include?(project.to_s))
158
199
  end
159
200
 
160
- private
201
+ def delegated_operation?(project, operation, current)
202
+ delegations = hitl_facts(current)["silence_operations"]
203
+ operations = delegations.is_a?(Hash) && delegations[project]
204
+ operations.is_a?(Array) && operation.is_a?(String) &&
205
+ operation.match?(/\A[a-z][a-z0-9-]{0,63}\z/) && operations.include?(operation)
206
+ end
161
207
 
162
208
  def document
163
209
  @document || TrustedFile.read_yaml(@grants_path) || {}
@@ -54,6 +54,7 @@ module Ace
54
54
  claims = proposal_journal(project).proposal_claims(record["revision_id"])
55
55
  claim = claims.last
56
56
  outcome = case claim && claim["state"]
57
+ when "pending" then "queued"
57
58
  when "accepted" then "executing"
58
59
  when "succeeded" then "succeeded"
59
60
  when "failed", "failed-settled", "rejected" then "failed"
@@ -88,6 +89,7 @@ module Ace
88
89
  record = proposal_for_request!(request, project: project)
89
90
  require_transport!("proposal reconciliation", {"project" => record["project_id"]})
90
91
  proposal_transition(record) do |current|
92
+ next current unless silence_delegated?(current)
91
93
  Ace::Hitl::Proposals::Policy.resolve(current, checkpoint: checkpoint, now: proposal_now)
92
94
  end
93
95
  end
@@ -136,6 +138,11 @@ module Ace
136
138
 
137
139
  private
138
140
 
141
+ def silence_delegated?(record)
142
+ @policy.proposal_silence?(caller_uid: record["caller_uid"], requester: record["requester"],
143
+ project: record["project_id"], operation: record["operation"])
144
+ end
145
+
139
146
  def require_proposer!(project)
140
147
  unless @policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: project)
141
148
  raise PermissionError, "proposal requires the installed second-commander decision principal"
@@ -189,6 +196,8 @@ module Ace
189
196
  def proposal_transition(record, &policy)
190
197
  proposal_journal(record.fetch("project_id")).change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
191
198
  attempt_id: record["attempt_id"]) do |current|
199
+ # Selection may change while waiting for the canonical ref lock.
200
+ proposal_journal(record.fetch("project_id"))
192
201
  # A reply to an older revision cannot modify the new decision.
193
202
  next current unless current["request_id"] == record["request_id"]
194
203
  policy.call(current)
@@ -203,6 +212,7 @@ module Ace
203
212
  replay = nil
204
213
  record = @binding.with_active(assignment: assignment, attempt: attempt, project: project, requester: @identity.username) do
205
214
  proposal_journal(project).change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
215
+ proposal_journal(project)
206
216
  if operation_id
207
217
  replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
208
218
  next previous if replay
@@ -228,6 +238,8 @@ module Ace
228
238
  "attempt_id" => attempt, "project_id" => project, "requester" => @identity.username,
229
239
  "caller_uid" => @identity.uid, "content_digest" => Digest::SHA256.hexdigest(JSON.generate(content)),
230
240
  "state" => "awaiting-delivery", "created_at" => proposal_now,
241
+ "silence_delegated" => @policy.proposal_silence?(caller_uid: @identity.uid,
242
+ requester: @identity.username, project: project, operation: content.fetch("operation")),
231
243
  "authorization" => request, "lifecycle_request" => projection).tap do |value|
232
244
  if operation_id
233
245
  value["revision_operation_id"] = operation_id
@@ -14,7 +14,7 @@ module Ace
14
14
  VERSION = 1
15
15
  MAX_FRAME_BYTES = 64 * 1024
16
16
  DEFAULT_DEADLINE_SECONDS = 10.0
17
- OPERATIONS = %w[create read deliver consume cancel pending states ping
17
+ OPERATIONS = %w[operator-question create read deliver consume cancel pending states ping
18
18
  proposal-create proposal-show proposal-revise proposal-ack proposal-reply proposal-reconcile proposal-due proposal-history proposal-wake].freeze
19
19
 
20
20
  # Wire failures map to the transport error; classified lifecycle
@@ -186,10 +186,12 @@ module Ace
186
186
  # the service identity or root — otherwise anyone who can replace
187
187
  # the socket can impersonate the service.
188
188
  def verify_control_group!
189
- group = Etc.getgrnam(@group)
190
- if group.gid.zero? || BROAD_CONTROL_GROUPS.include?(group.name)
189
+ # Named broad groups are forbidden even on a host where that
190
+ # deployment group has not been created yet.
191
+ group = Etc.getgrnam(@group) unless BROAD_CONTROL_GROUPS.include?(@group)
192
+ if group.nil? || group.gid.zero? || BROAD_CONTROL_GROUPS.include?(group.name)
191
193
  raise TransportError,
192
- "HITL boundary control group is too broad: #{group.name} " \
194
+ "HITL boundary control group is too broad: #{@group} " \
193
195
  "(configure a dedicated lifecycle group; root and #{Store::DEFAULT_GROUP} are refused)"
194
196
  end
195
197
 
@@ -11,6 +11,7 @@ require_relative "policy"
11
11
  require_relative "atomic_json"
12
12
  require_relative "kinds"
13
13
  require_relative "binding"
14
+ require_relative "operator_questions"
14
15
  require_relative "effects"
15
16
  require_relative "otp_vault"
16
17
  require_relative "protocol"
@@ -37,6 +38,7 @@ module Ace
37
38
  # with_active scope (the assignment exclusion), so stale, ended
38
39
  # or replaced attempts cannot acquire new authority.
39
40
  class Store
41
+ include OperatorQuestions
40
42
  include Proposals
41
43
  MAX_ANSWER = 4096
42
44
  # IO#read(limit) is byte-oriented, so the character limit is
@@ -97,6 +99,12 @@ module Ace
97
99
  @group = group
98
100
  end
99
101
 
102
+ def capabilities
103
+ contexts = ["operator-question"]
104
+ contexts.unshift("managed") unless @binding.is_a?(OperatorOnlyBinding)
105
+ {"pong" => true, "request_contexts" => contexts}
106
+ end
107
+
100
108
  # ---- requester side ------------------------------------------------
101
109
 
102
110
  # Create one request bound to the exact live attempt of the
@@ -221,7 +229,11 @@ module Ace
221
229
 
222
230
  def persist_request_locked!(value)
223
231
  request_id = value.fetch("id")
224
- attempt = value.fetch("attempt")
232
+ attempt = value["attempt"]
233
+ terminal = load_terminal(request_id)
234
+ if terminal && operator_request?(terminal)
235
+ raise StateError, "operator question identity is terminal and cannot acquire another context"
236
+ end
225
237
  request_path = requests_dir.join("#{request_id}.json")
226
238
  raise StateError, "HITL request already exists" if request_path.exist?
227
239
 
@@ -251,11 +263,16 @@ module Ace
251
263
  # transport). Never carries answer content.
252
264
  def read(id)
253
265
  request_id = safe_id(id)
266
+ terminal = load_terminal(request_id)
267
+ if terminal && operator_request?(terminal)
268
+ gate_read_access!(terminal)
269
+ return terminal.slice("id", "project", "requester", "requester_uid", "sensitive", "envelope", "state", "kind")
270
+ end
254
271
  unless request_path(request_id).exist?
255
272
  terminal = load_terminal(request_id)
256
273
  if terminal
257
274
  gate_read_access!(terminal)
258
- return terminal.slice("id", "assignment", "attempt", "project", "requester", "sensitive", "envelope", "state", "native_delivery", "effect_receipt_ref", "publication_binding", "publication_requester_peer", "otp")
275
+ return terminal.slice("id", "assignment", "attempt", "project", "requester", "requester_uid", "sensitive", "envelope", "state", "native_delivery", "effect_receipt_ref", "publication_binding", "publication_requester_peer", "otp")
259
276
  .merge("kind" => terminal.dig("envelope", "kind"))
260
277
  end
261
278
  end
@@ -276,6 +293,7 @@ module Ace
276
293
  "publication_binding" => value["publication_binding"],
277
294
  "publication_requester_peer" => value["publication_requester_peer"],
278
295
  "requester" => value["requester"],
296
+ "requester_uid" => value["requester_uid"],
279
297
  "envelope" => value["envelope"],
280
298
  "state" => public_state(request_id, value),
281
299
  "effect_receipt_ref" => effect_receipt_ref(value),
@@ -298,6 +316,8 @@ module Ace
298
316
  request_id = safe_id(id)
299
317
  replay = replay_terminal!(request_id, "consumed")
300
318
  if replay
319
+ terminal = load_terminal(request_id)
320
+ verify_operation!(terminal, operation) if operator_request?(terminal)
301
321
  if native_delivery
302
322
  terminal = load_terminal(request_id)
303
323
  raise StateError, "request was consumed locally; it has no native delivery claim" unless terminal["native_delivery"]
@@ -463,7 +483,7 @@ module Ace
463
483
  verify_otp_deadline!(value)
464
484
  write_answer(value, answer)
465
485
  update_public(value, "answer-delivered")
466
- unless value["sensitive"] == true
486
+ unless value["sensitive"] == true || operator_request?(value)
467
487
  requester_uid, requester_gid = @identity.user_ids(value["requester"])
468
488
  Effects.run(self, value, answer,
469
489
  requester_uid: requester_uid, requester_gid: requester_gid, identity: @identity)
@@ -509,6 +529,8 @@ module Ace
509
529
  ids = (requests_dir.glob("*.json") + public_dir.glob("*.json")).map { |path| path.basename(".json").to_s }.uniq.sort
510
530
  ids.each do |id|
511
531
  next if after && id <= after
532
+ terminal = load_terminal(id)
533
+ next if terminal && operator_request?(terminal)
512
534
  value = AtomicJson.read(requests_dir.join("#{id}.json"))
513
535
  active = value.is_a?(Hash)
514
536
  value = AtomicJson.read(public_dir.join("#{id}.json")) unless active
@@ -521,7 +543,7 @@ module Ace
521
543
  end
522
544
  if active
523
545
  value = value.slice("id", "assignment", "attempt", "project", "harness", "kind", "sensitive", "plan", "question",
524
- "options", "ace_hitl_id", "requester", "created_at", "otp", "envelope")
546
+ "options", "ace_hitl_id", "requester", "requester_uid", "created_at", "otp", "envelope")
525
547
  .merge("state" => public_state(value["id"], value), "has_effect" => !value["effect"].nil?)
526
548
  end
527
549
  yield value
@@ -533,7 +555,16 @@ module Ace
533
555
  def states
534
556
  require_transport!("states")
535
557
  public_dir.glob("*.json").sort.filter_map do |path|
536
- AtomicJson.read(path)
558
+ value = AtomicJson.read(path)
559
+ next unless value.is_a?(Hash)
560
+ if operator_request?(value)
561
+ begin
562
+ require_transport!("states", value)
563
+ rescue PermissionError
564
+ next
565
+ end
566
+ end
567
+ value
537
568
  end
538
569
  end
539
570
 
@@ -619,6 +650,13 @@ module Ace
619
650
  "envelope" => value["envelope"],
620
651
  "updated_at" => Time.now.to_i
621
652
  }
653
+ if operator_request?(value)
654
+ public.delete("assignment")
655
+ public.delete("attempt")
656
+ public["requester"] = value.fetch("requester")
657
+ public["requester_uid"] = value.fetch("requester_uid")
658
+ public["sensitive"] = false
659
+ end
622
660
  public["incarnation"] = value["incarnation"] if value["incarnation"]
623
661
  public["native_delivery"] = true if value["native_delivery"]
624
662
  public["otp"] = public_otp_projection(value["otp"]) if value["otp"]
@@ -771,6 +809,7 @@ module Ace
771
809
  end
772
810
 
773
811
  def verify_native_consumer!(value)
812
+ raise StateError, "operator question has no native execution claim" if operator_request?(value)
774
813
  raise StateError, "OTP requires protected local consume" if value["sensitive"] == true
775
814
  reverse = @binding.reverse_address(assignment: value["assignment"], project: value["project"], attempt: value["attempt"],
776
815
  caller_pid: @identity.respond_to?(:pid) ? @identity.pid : nil)
@@ -831,6 +870,12 @@ module Ace
831
870
  # ---- role gates ------------------------------------------------------
832
871
 
833
872
  def requester_gate!(value)
873
+ if operator_request?(value)
874
+ operator_authority!(value)
875
+ unless @identity.uid == value.fetch("requester_uid")
876
+ raise PermissionError, "only the kernel requester UID can consume this question"
877
+ end
878
+ end
834
879
  unless @identity.username == value["requester"].to_s
835
880
  raise PermissionError, "only the requesting role can consume this answer"
836
881
  end
@@ -849,6 +894,7 @@ module Ace
849
894
  end
850
895
 
851
896
  def require_transport!(operation, value = nil)
897
+ operator_authority!(value) if value && operator_request?(value)
852
898
  return true if @policy.transport?(@identity, project: value && value["project"])
853
899
 
854
900
  raise PermissionError, "#{operation} requires the configured transport identity"
@@ -858,6 +904,9 @@ module Ace
858
904
  # challenge's authorized operation; anything else fails closed
859
905
  # without touching the secret.
860
906
  def verify_operation!(value, operation)
907
+ if operator_request?(value) && operation
908
+ raise StateError, "operator question has no authorized execution operation"
909
+ end
861
910
  return unless value["otp"]
862
911
 
863
912
  presented = operation.to_s
@@ -886,7 +935,7 @@ module Ace
886
935
  raise PermissionError, "only the requesting role can #{operation == "cancelled" ? "cancel" : "consume"} this request"
887
936
  end
888
937
 
889
- requester_gate!(terminal) if terminal["publication_binding"]
938
+ requester_gate!(terminal) if terminal["publication_binding"] || operator_request?(terminal)
890
939
  case terminal["state"]
891
940
  when "cancelled"
892
941
  return replayed_cancel(request_id, terminal) if operation == "cancelled"
@@ -947,6 +996,10 @@ module Ace
947
996
  "effect_receipt_ref" => effect_receipt_ref(value),
948
997
  "at" => Time.now.to_i
949
998
  }
999
+ if operator_request?(value)
1000
+ record["requester_uid"] = value.fetch("requester_uid")
1001
+ record["kind"] = "question"
1002
+ end
950
1003
  %w[publication_binding publication_requester_peer otp].each do |key|
951
1004
  record[key] = value[key] if value.key?(key)
952
1005
  end
@@ -968,7 +1021,13 @@ module Ace
968
1021
  # the request stays pending and the classified error propagates
969
1022
  # for retry (review 8x32r9b0).
970
1023
  def with_live_authority!(value, requester:)
971
- if value["publication_binding"]
1024
+ if operator_request?(value)
1025
+ operator_authority!(value)
1026
+ if @identity.uid != value.fetch("requester_uid")
1027
+ require_transport!("operator transition", value)
1028
+ end
1029
+ yield
1030
+ elsif value["publication_binding"]
972
1031
  unless @binding.respond_to?(:with_publication!)
973
1032
  raise BindingError, "publication HITL source binding is unavailable"
974
1033
  end
@@ -10,18 +10,33 @@ module Ace
10
10
  # even when issued by the same CA, and no operation against any
11
11
  # other project is reachable through this authority.
12
12
  class TlsAccessPolicy < AccessPolicy
13
- def initialize(project:)
13
+ def initialize(project:, decision_policy: AccessPolicy.new)
14
14
  @project = project.to_s
15
+ @decision_policy = decision_policy
15
16
  end
16
17
 
17
18
  def transport?(peer, project: nil)
18
19
  bound?(peer) && (project.nil? || project.to_s == @project)
19
20
  end
20
21
 
22
+ def operator_question?(uid:, project:)
23
+ project == @project && @decision_policy.operator_question?(uid: uid, project: project)
24
+ end
25
+
21
26
  def proposal?(peer, project:)
22
27
  transport?(peer, project: project)
23
28
  end
24
29
 
30
+ def proposal_silence?(caller_uid:, requester:, project:, operation:)
31
+ return false unless project == @project
32
+ # The transport peer is authenticated separately. A local proposal
33
+ # retains its original kernel principal when Hermes reconciles it.
34
+ return @decision_policy.proposal_silence?(caller_uid: caller_uid, requester: requester,
35
+ project: project, operation: operation) unless caller_uid.nil?
36
+ requester == "urn:ace:hitl:#{@project}:hermes-client" &&
37
+ @decision_policy.silence_operation?(project: project, operation: operation)
38
+ end
39
+
25
40
  # A TLS authority has no Unix service uid; client endpoint
26
41
  # verification is certificate-based.
27
42
  def service_uid
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "ipaddr"
4
4
  require "json"
5
+ require "ace/runtime/molecules/protected_artifact_set"
5
6
  require_relative "tls_credentials"
6
7
  require_relative "credential_path_policy"
7
8
  require_relative "tls_material"
@@ -22,16 +23,24 @@ module Ace
22
23
  SCHEMA = "ace.hitl.tls.authority/v1"
23
24
  FIELDS = %w[schema project_id listen port service_user service_group
24
25
  tls_server_credential].freeze
26
+ MAX_BYTES = 65_536
25
27
 
26
28
  attr_reader :project_id, :listen, :port, :service_user, :service_group,
27
29
  :credential, :path
28
30
 
29
31
  def self.load(path)
30
- config = JSON.parse(File.read(path))
31
- authority = new(config, path: path)
32
- CredentialPathPolicy.verify_runtime_file!(path, authority.service_group)
33
- authority
34
- rescue JSON::ParserError, SystemCallError, IOError
32
+ reader = Ace::Runtime::Molecules::ProtectedArtifactSet.new(file_limit: MAX_BYTES, total_limit: MAX_BYTES)
33
+ reader.with do |held|
34
+ bytes, = held.read_path!(path)
35
+ config = JSON.parse(bytes, create_additions: false, allow_duplicate_key: false, max_nesting: 16)
36
+ authority = new(config, path: path)
37
+ CredentialPathPolicy.verify_runtime_file!(path, service_group: authority.service_group)
38
+ # Exact mode/group validation and parsing must refer to the same
39
+ # bounded protected inode, including every retained ancestor.
40
+ held.verify_unchanged!
41
+ authority
42
+ end
43
+ rescue JSON::ParserError, SystemCallError, IOError, Ace::Runtime::Error
35
44
  raise TransportError, "the HITL TLS authority configuration is unavailable or malformed: #{path}"
36
45
  end
37
46
 
@@ -35,7 +35,7 @@ module Ace
35
35
 
36
36
  def initialize(root:, binding:, project:, credential:, ssl_context:,
37
37
  listen: "127.0.0.1", port:, group: Store::DEFAULT_GROUP, vault: nil, logger: nil,
38
- idle_seconds: DEFAULT_IDLE_SECONDS, proposal_clock: -> { Time.now.utc })
38
+ idle_seconds: DEFAULT_IDLE_SECONDS, proposal_clock: -> { Time.now.utc }, decision_policy: AccessPolicy.new)
39
39
  @root = root
40
40
  @binding = binding
41
41
  @project = TlsCredentials.project_id(project)
@@ -48,6 +48,7 @@ module Ace
48
48
  @logger = logger
49
49
  @idle_seconds = idle_seconds
50
50
  @proposal_clock = proposal_clock
51
+ @policy = TlsAccessPolicy.new(project: @project, decision_policy: decision_policy)
51
52
  @max_connections = MAX_CONNECTIONS
52
53
  @connections = 0
53
54
  @handler_threads = []
@@ -223,7 +224,7 @@ module Ace
223
224
  Store.new(
224
225
  root: @root,
225
226
  binding: @binding,
226
- policy: TlsAccessPolicy.new(project: @project),
227
+ policy: @policy,
227
228
  identity: peer,
228
229
  vault: @vault,
229
230
  proposal_clock: @proposal_clock, stopping: -> { @stopping }, group: @group
@@ -233,7 +234,7 @@ module Ace
233
234
  end
234
235
 
235
236
  def root_store
236
- Store.new(root: @root, binding: @binding, policy: TlsAccessPolicy.new(project: @project), group: @group)
237
+ Store.new(root: @root, binding: @binding, policy: @policy, group: @group)
237
238
  end
238
239
  end
239
240
  end
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -108,8 +108,10 @@ module Ace
108
108
 
109
109
  def resolve(record, checkpoint:, now:)
110
110
  return record unless record["state"] == "awaiting-decision"
111
+ return record unless record["silence_delegated"] == true
111
112
  return record if timestamp(now) < timestamp(record.fetch("deadline"))
112
113
  valid = checkpoint["schema"] == "ace.hitl.hermes.ingress-checkpoint/v1" &&
114
+ checkpoint["project"].is_a?(String) && checkpoint["project"] == record["project_id"] &&
113
115
  checkpoint["request"] == record["request_id"] && checkpoint["revision"] == record["revision_id"] &&
114
116
  checkpoint["healthy"] == true && checkpoint["drained"] == true &&
115
117
  checkpoint.dig("checkpoint", "through") == record["deadline"] &&
@@ -16,9 +16,14 @@ module Ace
16
16
  # authority. Every doubt maps to the classified BindingError —
17
17
  # unknown identity/authority is an error, never permission.
18
18
  class AssignmentBinding < Lifecycle::Binding
19
- def initialize(coordinator: nil, repo_root: nil)
19
+ def initialize(coordinator: nil, repo_root: nil, project: nil, owner_guard: nil)
20
+ unless (project.nil? && owner_guard.nil?) ||
21
+ (project.is_a?(String) && !project.empty? && owner_guard.respond_to?(:call))
22
+ raise ArgumentError, "installed binding needs both a fixed project and its owner guard"
23
+ end
20
24
  @coordinator = coordinator
21
25
  @repo_root = repo_root
26
+ @project, @owner_guard = project, owner_guard
22
27
  end
23
28
 
24
29
  def validate_request(assignment:, attempt:, project:, requester:, caller_pid: nil)
@@ -30,6 +35,7 @@ module Ace
30
35
  # Read-only owner verification can run inside an already-held
31
36
  # assignment exclusion without reacquiring that exclusion.
32
37
  def reverse_address(assignment:, attempt:, project:, caller_pid:)
38
+ verify_selection!(project)
33
39
  unless caller_pid.is_a?(Integer) && caller_pid.positive?
34
40
  raise Lifecycle::BindingError, "kernel requester process identity is unavailable"
35
41
  end
@@ -49,15 +55,26 @@ module Ace
49
55
  unless project.is_a?(String) && !project.empty?
50
56
  raise Lifecycle::BindingError, "proposal requires an explicit project"
51
57
  end
58
+ verify_selection!(project)
52
59
  coordinator.send(:journal_for)
53
60
  end
54
61
 
55
62
  def proposal_projects
63
+ if @project
64
+ verify_selection!(@project)
65
+ return [@project]
66
+ end
56
67
  coordinator.send(:journal_for).proposals.map { |record| record.fetch("project_id") }.uniq.sort
57
68
  end
58
69
 
59
70
  private
60
71
 
72
+ def verify_selection!(project)
73
+ return unless @project
74
+ raise Lifecycle::BindingError, "project differs from installed HITL owner" unless project == @project
75
+ @owner_guard.call
76
+ end
77
+
61
78
  def with_verified(assignment_id, attempt_id, project_id, requester)
62
79
  # ONLY the authority verification is error-mapped: the
63
80
  # caller's transition (answer hand-off, commits, effects)
@@ -66,12 +83,14 @@ module Ace
66
83
  # failure (review 8x327bud). The exclusion still spans the
67
84
  # whole transition, so the phase flag separates the two.
68
85
  verifying = true
86
+ verify_selection!(project_id)
69
87
  coordinator.with_verified_attempt(
70
88
  assignment_id: assignment_id,
71
89
  attempt_id: attempt_id,
72
90
  project_id: project_id,
73
91
  requester: requester
74
92
  ) do |attempt|
93
+ verify_selection!(project_id)
75
94
  verifying = false
76
95
  yield attempt
77
96
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Hitl
5
- VERSION = "0.15.0"
5
+ VERSION = "0.17.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-hitl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-14 00:00:00.000000000 Z
10
+ date: 2026-10-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-hitl-contract
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.2.1
18
+ version: 0.3.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.2.1
25
+ version: 0.3.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ace-support-core
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -113,28 +113,42 @@ dependencies:
113
113
  requirements:
114
114
  - - "~>"
115
115
  - !ruby/object:Gem::Version
116
- version: '0.67'
116
+ version: '0.70'
117
117
  type: :runtime
118
118
  prerelease: false
119
119
  version_requirements: !ruby/object:Gem::Requirement
120
120
  requirements:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
- version: '0.67'
123
+ version: '0.70'
124
124
  - !ruby/object:Gem::Dependency
125
125
  name: ace-herdr
126
126
  requirement: !ruby/object:Gem::Requirement
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: 0.5.1
130
+ version: '0.6'
131
131
  type: :runtime
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
- version: 0.5.1
137
+ version: '0.6'
138
+ - !ruby/object:Gem::Dependency
139
+ name: ace-runtime
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0.3'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0.3'
138
152
  description: Provides the dedicated ace-hitl package surface for human in the loop
139
153
  (HITL) semantics and CLI workflows.
140
154
  email:
@@ -170,11 +184,13 @@ files:
170
184
  - lib/ace/hitl/cli/commands/duty.rb
171
185
  - lib/ace/hitl/cli/commands/lifecycle_command.rb
172
186
  - lib/ace/hitl/cli/commands/list.rb
187
+ - lib/ace/hitl/cli/commands/operator_question.rb
173
188
  - lib/ace/hitl/cli/commands/overseer_ack.rb
174
189
  - lib/ace/hitl/cli/commands/overseer_pending.rb
175
190
  - lib/ace/hitl/cli/commands/overseer_send.rb
176
191
  - lib/ace/hitl/cli/commands/pending.rb
177
192
  - lib/ace/hitl/cli/commands/proposal.rb
193
+ - lib/ace/hitl/cli/commands/read.rb
178
194
  - lib/ace/hitl/cli/commands/serve.rb
179
195
  - lib/ace/hitl/cli/commands/show.rb
180
196
  - lib/ace/hitl/cli/commands/states.rb
@@ -193,6 +209,7 @@ files:
193
209
  - lib/ace/hitl/lifecycle/identity.rb
194
210
  - lib/ace/hitl/lifecycle/kinds.rb
195
211
  - lib/ace/hitl/lifecycle/lifecycle_requests.rb
212
+ - lib/ace/hitl/lifecycle/operator_questions.rb
196
213
  - lib/ace/hitl/lifecycle/otp_vault.rb
197
214
  - lib/ace/hitl/lifecycle/overseer.rb
198
215
  - lib/ace/hitl/lifecycle/peer.rb