Module: Textus::Manifest::Schema

Defined in:
lib/textus/manifest/schema.rb

Constant Summary collapse

ROOT_KEYS =
%w[version roles zones entries rules audit].freeze
ROLE_KEYS =
%w[name can].freeze
ZONE_KEYS =
%w[name kind owner desc].freeze
LANES =

The closed coordination vocabulary (ADR 0028; completed at five in ADR 0033; unified here in ADR 0034). Each lane pairs a zone-kind with the single capability that authorizes originating bytes in it — a total bijection. This table is the ONE source of truth; the three legacy constants below are derived from it so a zone-kind and its required capability cannot drift. Key order is canon-first so the unknown-kind error message reads canon, workspace, quarantine, queue, derived.

{
  "canon" => "author",
  "workspace" => "keep",
  "quarantine" => "fetch",
  "queue" => "propose",
  "derived" => "build",
}.freeze
ZONE_KINDS =
LANES.keys.freeze
CAPABILITIES =
LANES.values.freeze
KIND_REQUIRES_VERB =
LANES
ENTRY_KEYS =
%w[
  key path zone kind schema owner nested format
  compute template publish_to publish_each
  intake events inject_boot index_filename ignore tracked
].freeze
COMPUTE_KEYS =
%w[kind select pluck sort_by limit transform command sources].freeze
INTAKE_KEYS =
%w[handler config].freeze
RULE_KEYS =
%w[match fetch intake_handler_allowlist guard retention].freeze
FETCH_KEYS =
%w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
FETCH_TIMEOUT_SECONDS_CEILING =
3600
RETENTION_KEYS =
%w[expire_after archive_after].freeze
AUDIT_KEYS =
%w[max_size keep].freeze
OWNER_SUBJECT_PATTERN =

Syntactic shape of an ‘owner:` subject token (the `patrick` in `human:patrick`) — the subject half of the owner-validation rule below. Role supplies the archetype set (Role::NAMES); this pattern is the owner-specific part, so it lives with the rule that composes them (ADR 0045 D1). Acting-role names are gated by Role::NAMES, not a regex.

/\A[a-z][a-z0-9_-]*\z/

Class Method Summary collapse

Class Method Details

.check_owner!(owner, path) ⇒ Object

Raises:



139
140
141
142
143
144
145
146
147
148
# File 'lib/textus/manifest/schema.rb', line 139

def self.check_owner!(owner, path)
  return if owner.nil?
  return if valid_owner?(owner)

  raise BadManifest.new(
    "invalid owner '#{owner}' at '#{path}' " \
    "(expected <archetype> or <archetype>:<subject>, " \
    "archetype one of: #{Textus::Role::NAMES.join(", ")})",
  )
end

.valid_owner?(token) ⇒ Boolean

The owner-validation rule: an ‘owner:` token is either a bare archetype (`agent`) or `<archetype>:<subject>` (`human:patrick`). The archetype is gated against the closed Role::NAMES set (so attribution can’t smuggle in a name the role side rejects, ADR 0045 D1); the subject is the free-form principal, validated by OWNER_SUBJECT_PATTERN. Split on the FIRST ‘:’ only — a subject may not itself contain ‘:’ (the pattern excludes it), so ‘human:a:b` is rejected.

Returns:

  • (Boolean)


157
158
159
160
161
162
163
164
165
# File 'lib/textus/manifest/schema.rb', line 157

def self.valid_owner?(token)
  return false unless token.is_a?(String) && !token.empty?

  archetype, subject = token.split(":", 2)
  return false unless Textus::Role::NAMES.include?(archetype)
  return true if subject.nil?

  OWNER_SUBJECT_PATTERN.match?(subject)
end

.validate!(raw) ⇒ Object

Raises:



45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/textus/manifest/schema.rb', line 45

def self.validate!(raw)
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)

  walk(raw, ROOT_KEYS, "$")
  validate_roles!(raw["roles"])
  validate_zones!(raw["zones"])
  validate_entries!(raw["entries"])
  validate_owners!(raw["zones"], raw["entries"])
  validate_rules!(raw["rules"])
  walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
  validate_single_queue!(raw)
  validate_zone_kind_consistency!(raw)
end

.validate_entries!(entries) ⇒ Object



73
74
75
76
77
78
79
80
# File 'lib/textus/manifest/schema.rb', line 73

def self.validate_entries!(entries)
  Array(entries).each_with_index do |e, i|
    path = "$.entries[#{i}]"
    walk(e, ENTRY_KEYS, path)
    walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
    walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
  end
end

.validate_fetch_timeout!(value, path) ⇒ Object

Raises:



167
168
169
170
171
172
173
174
# File 'lib/textus/manifest/schema.rb', line 167

def self.validate_fetch_timeout!(value, path)
  return if value.nil?
  return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING

  raise BadManifest.new(
    "fetch_timeout_seconds at '#{path}' must be a positive integer ≤ #{FETCH_TIMEOUT_SECONDS_CEILING} (got #{value.inspect})",
  )
end

.validate_owners!(zones, entries) ⇒ Object

Owners are validated against the SAME closed archetype set as role names (ADR 0045 D1) so attribution can’t bypass the closed-name guarantee. Applies to both zone owners and entry owners; owner is optional, so a nil owner is not an error.



130
131
132
133
134
135
136
137
# File 'lib/textus/manifest/schema.rb', line 130

def self.validate_owners!(zones, entries)
  Array(zones).each_with_index do |z, i|
    check_owner!(z["owner"], "$.zones[#{i}]")
  end
  Array(entries).each_with_index do |e, i|
    check_owner!(e["owner"], "$.entries[#{i}]")
  end
end

.validate_roles!(roles) ⇒ Object

Raises:



94
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
124
# File 'lib/textus/manifest/schema.rb', line 94

def self.validate_roles!(roles)
  return if roles.nil?
  raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)

  roles.each_with_index do |r, i|
    path = "$.roles[#{i}]"
    walk(r, ROLE_KEYS, path)
    name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
    unless Textus::Role::NAMES.include?(name)
      raise BadManifest.new(
        "unknown role name '#{name}' at '#{path}' " \
        "(allowed: #{Textus::Role::NAMES.join(", ")})",
      )
    end
    Array(r["can"]).each do |verb|
      next if CAPABILITIES.include?(verb)

      raise BadManifest.new(
        "unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
        "(known: #{CAPABILITIES.join(", ")})",
      )
    end
  end

  author_holders = roles.count { |r| Array(r["can"]).include?("author") }
  return if author_holders <= 1

  raise BadManifest.new(
    "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
  )
end

.validate_rules!(rules) ⇒ Object



82
83
84
85
86
87
88
89
90
91
92
# File 'lib/textus/manifest/schema.rb', line 82

def self.validate_rules!(rules)
  Array(rules).each_with_index do |r, i|
    path = "$.rules[#{i}]"
    walk(r, RULE_KEYS, path)
    if r["fetch"].is_a?(Hash)
      walk(r["fetch"], FETCH_KEYS, "#{path}.fetch")
      validate_fetch_timeout!(r["fetch"]["fetch_timeout_seconds"], "#{path}.fetch.fetch_timeout_seconds")
    end
    walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
  end
end

.validate_single_queue!(raw) ⇒ Object

Raises:



186
187
188
189
190
191
192
193
# File 'lib/textus/manifest/schema.rb', line 186

def self.validate_single_queue!(raw)
  queues = Array(raw["zones"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
  return if queues.size <= 1

  raise BadManifest.new(
    "at most one zone may declare kind: queue (found: #{queues.join(", ")})",
  )
end

.validate_zone_kind_consistency!(raw) ⇒ Object

Write authority is derived from capabilities (ADR 0030): a zone of a given kind can only be written by a role that holds the kind’s required verb. Reject a manifest declaring a zone whose required verb is held by no role. Capabilities.resolve returns the defaults when ‘roles:` is nil, so the capability union is all four verbs and every kind is satisfied.



200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/textus/manifest/schema.rb', line 200

def self.validate_zone_kind_consistency!(raw)
  held = Capabilities.resolve(raw["roles"]).values.flatten.uniq

  Array(raw["zones"]).each_with_index do |z, i|
    verb = KIND_REQUIRES_VERB[z["kind"]]
    next if verb.nil? || held.include?(verb)

    raise BadManifest.new(
      "zone '#{z["name"]}' (#{z["kind"]}) at '$.zones[#{i}]' " \
      "needs a role with capability '#{verb}'; none declared",
    )
  end
end

.validate_zones!(zones) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/textus/manifest/schema.rb', line 59

def self.validate_zones!(zones)
  Array(zones).each_with_index do |z, i|
    walk(z, ZONE_KEYS, "$.zones[#{i}]")
    if z["kind"].nil?
      raise BadManifest.new("zone '#{z["name"]}' at '$.zones[#{i}]' must declare a kind (one of: #{ZONE_KINDS.join(", ")})")
    end
    next if ZONE_KINDS.include?(z["kind"])

    raise BadManifest.new(
      "unknown zone kind '#{z["kind"]}' at '$.zones[#{i}]' (known: #{ZONE_KINDS.join(", ")})",
    )
  end
end

.walk(hash, allowed, path) ⇒ Object



176
177
178
179
180
181
182
183
184
# File 'lib/textus/manifest/schema.rb', line 176

def self.walk(hash, allowed, path)
  return unless hash.is_a?(Hash)

  hash.each_key do |k|
    next if allowed.include?(k)

    raise BadManifest.new("unknown key '#{k}' at '#{path}'")
  end
end