Class: Ace::Hitl::Hermes::Molecules::HermesMessage

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/hitl/hermes/molecules/hermes_message.rb

Overview

Typed message envelope for schema ace.hitl.hermes.message/v1 (spec 8wm.t.vs1 §3). One message per file; the payload id MUST equal the file name stem. Validation is fail closed and names every violated rule. The Captain's answer file shape is exactly id, kind: "answer", answer, sender, received_at.

Constant Summary collapse

TIMESTAMP_PATTERN =
/\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z\z/
KINDS =
%w[question answer].freeze
EXPECTED_FIELDS =
{
  "question" => %w[schema id kind sender question created_at],
  "answer" => %w[schema id kind sender answer received_at]
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(id:, kind:, sender:, body_field:, body:, timestamp_field:, timestamp:) ⇒ HermesMessage

Returns a new instance of HermesMessage.



125
126
127
128
129
130
131
132
133
134
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 125

def initialize(id:, kind:, sender:, body_field:, body:, timestamp_field:, timestamp:)
  @schema = HermesContract::MESSAGE_SCHEMA
  @id = id
  @kind = kind
  @sender = sender
  @body_field = body_field
  @body = body
  @timestamp_field = timestamp_field
  @timestamp = timestamp
end

Instance Attribute Details

#body ⇒ Object (readonly)

Returns the value of attribute body.



25
26
27
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 25

def body
  @body
end

#id ⇒ Object (readonly)

Returns the value of attribute id.



25
26
27
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 25

def id
  @id
end

#kind ⇒ Object (readonly)

Returns the value of attribute kind.



25
26
27
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 25

def kind
  @kind
end

#schema ⇒ Object (readonly)

Returns the value of attribute schema.



25
26
27
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 25

def schema
  @schema
end

#sender ⇒ Object (readonly)

Returns the value of attribute sender.



25
26
27
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 25

def sender
  @sender
end

#timestamp ⇒ Object (readonly)

Returns the value of attribute timestamp.



25
26
27
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 25

def timestamp
  @timestamp
end

#timestamp_field ⇒ Object (readonly)

Returns the value of attribute timestamp_field.



25
26
27
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 25

def timestamp_field
  @timestamp_field
end

Class Method Details

.answer(id:, answer:, sender:, received_at:) ⇒ Object



35
36
37
38
39
40
41
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 35

def self.answer(id:, answer:, sender:, received_at:)
  new(
    id: id, kind: :answer, sender: sender,
    body_field: "answer", body: answer,
    timestamp_field: "received_at", timestamp: received_at
  )
end

.from_hash(hash, filename_id: nil) ⇒ Object

Fail-closed parse of an already-decoded envelope Hash. filename_id (when given) must equal the payload id.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 45

def self.from_hash(hash, filename_id: nil)
  # Defense-in-depth format gate (the Box always decodes via
  # HermesFormats first): from_hash is public, so it pins the
  # schema value itself instead of trusting its caller.
  unless hash["schema"] == HermesContract::MESSAGE_SCHEMA
    raise UnknownFormatError,
      "unsupported message schema #{hash["schema"].inspect} (supported: " \
      "#{HermesContract::MESSAGE_SCHEMA})"
  end

  kind_name = hash["kind"]
  unless KINDS.include?(kind_name)
    raise InvalidMessageError,
      "message kind must be one of #{KINDS.join(", ")} (got #{kind_name.inspect})"
  end

  expected = EXPECTED_FIELDS.fetch(kind_name)
  missing = expected - hash.keys
  extra = hash.keys - expected
  unless missing.empty? && extra.empty?
    raise InvalidMessageError,
      "message fields violate schema #{HermesContract::MESSAGE_SCHEMA} " \
      "(missing: #{missing.empty? ? "none" : missing.join(", ")}; " \
      "unexpected: #{extra.empty? ? "none" : extra.join(", ")})"
  end

  id = Atoms::HermesTokens.validate!(hash["id"], "message id")
  if filename_id && id != filename_id
    raise InvalidMessageError,
      "message id #{id.inspect} does not match file name stem #{filename_id.inspect}"
  end

  sender = Atoms::HermesTokens.validate!(hash["sender"], "sender")

  body_field = (kind_name == "question") ? "question" : "answer"
  body = hash[body_field]
  unless body.is_a?(String) && !body.strip.empty?
    raise InvalidMessageError, "message #{body_field} must be a non-empty string"
  end

  timestamp_field = (kind_name == "question") ? "created_at" : "received_at"
  timestamp = validate_timestamp!(hash[timestamp_field], timestamp_field)

  new(
    id: id, kind: kind_name.to_sym, sender: sender,
    body_field: body_field, body: body,
    timestamp_field: timestamp_field, timestamp: timestamp
  )
end

.question(id:, question:, sender:, created_at:) ⇒ Object



27
28
29
30
31
32
33
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 27

def self.question(id:, question:, sender:, created_at:)
  new(
    id: id, kind: :question, sender: sender,
    body_field: "question", body: question,
    timestamp_field: "created_at", timestamp: created_at
  )
end

.validate_timestamp!(value, field) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 95

def self.validate_timestamp!(value, field)
  match = value.is_a?(String) && TIMESTAMP_PATTERN.match(value)
  unless match
    raise InvalidMessageError,
      "message #{field} must be UTC ISO-8601 YYYY-MM-DDTHH:MM:SSZ " \
      "(got #{value.inspect})"
  end

  begin
    parsed = Time.iso8601(value)
  rescue ArgumentError
    raise InvalidMessageError,
      "message #{field} is not a real calendar timestamp: #{value.inspect}"
  end

  # Time.iso8601 silently rolls impossible components (Feb 30,
  # Apr 31, hour 24) over into the next real instant; the
  # contract requires the literal calendar date/time, so the
  # parsed value must round-trip to the source components.
  source = match.captures.map(&:to_i)
  round_trip = [parsed.year, parsed.month, parsed.day,
    parsed.hour, parsed.min, parsed.sec]
  unless round_trip == source
    raise InvalidMessageError,
      "message #{field} is not a real calendar timestamp: #{value.inspect}"
  end

  value
end

Instance Method Details

#answer? ⇒ Boolean

Returns:

  • (Boolean)


140
141
142
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 140

def answer?
  @kind == :answer
end

#question? ⇒ Boolean

Returns:

  • (Boolean)


136
137
138
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 136

def question?
  @kind == :question
end

#to_h ⇒ Object



144
145
146
147
148
149
150
151
152
153
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 144

def to_h
  {
    "schema" => @schema,
    "id" => @id,
    "kind" => @kind.to_s,
    "sender" => @sender,
    @body_field => @body,
    @timestamp_field => @timestamp
  }
end

#to_json ⇒ Object

Canonical serialization: fixed key order, compact JSON.



156
157
158
# File 'lib/ace/hitl/hermes/molecules/hermes_message.rb', line 156

def to_json(*)
  JSON.generate(to_h)
end