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
].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

Class Method Summary collapse

Class Method Details

.validate!(raw) ⇒ Object

Raises:



38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/textus/manifest/schema.rb', line 38

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_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



65
66
67
68
69
70
71
72
# File 'lib/textus/manifest/schema.rb', line 65

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:



112
113
114
115
116
117
118
119
# File 'lib/textus/manifest/schema.rb', line 112

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_roles!(roles) ⇒ Object

Raises:



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/textus/manifest/schema.rb', line 86

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")
    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



74
75
76
77
78
79
80
81
82
83
84
# File 'lib/textus/manifest/schema.rb', line 74

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:



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

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.



145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/textus/manifest/schema.rb', line 145

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



51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/textus/manifest/schema.rb', line 51

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



121
122
123
124
125
126
127
128
129
# File 'lib/textus/manifest/schema.rb', line 121

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