ace-hitl-hermes 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4748399831bb81daa3c725bed83373df44675fa005410f22e88632073abe839a
4
+ data.tar.gz: 63d3164afdfb14a1ff5072f553bc9a5c5f9e3a092ebb4088ab0822bf8522fddc
5
+ SHA512:
6
+ metadata.gz: e6781892f412b2c1ba7798f2725a3c454f7c72844de9c81a68224add320e636539863fba1201bdb15d2753f878f5b932738b0fab1e34eaab8fc3e76a9a342cb7
7
+ data.tar.gz: '07811266c2c80bf1277575bf6e295b522133ea4f06169cdface8d761b4c2ce9216ae65f22b127b8e3b552df58adc57a7e30be995cc5c82091237838a353e21d9'
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to `ace-hitl-hermes` will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-27
11
+
12
+ ### Added
13
+ - **Folder-as-interface plugin + registry (spec 8wm.t.vs1)**: new package owning the channel registry, notification texts, and message formats for the shared lab <-> hermes folder. Folder contract `ace.hitl.hermes.folder/v1` + message schema `ace.hitl.hermes.message/v1` (JSON Schema asset shipped at `lib/ace/hitl/hermes/schemas/message.v1.schema.json`): a message is a file named `<id>.json`, the address is `<machine>/<folder>/<id>`, the Captain's answer file carries `answer` / `sender` / `received_at`. Fail-closed filename validation, UTF-8 + 64 KiB bounds, id/filename cross-check, ownership/permissions (files 0640, tmp 0600, quarantine 0750; running as root is refused), atomic same-directory tmp+rename writes, quarantine for invalid files with reason sidecars, bounded collision and undeleted-file retry policies, and the write -> validate -> deliver -> ACK deletion state machine. Canonical shared-contract file location/ownership is settled by A4 (8wm.t.vs2); the lab-config consumer side is 8wm.t.vp9 (separate repo).
14
+
15
+ ### Fixed
16
+ - **PR#336 review hardening (codex astra high)**: `publish` validates the exact serialized envelope bytes through the consumer decode gate (UTF-8 + 64 KiB) before any disk write, so an oversized envelope fails at the producer instead of being terminally quarantined by its own poll; `poll` opens message files with `O_NOFOLLOW` and requires a regular file on the descriptor, quarantining top-level symlinks instead of following them (no re-delivery of quarantined content, no importing foreign content through links); the shipped JSON Schema now rejects the envelopes the Ruby exact-field and body-strip checks reject (mutually exclusive kind fields, nonblank bodies).
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # ace-hitl-hermes
2
+
3
+ Folder-as-interface HITL transport plugin for the hermes relay
4
+ (spec `8wm.t.vs1`). The shared folder between the lab and hermes **is**
5
+ the transport:
6
+
7
+ - a **message** is a file `<id>.json` in the channel folder;
8
+ - the **address** is `<machine>/<folder>/<id>`;
9
+ - **delivery** is push — the consuming side picks the validated file up
10
+ and takes it to its target (labd pushes answers to the asking agent,
11
+ hermes surfaces questions to the Captain);
12
+ - **ACK** is the deletion of the file after delivery.
13
+
14
+ The Captain's answer is a file `<folder>/<id>.json` with the fields
15
+ `answer` / `sender` / `received_at`, written atomically (same-directory
16
+ tmp file + rename) without root.
17
+
18
+ ## Contract
19
+
20
+ - Folder contract: `ace.hitl.hermes.folder/v1` (spec `8wm.t.vs1`).
21
+ - Message schema: `ace.hitl.hermes.message/v1`; the machine-readable
22
+ JSON Schema ships at
23
+ `lib/ace/hitl/hermes/schemas/message.v1.schema.json`.
24
+ - State machine: write -> validate -> deliver -> ACK deletion, with
25
+ bounded retries (collision at write time, undeleted after delivery)
26
+ and quarantine as the terminal branch for invalid files.
27
+ - Canonical shared-contract file location/ownership is settled by A4
28
+ (`8wm.t.vs2`); the lab-config consumer side is `8wm.t.vp9` (separate
29
+ repository, intentional cross-repo reference).
30
+
31
+ ## Surface
32
+
33
+ | Piece | Responsibility |
34
+ |---|---|
35
+ | `Hermes::Molecules::HermesChannels::Registry` | channel registry + `<machine>/<folder>/<id>` addressing, fail closed |
36
+ | `Hermes::Molecules::HermesFormats` | decode gate: UTF-8, 64 KiB bound, JSON object, known schema only |
37
+ | `Hermes::Molecules::HermesMessage` | typed envelope, fail-closed validation, canonical JSON |
38
+ | `Hermes::Molecules::HermesAtomicWriter` | same-directory tmp + rename, no root (euid 0 refused) |
39
+ | `Hermes::Molecules::HermesQuarantine` | quarantine moves + reason sidecars |
40
+ | `Hermes::Molecules::HermesRetryPolicy` | collision + undeleted-file retry decisions |
41
+ | `Hermes::Molecules::HermesNotifications` | deterministic single-line notification texts |
42
+ | `Hermes::Organisms::HermesBox` | folder interface per channel: `publish`, `poll`, `ack`, `age`, `identical?` |
43
+
44
+ ## Usage
45
+
46
+ ```ruby
47
+ require "ace/hitl/hermes"
48
+
49
+ registry = Ace::Hitl::Hermes::Molecules::HermesChannels::Registry.new
50
+ registry.register("inbox", machine: "lab01", folder: "/run/lab/hermes/inbox")
51
+ registry.default = "inbox"
52
+
53
+ lines = []
54
+ box = Ace::Hitl::Hermes::Organisms::HermesBox.new(
55
+ channel: "inbox", registry: registry,
56
+ notifier: ->(line) { lines << line }
57
+ )
58
+
59
+ # Write the Captain's answer (atomic, no root, collision-safe):
60
+ message = box.publish(
61
+ kind: :answer, id: "m-123", body: "Ship it.",
62
+ sender: "captain", timestamp: "2026-09-24T10:00:00Z"
63
+ )
64
+
65
+ # Consume (fail closed; invalid files are quarantined, never delivered):
66
+ result = box.poll
67
+ result.messages.each do |msg|
68
+ deliver(msg) # push to the target
69
+ box.ack(msg.id) # deletion IS the ACK
70
+ end
71
+ ```
72
+
73
+ ## Development
74
+
75
+ ```sh
76
+ ace-test atoms # fast unit tests
77
+ ace-test # the whole package suite
78
+ ```
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << "test"
7
+ t.pattern = "test/**/*_test.rb"
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Hermes
6
+ module Atoms
7
+ # Token validation for every value that becomes part of a file
8
+ # name or an address (spec 8wm.t.vs1 §2): ids, senders, channel
9
+ # names, machine names. Path-traversal safe: no separators, no
10
+ # leading dot, bounded length.
11
+ module HermesTokens
12
+ PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,63}\z/
13
+
14
+ module_function
15
+
16
+ def valid?(value)
17
+ value.is_a?(String) && value.match?(PATTERN)
18
+ end
19
+
20
+ # Fail closed: raises ContractError naming the label and the rule.
21
+ def validate!(value, label)
22
+ token = value.is_a?(String) ? value.strip : value
23
+ return token if valid?(token)
24
+
25
+ raise ContractError,
26
+ "hermes #{label} must match #{PATTERN.inspect} " \
27
+ "(1..64 chars; letters, digits, '.', '_', ':', '-'; " \
28
+ "no leading dot; got #{value.inspect})"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Hermes
6
+ # Error model for the folder-as-interface plugin (spec 8wm.t.vs1 §9).
7
+ class Error < StandardError; end
8
+
9
+ # The shared folder or a configuration value violates the folder
10
+ # contract (missing folder, non-directory, world-writable folder,
11
+ # invalid channel/token values).
12
+ class ContractError < Error; end
13
+
14
+ # The plugin was invoked as root; writing without root is a pinned
15
+ # contract property and is refused fail closed.
16
+ class RootUserError < Error; end
17
+
18
+ # The target message file already exists; publish retries with a
19
+ # fresh id (bounded) instead of overwriting.
20
+ class CollisionError < Error; end
21
+
22
+ # The message declares a schema version this plugin does not support
23
+ # (only ace.hitl.hermes.message/v1); fail closed, quarantine upstream.
24
+ class UnknownFormatError < Error; end
25
+
26
+ # The message envelope violates schema ace.hitl.hermes.message/v1
27
+ # (bad filename/id pairing, missing or extra fields, bad sender,
28
+ # bad timestamp, empty body, wrong encoding or size).
29
+ class InvalidMessageError < Error; end
30
+
31
+ # The channel name is not in the registry.
32
+ class UnknownChannelError < Error; end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "fileutils"
5
+ require_relative "hermes_contract"
6
+
7
+ module Ace
8
+ module Hitl
9
+ module Hermes
10
+ module Molecules
11
+ # Atomic write protocol (spec 8wm.t.vs1 §5): same-directory tmp
12
+ # file (0600) + fsync + chmod 0640 + rename(2) onto the target.
13
+ # Readers never observe partial content; no root privileges are
14
+ # required or used - running as root is refused fail closed.
15
+ module HermesAtomicWriter
16
+ module_function
17
+
18
+ # Writes `bytes` to `final_path` atomically. The target must not
19
+ # exist (CollisionError otherwise); tmp files are removed on
20
+ # every failure path. Returns the final path.
21
+ def write(final_path, bytes, euid_provider: -> { Process.euid })
22
+ if euid_provider.call.zero?
23
+ raise RootUserError,
24
+ "hermes writes without root: refusing atomic write as euid 0 " \
25
+ "(#{final_path})"
26
+ end
27
+
28
+ folder = File.dirname(final_path)
29
+ HermesContract.verify_folder!(folder)
30
+ if File.exist?(final_path)
31
+ raise CollisionError, "hermes message file already exists: #{final_path}"
32
+ end
33
+
34
+ tmp_path = File.join(
35
+ folder,
36
+ "#{HermesContract::TMP_PREFIX}#{Process.pid}-#{SecureRandom.hex(8)}.tmp"
37
+ )
38
+ begin
39
+ File.open(tmp_path, File::WRONLY | File::CREAT | File::EXCL,
40
+ HermesContract::TMP_MODE) do |file|
41
+ file.write(bytes)
42
+ file.fsync
43
+ end
44
+ File.chmod(HermesContract::FILE_MODE, tmp_path)
45
+ File.rename(tmp_path, final_path)
46
+ rescue
47
+ File.delete(tmp_path) if File.exist?(tmp_path)
48
+ raise
49
+ end
50
+ final_path
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/hermes_tokens"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Hermes
8
+ module Molecules
9
+ module HermesChannels
10
+ # One shared folder between lab and hermes (spec 8wm.t.vs1 §2).
11
+ # `folder` is the absolute directory path; the message address
12
+ # is `<machine>/<name>/<id>` where `<name>` is the channel (and
13
+ # folder basename) token.
14
+ Channel = Struct.new(:name, :machine, :folder) do
15
+ def path
16
+ folder
17
+ end
18
+
19
+ def address(id)
20
+ "#{machine}/#{name}/#{id}"
21
+ end
22
+ end
23
+
24
+ # The channel registry the plugin owns (rejestr kanalow).
25
+ # Token validation happens here, fail closed; folder existence
26
+ # and writability are re-verified at every folder operation by
27
+ # the Box (a channel may be registered before its folder
28
+ # exists).
29
+ class Registry
30
+ def initialize
31
+ @channels = {}
32
+ @default_name = nil
33
+ end
34
+
35
+ def register(name, machine:, folder:)
36
+ name = Atoms::HermesTokens.validate!(name, "channel name")
37
+ machine = Atoms::HermesTokens.validate!(machine, "machine name")
38
+ unless folder.is_a?(String) && File.absolute_path?(folder)
39
+ raise ContractError,
40
+ "hermes channel #{name} folder must be an absolute directory path " \
41
+ "(got #{folder.inspect})"
42
+ end
43
+ base = File.basename(folder)
44
+ unless Atoms::HermesTokens.valid?(base)
45
+ raise ContractError,
46
+ "hermes channel #{name} folder basename must match " \
47
+ "#{Atoms::HermesTokens::PATTERN.inspect} (got #{base.inspect})"
48
+ end
49
+ if @channels.key?(name)
50
+ raise ContractError, "hermes channel already registered: #{name}"
51
+ end
52
+
53
+ @channels[name] = Channel.new(name: name, machine: machine, folder: folder)
54
+ name
55
+ end
56
+
57
+ def resolve(name)
58
+ channel = @channels[name]
59
+ unless channel
60
+ raise UnknownChannelError,
61
+ "unknown hermes channel #{name.inspect} (available: #{available.join(", ")})"
62
+ end
63
+ channel
64
+ end
65
+
66
+ def default=(name)
67
+ resolve(name)
68
+ @default_name = name
69
+ end
70
+
71
+ def default
72
+ @default_name
73
+ end
74
+
75
+ def resolve_default
76
+ unless @default_name
77
+ raise ContractError, "no default hermes channel registered"
78
+ end
79
+ resolve(@default_name)
80
+ end
81
+
82
+ def available
83
+ @channels.keys.sort
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/hermes_tokens"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Hermes
8
+ module Molecules
9
+ # Versioned folder contract constants + folder-level rules
10
+ # (spec 8wm.t.vs1 §§1-5). The plugin implements contract version
11
+ # ace.hitl.hermes.folder/v1; the canonical shared-contract file is
12
+ # settled by A4 (8wm.t.vs2), the lab-config consumer side is
13
+ # 8wm.t.vp9 (separate repository).
14
+ module HermesContract
15
+ FOLDER_CONTRACT = "ace.hitl.hermes.folder/v1"
16
+ MESSAGE_SCHEMA = "ace.hitl.hermes.message/v1"
17
+
18
+ # Content bounds: UTF-8 only, at most 64 KiB per message file.
19
+ MAX_BYTES = 65_536
20
+
21
+ # Permissions: message files 0640, tmp files 0600 during the
22
+ # atomic write, quarantine directory 0750. No root, ever.
23
+ FILE_MODE = 0o640
24
+ TMP_MODE = 0o600
25
+ QUARANTINE_DIR_MODE = 0o750
26
+
27
+ MESSAGE_EXT = ".json"
28
+ TMP_PREFIX = ".hermes-tmp-"
29
+ QUARANTINE_DIR = ".quarantine"
30
+ REASON_EXT = ".reason.txt"
31
+
32
+ TMP_BASENAME_PREFIX = "." # every tmp file is a dotfile; poll never sees it
33
+
34
+ module_function
35
+
36
+ # Fail-closed folder verification before EVERY folder operation:
37
+ # exists, is a directory, writable by the invoking user, and not
38
+ # world-writable.
39
+ def verify_folder!(path)
40
+ unless File.exist?(path)
41
+ raise ContractError, "hermes folder does not exist: #{path}"
42
+ end
43
+ unless File.directory?(path)
44
+ raise ContractError, "hermes folder is not a directory: #{path}"
45
+ end
46
+ unless File.writable?(path)
47
+ raise ContractError, "hermes folder is not writable by the invoking user: #{path}"
48
+ end
49
+ if File.stat(path).mode & 0o002 != 0
50
+ raise ContractError, "hermes folder must not be world-writable: #{path}"
51
+ end
52
+
53
+ path
54
+ end
55
+
56
+ def file_name(id)
57
+ "#{Atoms::HermesTokens.validate!(id, "message id")}#{MESSAGE_EXT}"
58
+ end
59
+
60
+ # `<token>.json` -> id token; anything else is not a message file.
61
+ def parse_file_name(name)
62
+ return nil unless name.is_a?(String) && name.end_with?(MESSAGE_EXT)
63
+
64
+ id = name.delete_suffix(MESSAGE_EXT)
65
+ Atoms::HermesTokens.valid?(id) ? id : nil
66
+ end
67
+
68
+ def message_path(folder, id)
69
+ File.join(folder, file_name(id))
70
+ end
71
+
72
+ def quarantine_path(folder)
73
+ File.join(folder, QUARANTINE_DIR)
74
+ end
75
+
76
+ # Shipped machine-readable contract artifact (JSON Schema
77
+ # draft-07) for the message envelope v1.
78
+ def schema_asset_path
79
+ File.expand_path("../schemas/message.v1.schema.json", __dir__)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "hermes_contract"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module Hermes
9
+ module Molecules
10
+ # Message format registry (spec 8wm.t.vs1 §9): the decode gate
11
+ # every message file passes before any other validation. Supports
12
+ # exactly ace.hitl.hermes.message/v1; unknown versions and every
13
+ # encoding/size violation fail closed.
14
+ module HermesFormats
15
+ SUPPORTED = [HermesContract::MESSAGE_SCHEMA].freeze
16
+
17
+ module_function
18
+
19
+ def supported
20
+ SUPPORTED.dup
21
+ end
22
+
23
+ # bytes -> parsed JSON object (Hash). Raises InvalidMessageError
24
+ # for size/encoding/JSON violations, UnknownFormatError for an
25
+ # unsupported or absent schema id.
26
+ def decode!(bytes)
27
+ unless bytes.bytesize <= HermesContract::MAX_BYTES
28
+ raise InvalidMessageError,
29
+ "message exceeds the #{HermesContract::MAX_BYTES}-byte size bound " \
30
+ "(#{bytes.bytesize} bytes)"
31
+ end
32
+
33
+ content = bytes.dup.force_encoding(Encoding::UTF_8)
34
+ unless content.valid_encoding?
35
+ raise InvalidMessageError, "message is not valid UTF-8"
36
+ end
37
+
38
+ hash =
39
+ begin
40
+ JSON.parse(content)
41
+ rescue JSON::ParserError => e
42
+ raise InvalidMessageError, "message is not valid JSON: #{e.message}"
43
+ end
44
+ unless hash.is_a?(Hash)
45
+ raise InvalidMessageError, "message must be a JSON object"
46
+ end
47
+
48
+ schema = hash["schema"]
49
+ unless SUPPORTED.include?(schema)
50
+ raise UnknownFormatError,
51
+ "unsupported message schema #{schema.inspect} (supported: " \
52
+ "#{SUPPORTED.join(", ")})"
53
+ end
54
+
55
+ hash
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../atoms/hermes_tokens"
6
+
7
+ module Ace
8
+ module Hitl
9
+ module Hermes
10
+ module Molecules
11
+ # Typed message envelope for schema ace.hitl.hermes.message/v1
12
+ # (spec 8wm.t.vs1 §3). One message per file; the payload id MUST
13
+ # equal the file name stem. Validation is fail closed and names
14
+ # every violated rule. The Captain's answer file shape is exactly
15
+ # {schema, id, kind: "answer", answer, sender, received_at}.
16
+ class HermesMessage
17
+ TIMESTAMP_PATTERN = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z\z/
18
+ KINDS = %w[question answer].freeze
19
+
20
+ EXPECTED_FIELDS = {
21
+ "question" => %w[schema id kind sender question created_at],
22
+ "answer" => %w[schema id kind sender answer received_at]
23
+ }.freeze
24
+
25
+ attr_reader :schema, :id, :kind, :sender, :body, :timestamp_field, :timestamp
26
+
27
+ def self.question(id:, question:, sender:, created_at:)
28
+ new(
29
+ id: id, kind: :question, sender: sender,
30
+ body_field: "question", body: question,
31
+ timestamp_field: "created_at", timestamp: created_at
32
+ )
33
+ end
34
+
35
+ def self.answer(id:, answer:, sender:, received_at:)
36
+ new(
37
+ id: id, kind: :answer, sender: sender,
38
+ body_field: "answer", body: answer,
39
+ timestamp_field: "received_at", timestamp: received_at
40
+ )
41
+ end
42
+
43
+ # Fail-closed parse of an already-decoded envelope Hash.
44
+ # `filename_id` (when given) must equal the payload id.
45
+ def self.from_hash(hash, filename_id: nil)
46
+ # Defense-in-depth format gate (the Box always decodes via
47
+ # HermesFormats first): from_hash is public, so it pins the
48
+ # schema value itself instead of trusting its caller.
49
+ unless hash["schema"] == HermesContract::MESSAGE_SCHEMA
50
+ raise UnknownFormatError,
51
+ "unsupported message schema #{hash["schema"].inspect} (supported: " \
52
+ "#{HermesContract::MESSAGE_SCHEMA})"
53
+ end
54
+
55
+ kind_name = hash["kind"]
56
+ unless KINDS.include?(kind_name)
57
+ raise InvalidMessageError,
58
+ "message kind must be one of #{KINDS.join(", ")} (got #{kind_name.inspect})"
59
+ end
60
+
61
+ expected = EXPECTED_FIELDS.fetch(kind_name)
62
+ missing = expected - hash.keys
63
+ extra = hash.keys - expected
64
+ unless missing.empty? && extra.empty?
65
+ raise InvalidMessageError,
66
+ "message fields violate schema #{HermesContract::MESSAGE_SCHEMA} " \
67
+ "(missing: #{missing.empty? ? "none" : missing.join(", ")}; " \
68
+ "unexpected: #{extra.empty? ? "none" : extra.join(", ")})"
69
+ end
70
+
71
+ id = Atoms::HermesTokens.validate!(hash["id"], "message id")
72
+ if filename_id && id != filename_id
73
+ raise InvalidMessageError,
74
+ "message id #{id.inspect} does not match file name stem #{filename_id.inspect}"
75
+ end
76
+
77
+ sender = Atoms::HermesTokens.validate!(hash["sender"], "sender")
78
+
79
+ body_field = (kind_name == "question") ? "question" : "answer"
80
+ body = hash[body_field]
81
+ unless body.is_a?(String) && !body.strip.empty?
82
+ raise InvalidMessageError, "message #{body_field} must be a non-empty string"
83
+ end
84
+
85
+ timestamp_field = (kind_name == "question") ? "created_at" : "received_at"
86
+ timestamp = validate_timestamp!(hash[timestamp_field], timestamp_field)
87
+
88
+ new(
89
+ id: id, kind: kind_name.to_sym, sender: sender,
90
+ body_field: body_field, body: body,
91
+ timestamp_field: timestamp_field, timestamp: timestamp
92
+ )
93
+ end
94
+
95
+ def self.validate_timestamp!(value, field)
96
+ match = value.is_a?(String) && TIMESTAMP_PATTERN.match(value)
97
+ unless match
98
+ raise InvalidMessageError,
99
+ "message #{field} must be UTC ISO-8601 YYYY-MM-DDTHH:MM:SSZ " \
100
+ "(got #{value.inspect})"
101
+ end
102
+
103
+ begin
104
+ parsed = Time.iso8601(value)
105
+ rescue ArgumentError
106
+ raise InvalidMessageError,
107
+ "message #{field} is not a real calendar timestamp: #{value.inspect}"
108
+ end
109
+
110
+ # Time.iso8601 silently rolls impossible components (Feb 30,
111
+ # Apr 31, hour 24) over into the next real instant; the
112
+ # contract requires the literal calendar date/time, so the
113
+ # parsed value must round-trip to the source components.
114
+ source = match.captures.map(&:to_i)
115
+ round_trip = [parsed.year, parsed.month, parsed.day,
116
+ parsed.hour, parsed.min, parsed.sec]
117
+ unless round_trip == source
118
+ raise InvalidMessageError,
119
+ "message #{field} is not a real calendar timestamp: #{value.inspect}"
120
+ end
121
+
122
+ value
123
+ end
124
+
125
+ def initialize(id:, kind:, sender:, body_field:, body:, timestamp_field:, timestamp:)
126
+ @schema = HermesContract::MESSAGE_SCHEMA
127
+ @id = id
128
+ @kind = kind
129
+ @sender = sender
130
+ @body_field = body_field
131
+ @body = body
132
+ @timestamp_field = timestamp_field
133
+ @timestamp = timestamp
134
+ end
135
+
136
+ def question?
137
+ @kind == :question
138
+ end
139
+
140
+ def answer?
141
+ @kind == :answer
142
+ end
143
+
144
+ def to_h
145
+ {
146
+ "schema" => @schema,
147
+ "id" => @id,
148
+ "kind" => @kind.to_s,
149
+ "sender" => @sender,
150
+ @body_field => @body,
151
+ @timestamp_field => @timestamp
152
+ }
153
+ end
154
+
155
+ # Canonical serialization: fixed key order, compact JSON.
156
+ def to_json(*)
157
+ JSON.generate(to_h)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Hermes
6
+ module Molecules
7
+ # Deterministic single-line notification texts (spec 8wm.t.vs1 §9):
8
+ # no clock, no colors, only provided values. The address is the
9
+ # message address `<machine>/<folder>/<id>`. Emission is an
10
+ # injectable sink (default silent in Box).
11
+ module HermesNotifications
12
+ EVENTS = %w[
13
+ question_received
14
+ answer_written
15
+ delivered
16
+ acked
17
+ quarantined
18
+ retry_scheduled
19
+ retry_exhausted
20
+ ].freeze
21
+
22
+ module_function
23
+
24
+ # Builds the notification line for an event. Unknown events fail
25
+ # closed so a typo cannot silently drop notifications.
26
+ def emit(event, address:, **details)
27
+ case event.to_s
28
+ when "question_received"
29
+ "hermes: question #{address} received"
30
+ when "answer_written"
31
+ "hermes: answer #{address} written"
32
+ when "delivered"
33
+ "hermes: #{address} delivered"
34
+ when "acked"
35
+ "hermes: #{address} acked (file deleted)"
36
+ when "quarantined"
37
+ "hermes: #{address} quarantined (#{details[:reason]})"
38
+ when "retry_scheduled"
39
+ "hermes: #{address} retry #{details[:attempt]}/#{details[:max_attempts]} " \
40
+ "(#{details[:policy]})"
41
+ when "retry_exhausted"
42
+ "hermes: #{address} retries exhausted (#{details[:reason]})"
43
+ else
44
+ raise ContractError,
45
+ "unknown hermes notification event #{event.inspect} " \
46
+ "(known: #{EVENTS.join(", ")})"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "hermes_contract"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module Hermes
9
+ module Molecules
10
+ # Quarantine for invalid message files (spec 8wm.t.vs1 §7): the
11
+ # file is moved atomically into <folder>/.quarantine/ (0750) with
12
+ # a <name>.reason.txt sidecar (0640). Quarantined content is never
13
+ # rewritten, re-validated, or delivered. Like every hermes write
14
+ # path, running as root is refused fail closed.
15
+ module HermesQuarantine
16
+ module_function
17
+
18
+ # Moves `path` (inside `folder`) into the quarantine directory
19
+ # and writes the reason sidecar. Returns the quarantined path.
20
+ def move(folder, path, reason:, now: -> { Time.now },
21
+ euid_provider: -> { Process.euid })
22
+ if euid_provider.call.zero?
23
+ raise RootUserError,
24
+ "hermes writes without root: refusing quarantine move as euid 0 " \
25
+ "(#{path})"
26
+ end
27
+
28
+ quarantine_dir = HermesContract.quarantine_path(folder)
29
+ unless File.directory?(quarantine_dir)
30
+ Dir.mkdir(quarantine_dir, HermesContract::QUARANTINE_DIR_MODE)
31
+ # Modes are FORCED, not inherited from the umask (mirrors
32
+ # HermesAtomicWriter): the pinned 0750 dir contract holds
33
+ # under any umask.
34
+ File.chmod(HermesContract::QUARANTINE_DIR_MODE, quarantine_dir)
35
+ end
36
+
37
+ base = File.basename(path)
38
+ dest = File.join(quarantine_dir, base)
39
+ suffix = 0
40
+ while File.exist?(dest)
41
+ suffix += 1
42
+ dest = File.join(quarantine_dir, "#{base}.#{suffix}")
43
+ end
44
+
45
+ sidecar_path = "#{dest}#{HermesContract::REASON_EXT}"
46
+ File.rename(path, dest)
47
+ File.open(sidecar_path,
48
+ File::WRONLY | File::CREAT | File::EXCL,
49
+ HermesContract::FILE_MODE) do |sidecar|
50
+ sidecar.write("#{now.call.utc.strftime("%Y-%m-%dT%H:%M:%SZ")} #{reason}\n")
51
+ end
52
+ # The open-mode is umask-sensitive; force the pinned 0640.
53
+ File.chmod(HermesContract::FILE_MODE, sidecar_path)
54
+ dest
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Hermes
6
+ module Molecules
7
+ # Pure retry decisions (spec 8wm.t.vs1 §8). The clock-and-loop
8
+ # belongs to the transport role (y24 / A4); this module pins the
9
+ # policy so both sides decide identically.
10
+ #
11
+ # - collision retries (write time): bounded fresh-id attempts;
12
+ # - undeleted-file retries (post-delivery): a delivered file that
13
+ # is not ACKed after the stale deadline is redelivered as
14
+ # IDENTICAL bytes, bounded, then quarantined (terminal).
15
+ module HermesRetryPolicy
16
+ DEFAULT_MAX_ATTEMPTS = 3
17
+ DEFAULT_BASE_DELAY = 1.0
18
+ DEFAULT_MAX_DELAY = 60.0
19
+
20
+ class << self
21
+ def collision_retry?(failed_attempts, max_attempts: DEFAULT_MAX_ATTEMPTS)
22
+ failed_attempts < max_attempts
23
+ end
24
+
25
+ # Exponential backoff for the given 1-based attempt number.
26
+ def delay_for(attempt, base_delay: DEFAULT_BASE_DELAY, max_delay: DEFAULT_MAX_DELAY)
27
+ [base_delay * (2**(attempt - 1)), max_delay].min
28
+ end
29
+
30
+ # Decision for a delivered-but-not-acked file.
31
+ # `attempts` = deliveries already performed (>= 1).
32
+ def undeleted_decision(age_seconds:, stale_after:, attempts:, max_attempts: DEFAULT_MAX_ATTEMPTS)
33
+ return :wait if age_seconds < stale_after
34
+ return :redeliver if attempts < max_attempts
35
+
36
+ :quarantine
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require_relative "../molecules/hermes_contract"
6
+ require_relative "../molecules/hermes_formats"
7
+ require_relative "../molecules/hermes_message"
8
+ require_relative "../molecules/hermes_atomic_writer"
9
+ require_relative "../molecules/hermes_quarantine"
10
+ require_relative "../molecules/hermes_retry_policy"
11
+ require_relative "../molecules/hermes_notifications"
12
+ require_relative "../molecules/hermes_channels"
13
+
14
+ module Ace
15
+ module Hitl
16
+ module Hermes
17
+ module Organisms
18
+ # The folder interface per channel (spec 8wm.t.vs1 §9): publish
19
+ # (atomic write + collision retry), poll (validate + quarantine),
20
+ # ack (deletion IS the ACK), age (retry clock), identical?
21
+ # (redelivery identity). The Box is role-agnostic: the hermes
22
+ # side polls for questions, the lab side (labd) polls for answers.
23
+ class HermesBox
24
+ QuarantinedFile = Struct.new(:path, :reason)
25
+ PollResult = Struct.new(:messages, :quarantined)
26
+
27
+ attr_reader :channel
28
+
29
+ def initialize(channel:, registry: nil, id_generator: -> { SecureRandom.hex(6) },
30
+ notifier: nil, euid_provider: -> { Process.euid })
31
+ @channel =
32
+ if channel.is_a?(Molecules::HermesChannels::Channel)
33
+ channel
34
+ elsif registry
35
+ registry.resolve(channel)
36
+ else
37
+ raise ContractError,
38
+ "hermes box needs a Channel or a registry to resolve the channel name"
39
+ end
40
+ @id_generator = id_generator
41
+ @notifier = notifier || ->(_line) {}
42
+ @euid_provider = euid_provider
43
+ end
44
+
45
+ def address(id)
46
+ @channel.address(id)
47
+ end
48
+
49
+ # Atomic write of a validated message. A GENERATED id is
50
+ # regenerated (bounded, notified) on a filename collision; an
51
+ # EXPLICITLY supplied id never gets regenerated - it asserts
52
+ # correlation (e.g. the answer of question `q-1` is `q-1.json`),
53
+ # so a collision there fails loudly instead of silently breaking
54
+ # the question -> answer pairing. Returns the published Message.
55
+ def publish(kind:, body:, sender:, timestamp:, id: nil)
56
+ explicit_id = !id.nil?
57
+ attempts = 0
58
+ loop do
59
+ message = build_message(
60
+ kind: kind, id: id || @id_generator.call,
61
+ sender: sender, timestamp: timestamp, body: body
62
+ )
63
+ # Producer fail-closed gate: the exact BYTES about to be
64
+ # written must survive the consumers' decode gate (UTF-8 +
65
+ # 64 KiB cap + schema id), and the decoded envelope must
66
+ # satisfy the exact message contract, BEFORE any disk
67
+ # write - so an envelope its own poll would terminally
68
+ # quarantine can never be published (review 8wq2zttx on
69
+ # PR#336).
70
+ envelope = message.to_json
71
+ Molecules::HermesFormats.decode!(envelope)
72
+ Molecules::HermesMessage.from_hash(
73
+ JSON.parse(envelope), filename_id: message.id
74
+ )
75
+ begin
76
+ Molecules::HermesAtomicWriter.write(
77
+ Molecules::HermesContract.message_path(@channel.folder, message.id),
78
+ envelope,
79
+ euid_provider: @euid_provider
80
+ )
81
+ rescue CollisionError
82
+ raise if explicit_id
83
+
84
+ attempts += 1
85
+ unless Molecules::HermesRetryPolicy.collision_retry?(attempts)
86
+ raise
87
+ end
88
+
89
+ notify(:retry_scheduled, address(message.id), attempt: attempts,
90
+ max_attempts: Molecules::HermesRetryPolicy::DEFAULT_MAX_ATTEMPTS,
91
+ policy: "collision")
92
+ id = nil # force a fresh id on the next iteration
93
+ next
94
+ end
95
+
96
+ notify(:answer_written, address(message.id)) if message.answer?
97
+ return message
98
+ end
99
+ end
100
+
101
+ # Validate + collect everything currently in the folder.
102
+ # Valid files become Messages; files failing the fail-closed
103
+ # gate are quarantined (never delivered). Foreign names
104
+ # (dotfiles, tmp leftovers, non-`<token>.json`) are ignored.
105
+ def poll
106
+ Molecules::HermesContract.verify_folder!(@channel.folder)
107
+ messages = []
108
+ quarantined = []
109
+
110
+ Dir.children(@channel.folder).sort.each do |name|
111
+ id = Molecules::HermesContract.parse_file_name(name)
112
+ next if id.nil? # dotfiles, tmp leftovers, foreign names
113
+
114
+ path = File.join(@channel.folder, name)
115
+ # Opened with O_NOFOLLOW and verified regular on the open
116
+ # descriptor: a top-level symlink must never be followed -
117
+ # it could import content from outside the channel or
118
+ # reverse a quarantine's terminal state (review 8wq2ztu3
119
+ # on PR#336).
120
+ begin
121
+ file = File.open(path, File::RDONLY | File::NOFOLLOW)
122
+ rescue Errno::ENOENT
123
+ next # concurrently acked between listing and reading
124
+ rescue Errno::ELOOP
125
+ quarantined << quarantine(path, id, "message file is a symlink")
126
+ next
127
+ end
128
+
129
+ begin
130
+ begin
131
+ regular = file.stat.file?
132
+ rescue Errno::ENOENT
133
+ next # concurrently acked after the open
134
+ end
135
+ unless regular
136
+ quarantined << quarantine(path, id, "message file is not a regular file")
137
+ next
138
+ end
139
+
140
+ bytes = file.read(Molecules::HermesContract::MAX_BYTES + 1) || ""
141
+ hash = Molecules::HermesFormats.decode!(bytes)
142
+ message = Molecules::HermesMessage.from_hash(hash, filename_id: id)
143
+ rescue Error => e
144
+ quarantined << quarantine(path, id, e.message)
145
+ next
146
+ ensure
147
+ file.close
148
+ end
149
+
150
+ notify(:question_received, address(message.id)) if message.question?
151
+ messages << message
152
+ end
153
+
154
+ PollResult.new(messages: messages, quarantined: quarantined)
155
+ end
156
+
157
+ # Deletion is the ACK; idempotent (an absent file reports
158
+ # :already_acked, never an error). The delete is a folder write,
159
+ # so the no-root gate applies exactly like on publish.
160
+ def ack(id)
161
+ if @euid_provider.call.zero?
162
+ raise RootUserError,
163
+ "hermes writes without root: refusing ack deletion as euid 0 " \
164
+ "(#{Molecules::HermesContract.message_path(@channel.folder, id)})"
165
+ end
166
+ Molecules::HermesContract.verify_folder!(@channel.folder)
167
+ path = Molecules::HermesContract.message_path(@channel.folder, id)
168
+ if File.exist?(path)
169
+ File.delete(path)
170
+ notify(:acked, address(id))
171
+ :acked
172
+ else
173
+ :already_acked
174
+ end
175
+ end
176
+
177
+ # Seconds since the message file was written (mtime); nil when
178
+ # the file is gone (already acked). The undeleted-file retry
179
+ # clock (spec §8).
180
+ def age(id)
181
+ path = Molecules::HermesContract.message_path(@channel.folder, id)
182
+ File.exist?(path) ? Time.now - File.mtime(path) : nil
183
+ end
184
+
185
+ # True when the file for `id` currently holds exactly `bytes` -
186
+ # the identity check every redelivery must pass (retries never
187
+ # duplicate answers).
188
+ def identical?(id, bytes)
189
+ path = Molecules::HermesContract.message_path(@channel.folder, id)
190
+ File.exist?(path) && read_capped(path) == bytes
191
+ rescue Errno::ENOENT
192
+ false
193
+ end
194
+
195
+ private
196
+
197
+ def build_message(kind:, id:, sender:, timestamp:, body:)
198
+ case kind.to_s
199
+ when "question"
200
+ Molecules::HermesMessage.question(
201
+ id: id, question: body, sender: sender, created_at: timestamp
202
+ )
203
+ when "answer"
204
+ Molecules::HermesMessage.answer(
205
+ id: id, answer: body, sender: sender, received_at: timestamp
206
+ )
207
+ else
208
+ raise ContractError,
209
+ "hermes message kind must be question or answer (got #{kind.inspect})"
210
+ end
211
+ end
212
+
213
+ # Read at most MAX_BYTES + 1 bytes so an oversized file fails
214
+ # the size gate without reading the whole file. An empty file
215
+ # reads as "" (File.read with an explicit length returns nil at
216
+ # EOF) so the empty payload still fails the formats gate and is
217
+ # quarantined instead of crashing the poll loop.
218
+ def read_capped(path)
219
+ File.read(path, Molecules::HermesContract::MAX_BYTES + 1) || ""
220
+ end
221
+
222
+ def quarantine(path, id, reason)
223
+ quarantined = Molecules::HermesQuarantine.move(
224
+ @channel.folder, path, reason: reason,
225
+ euid_provider: @euid_provider
226
+ )
227
+ notify(:quarantined, address(id), reason: reason)
228
+ QuarantinedFile.new(path: quarantined, reason: reason)
229
+ end
230
+
231
+ def notify(event, address, **details)
232
+ @notifier.call(
233
+ Molecules::HermesNotifications.emit(event, address: address, **details)
234
+ )
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "ace.hitl.hermes.message/v1",
4
+ "title": "ace-hitl-hermes message envelope v1",
5
+ "description": "One JSON object per file in the shared lab <-> hermes folder (folder contract ace.hitl.hermes.folder/v1, spec 8wm.t.vs1). File name must be <id>.json with id equal to the payload id.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "id", "kind", "sender"],
9
+ "properties": {
10
+ "schema": {
11
+ "const": "ace.hitl.hermes.message/v1"
12
+ },
13
+ "id": {
14
+ "$ref": "#/definitions/token"
15
+ },
16
+ "sender": {
17
+ "$ref": "#/definitions/token"
18
+ },
19
+ "kind": {
20
+ "enum": ["question", "answer"]
21
+ },
22
+ "question": {
23
+ "type": "string",
24
+ "minLength": 1,
25
+ "pattern": "\\S",
26
+ "description": "Required for kind=question; non-empty after strip."
27
+ },
28
+ "answer": {
29
+ "type": "string",
30
+ "minLength": 1,
31
+ "pattern": "\\S",
32
+ "description": "Required for kind=answer (the Captain's answer); non-empty after strip."
33
+ },
34
+ "created_at": {
35
+ "$ref": "#/definitions/timestamp",
36
+ "description": "Required for kind=question."
37
+ },
38
+ "received_at": {
39
+ "$ref": "#/definitions/timestamp",
40
+ "description": "Required for kind=answer."
41
+ }
42
+ },
43
+ "definitions": {
44
+ "token": {
45
+ "type": "string",
46
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$"
47
+ },
48
+ "timestamp": {
49
+ "type": "string",
50
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
51
+ "format": "date-time"
52
+ }
53
+ },
54
+ "oneOf": [
55
+ {
56
+ "properties": {
57
+ "kind": {
58
+ "const": "question"
59
+ }
60
+ },
61
+ "required": ["question", "created_at"],
62
+ "not": {
63
+ "anyOf": [
64
+ { "required": ["answer"] },
65
+ { "required": ["received_at"] }
66
+ ]
67
+ }
68
+ },
69
+ {
70
+ "properties": {
71
+ "kind": {
72
+ "const": "answer"
73
+ }
74
+ },
75
+ "required": ["answer", "received_at"],
76
+ "not": {
77
+ "anyOf": [
78
+ { "required": ["question"] },
79
+ { "required": ["created_at"] }
80
+ ]
81
+ }
82
+ }
83
+ ]
84
+ }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ # Folder-as-interface HITL transport plugin (spec 8wm.t.vs1).
6
+ module Hermes
7
+ VERSION = "0.1.0"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hermes/version"
4
+ require_relative "hermes/errors"
5
+ require_relative "hermes/atoms/hermes_tokens"
6
+ require_relative "hermes/molecules/hermes_contract"
7
+ require_relative "hermes/molecules/hermes_formats"
8
+ require_relative "hermes/molecules/hermes_message"
9
+ require_relative "hermes/molecules/hermes_atomic_writer"
10
+ require_relative "hermes/molecules/hermes_quarantine"
11
+ require_relative "hermes/molecules/hermes_retry_policy"
12
+ require_relative "hermes/molecules/hermes_notifications"
13
+ require_relative "hermes/molecules/hermes_channels"
14
+ require_relative "hermes/organisms/hermes_box"
15
+
16
+ module Ace
17
+ module Hitl
18
+ # Folder-as-interface HITL transport plugin (spec 8wm.t.vs1): the
19
+ # shared folder between lab and hermes IS the transport. A message is
20
+ # a file, the address is <machine>/<folder>/<id>, delivery is push,
21
+ # and ACK is the deletion of the file after delivery. The plugin owns
22
+ # the channel registry, the notification texts, and the message
23
+ # formats (ace.hitl.hermes.message/v1).
24
+ module Hermes
25
+ end
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-hitl-hermes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-09-27 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ace-support-test-helpers
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.14'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.14'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.19'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.19'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: 'ace-hitl-hermes treats the shared lab <-> hermes folder as the HITL
55
+ transport: a message is a file, the address is <machine>/<folder>/<id>, delivery
56
+ is push, and ACK is deletion. The plugin owns the channel registry, notification
57
+ texts, and the versioned message formats (ace.hitl.hermes.message/v1), with atomic
58
+ same-directory tmp+rename writes, fail-closed validation, quarantine, and bounded
59
+ retries.'
60
+ email:
61
+ - mc@cs3b.com
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - CHANGELOG.md
67
+ - README.md
68
+ - Rakefile
69
+ - lib/ace/hitl/hermes.rb
70
+ - lib/ace/hitl/hermes/atoms/hermes_tokens.rb
71
+ - lib/ace/hitl/hermes/errors.rb
72
+ - lib/ace/hitl/hermes/molecules/hermes_atomic_writer.rb
73
+ - lib/ace/hitl/hermes/molecules/hermes_channels.rb
74
+ - lib/ace/hitl/hermes/molecules/hermes_contract.rb
75
+ - lib/ace/hitl/hermes/molecules/hermes_formats.rb
76
+ - lib/ace/hitl/hermes/molecules/hermes_message.rb
77
+ - lib/ace/hitl/hermes/molecules/hermes_notifications.rb
78
+ - lib/ace/hitl/hermes/molecules/hermes_quarantine.rb
79
+ - lib/ace/hitl/hermes/molecules/hermes_retry_policy.rb
80
+ - lib/ace/hitl/hermes/organisms/hermes_box.rb
81
+ - lib/ace/hitl/hermes/schemas/message.v1.schema.json
82
+ - lib/ace/hitl/hermes/version.rb
83
+ homepage: https://github.com/cs3b/ace
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ allowed_push_host: https://rubygems.org
88
+ homepage_uri: https://github.com/cs3b/ace
89
+ source_code_uri: https://github.com/cs3b/ace/tree/main/ace-hitl-hermes/
90
+ changelog_uri: https://github.com/cs3b/ace/blob/main/ace-hitl-hermes/CHANGELOG.md
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 3.2.0
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.6.9
106
+ specification_version: 4
107
+ summary: Folder-as-interface HITL transport plugin for the hermes relay
108
+ test_files: []