textus 0.29.0 → 0.35.1

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -235
  3. data/CHANGELOG.md +169 -0
  4. data/README.md +85 -64
  5. data/SPEC.md +366 -201
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +93 -76
  8. data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
  9. data/lib/textus/cli/verb/build.rb +1 -1
  10. data/lib/textus/cli/verb/fetch.rb +14 -0
  11. data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
  12. data/lib/textus/cli/verb/get.rb +1 -1
  13. data/lib/textus/cli/verb/hook_run.rb +2 -6
  14. data/lib/textus/cli/verb/hooks.rb +1 -1
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +8 -8
  18. data/lib/textus/cli.rb +21 -18
  19. data/lib/textus/container.rb +1 -2
  20. data/lib/textus/dispatcher.rb +11 -3
  21. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
  22. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  23. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  24. data/lib/textus/doctor/check.rb +8 -5
  25. data/lib/textus/doctor.rb +2 -1
  26. data/lib/textus/domain/action.rb +3 -3
  27. data/lib/textus/domain/duration.rb +22 -0
  28. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  29. data/lib/textus/domain/freshness/policy.rb +2 -2
  30. data/lib/textus/domain/freshness.rb +7 -7
  31. data/lib/textus/domain/outcome.rb +2 -2
  32. data/lib/textus/domain/permission.rb +2 -10
  33. data/lib/textus/domain/policy/base_guards.rb +25 -0
  34. data/lib/textus/domain/policy/evaluation.rb +18 -0
  35. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
  36. data/lib/textus/domain/policy/guard.rb +35 -0
  37. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  38. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  39. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  40. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  41. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  42. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  43. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  44. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  45. data/lib/textus/domain/policy/retention.rb +26 -0
  46. data/lib/textus/domain/retention.rb +44 -0
  47. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  48. data/lib/textus/envelope/io/reader.rb +4 -0
  49. data/lib/textus/envelope/io/writer.rb +8 -0
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +12 -24
  53. data/lib/textus/hooks/rpc_registry.rb +9 -35
  54. data/lib/textus/hooks/signature.rb +31 -0
  55. data/lib/textus/init.rb +24 -18
  56. data/lib/textus/maintenance/zone_mv.rb +1 -1
  57. data/lib/textus/manifest/capabilities.rb +29 -0
  58. data/lib/textus/manifest/data.rb +16 -8
  59. data/lib/textus/manifest/entry/base.rb +2 -2
  60. data/lib/textus/manifest/policy.rb +62 -19
  61. data/lib/textus/manifest/rules.rb +25 -14
  62. data/lib/textus/manifest/schema.rb +78 -38
  63. data/lib/textus/manifest.rb +6 -5
  64. data/lib/textus/mcp/server.rb +2 -10
  65. data/lib/textus/mcp/session.rb +7 -23
  66. data/lib/textus/mcp/tool_schemas.rb +3 -3
  67. data/lib/textus/mcp/tools.rb +7 -7
  68. data/lib/textus/ports/audit_subscriber.rb +1 -1
  69. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  70. data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
  71. data/lib/textus/projection.rb +1 -1
  72. data/lib/textus/read/freshness.rb +9 -9
  73. data/lib/textus/read/get.rb +8 -8
  74. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  75. data/lib/textus/read/policy_explain.rb +19 -10
  76. data/lib/textus/read/pulse.rb +5 -4
  77. data/lib/textus/read/retainable.rb +17 -0
  78. data/lib/textus/read/validator.rb +1 -1
  79. data/lib/textus/role_scope.rb +3 -2
  80. data/lib/textus/schema/tools.rb +5 -5
  81. data/lib/textus/version.rb +1 -1
  82. data/lib/textus/write/accept.rb +19 -55
  83. data/lib/textus/write/delete.rb +15 -17
  84. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
  85. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  86. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
  87. data/lib/textus/write/intake_fetch.rb +23 -0
  88. data/lib/textus/write/mv.rb +17 -15
  89. data/lib/textus/write/put.rb +15 -17
  90. data/lib/textus/write/reject.rb +11 -5
  91. data/lib/textus/write/retention_sweep.rb +55 -0
  92. metadata +32 -18
  93. data/lib/textus/cli/verb/refresh.rb +0 -14
  94. data/lib/textus/domain/authorizer.rb +0 -37
  95. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  96. data/lib/textus/domain/policy/promote.rb +0 -26
  97. data/lib/textus/domain/policy/promotion.rb +0 -57
  98. data/lib/textus/manifest/role_kinds.rb +0 -21
  99. data/lib/textus/write/authority_gate.rb +0 -24
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ class Manifest
3
+ # Resolves a manifest's `roles:` block (or the absence of one) into a
4
+ # capability map: { role_name => [verbs] }. Verbs are a subset of the
5
+ # closed capability set (Schema::CAPABILITIES). See ADR 0030.
6
+ module Capabilities
7
+ # Fallback role set for a manifest that omits `roles:` entirely. Agent
8
+ # is intentionally minimal here (`propose` only) — narrower than the
9
+ # `textus init` scaffold, which declares `agent: [propose, keep]` so the
10
+ # default `notebook` workspace is writable. A roles-less manifest that
11
+ # declares a `kind: workspace` zone is therefore rejected at load (no
12
+ # `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
13
+ DEFAULT_MAPPING = {
14
+ "human" => %w[author propose].freeze,
15
+ "agent" => %w[propose].freeze,
16
+ "automation" => %w[fetch build].freeze,
17
+ }.freeze
18
+
19
+ # Returns { role_name => [verbs] }. When `roles:` is declared we use
20
+ # exactly that; defaults are *not* layered in (declaring roles is an
21
+ # opt-in to a fully user-defined vocabulary).
22
+ def self.resolve(raw_roles)
23
+ return DEFAULT_MAPPING if raw_roles.nil?
24
+
25
+ raw_roles.to_h { |r| [r["name"], Array(r["can"]).freeze] }.freeze
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,17 +1,19 @@
1
1
  require_relative "schema"
2
- require_relative "role_kinds"
2
+ require_relative "capabilities"
3
3
 
4
4
  module Textus
5
5
  class Manifest
6
6
  # Immutable, parsed view of a manifest YAML document.
7
7
  #
8
- # Holds raw structural data (zones, entries, audit_config, role_mapping)
8
+ # Holds raw structural data (zones, entries, audit_config, role_caps)
9
9
  # but no behaviour beyond accessors. Behaviour (zone authority, key
10
10
  # resolution, rules) lives on Manifest::Policy / Resolver / Rules.
11
11
  class Data
12
12
  AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
13
 
14
- attr_reader :raw, :root, :entries, :zones, :zone_readers, :audit_config, :role_mapping, :policy
14
+ attr_reader :raw, :root, :entries, :declared_zone_kinds,
15
+ :zone_descs, :zone_owners,
16
+ :audit_config, :role_caps, :policy
15
17
 
16
18
  def self.validate_key!(key)
17
19
  raise UsageError.new("empty key") if key.nil? || key.empty?
@@ -33,13 +35,19 @@ module Textus
33
35
  def initialize(raw:, root:)
34
36
  @raw = raw
35
37
  @root = root
36
- @zones = Array(raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
37
- @zone_readers = Array(raw["zones"]).to_h do |z|
38
- rp = z["read_policy"]
39
- [z["name"], rp.nil? ? :all : Array(rp)]
38
+ # Write authority is derived from capabilities × zone-kind (ADR 0030),
39
+ # not a per-zone writer list. "Which zones are declared" lives in the
40
+ # one kind-keyed map below (declared_zone_kinds); membership checks by
41
+ # read-side callers (boot, maintenance/zone_mv) use its keyset (ADR 0034).
42
+ @declared_zone_kinds = Array(raw["zones"]).to_h do |z|
43
+ [z["name"], z["kind"]&.to_sym]
40
44
  end
45
+ @zone_descs = Array(raw["zones"]).to_h { |z| [z["name"], z["desc"]] }
46
+ # Only zones that actually declare an owner — keep nil-tombstones out so a
47
+ # future `zone_owners.key?(name)` means "owner declared", not "zone exists".
48
+ @zone_owners = Array(raw["zones"]).to_h { |z| [z["name"], z["owner"]] }.compact
41
49
  @audit_config = build_audit_config(raw)
42
- @role_mapping = RoleKinds.resolve(raw["roles"])
50
+ @role_caps = Capabilities.resolve(raw["roles"])
43
51
  # Policy is constructed before entries because Entry validators
44
52
  # call `entry.in_generator_zone?(policy)` and similar helpers
45
53
  # that take Policy as an argument.
@@ -23,8 +23,8 @@ module Textus
23
23
  raise UsageError.new("entry '#{@key}': #{e.message}")
24
24
  end
25
25
 
26
- def in_generator_zone?(policy) = policy.zone_kinds(@zone).include?(:generator)
27
- def in_proposal_zone?(policy) = policy.zone_kinds(@zone).include?(:proposer)
26
+ def in_generator_zone?(policy) = policy.derived_zone?(@zone)
27
+ def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
28
28
 
29
29
  def nested? = false
30
30
  def derived? = false
@@ -2,46 +2,89 @@ module Textus
2
2
  class Manifest
3
3
  # Authority over zones and roles derived from a Manifest::Data snapshot.
4
4
  # Encapsulates the lookups previously living on Manifest itself
5
- # (zone_writers, zone_kinds, permission_for, role_kind, roles_with_kind).
5
+ # (zone_writers, permission_for). Write authority is derived from
6
+ # capabilities × zone-kind (ADR 0030): each zone-kind requires one verb
7
+ # (Schema::KIND_REQUIRES_VERB) and a role may write a zone iff its caps
8
+ # include that verb (verb_for_zone, roles_with_capability). Derived /
9
+ # proposal-queue status is authoritative via the declared-kind family
10
+ # (declared_kind, derived_zone?, queue_zone?, queue_zone).
6
11
  class Policy
7
12
  def initialize(data)
8
13
  @data = data
9
- @zone_kinds_cache = {}
10
14
  end
11
15
 
12
- def zone_writers(zone_name)
13
- @data.zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
16
+ # The capability a zone's kind requires to be written, or nil if the
17
+ # zone declares no kind. declared_kind returns a Symbol; the table is
18
+ # keyed by String.
19
+ def verb_for_zone(zone_name)
20
+ kind = declared_kind(zone_name)
21
+ kind && Schema::KIND_REQUIRES_VERB[kind.to_s]
22
+ end
23
+
24
+ # Names of roles whose declared caps include `verb`.
25
+ def roles_with_capability(verb)
26
+ @data.role_caps.select { |_name, caps| caps.include?(verb) }.keys
14
27
  end
15
28
 
16
- def zone_readers
17
- @data.zone_readers
29
+ # The conventional automated proposer: a role that can propose but is not
30
+ # the author-anchor (so it resolves to `agent`, not `human`, under the
31
+ # default mapping). Falls back to the first proposer, then nil.
32
+ def proposer_role
33
+ proposers = roles_with_capability("propose")
34
+ (proposers - roles_with_capability("author")).first || proposers.first
35
+ end
36
+
37
+ # The roles authorized to write `zone_name`: those holding the verb its
38
+ # kind requires. Raises on an undeclared zone.
39
+ def zone_writers(zone_name)
40
+ raise UsageError.new("undeclared zone '#{zone_name}'") unless @data.declared_zone_kinds.key?(zone_name)
41
+
42
+ roles_with_capability(verb_for_zone(zone_name))
18
43
  end
19
44
 
20
45
  def permission_for(zone_name)
21
46
  Textus::Domain::Permission.new(
22
47
  zone: zone_name,
23
- write_policy: zone_writers(zone_name),
24
- read_policy: @data.zone_readers[zone_name] || :all,
48
+ writers: zone_writers(zone_name),
25
49
  )
26
50
  end
27
51
 
28
- def zone_kinds(zone_name)
29
- @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
30
- k = role_kind(w)
31
- acc << k if k
32
- end.freeze
52
+ # The kind declared on a zone in the manifest, or nil if undeclared.
53
+ def declared_kind(zone_name)
54
+ @data.declared_zone_kinds[zone_name]
33
55
  end
34
56
 
35
- def role_mapping
36
- @data.role_mapping
57
+ # Zone names declaring `kind` (a Symbol), in manifest order. Lets callers
58
+ # (boot) name a kind's live zone instance(s) instead of hardcoding names.
59
+ def zones_of_kind(kind)
60
+ @data.declared_zone_kinds.select { |_name, k| k == kind }.keys
37
61
  end
38
62
 
39
- def role_kind(name)
40
- @data.role_mapping[name]
63
+ # The single zone declaring `kind: queue`, or nil. Schema guarantees <=1.
64
+ def queue_zone
65
+ @data.declared_zone_kinds.key(:queue)
41
66
  end
42
67
 
43
- def roles_with_kind(kind)
44
- @data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
68
+ # A zone is derived iff it declares kind: derived.
69
+ def derived_zone?(zone_name)
70
+ declared_kind(zone_name) == :derived
71
+ end
72
+
73
+ # A zone is a proposal queue iff it declares kind: queue.
74
+ def queue_zone?(zone_name)
75
+ declared_kind(zone_name) == :queue
76
+ end
77
+
78
+ # The zone a proposer role writes proposals into: the single zone that
79
+ # declares kind: queue, when the role can write it. Returns nil if there
80
+ # is no queue zone or the role cannot write it.
81
+ def propose_zone_for(role)
82
+ return nil if role.nil?
83
+
84
+ q = queue_zone
85
+ return nil unless q && zone_writers(q).include?(role)
86
+
87
+ q
45
88
  end
46
89
  end
47
90
  end
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Rules
4
- RuleSet = ::Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
- EMPTY_SET = RuleSet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
4
+ RuleSet = ::Data.define(:fetch, :handler_allowlist, :guard, :retention)
5
+ EMPTY_SET = RuleSet.new(fetch: nil, handler_allowlist: nil, guard: nil, retention: nil)
6
6
 
7
7
  def self.parse(raw)
8
8
  new(Array(raw).map { |b| Block.new(b) })
@@ -15,16 +15,16 @@ module Textus
15
15
  attr_reader :blocks
16
16
 
17
17
  def for(key)
18
- slots = { refresh: [], handler_allowlist: [], promote: [], retention: [] }
18
+ slots = { fetch: [], handler_allowlist: [], guard: [], retention: [] }
19
19
  @blocks.each do |b|
20
20
  next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
21
21
 
22
22
  slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
23
23
  end
24
24
  RuleSet.new(
25
- refresh: pick(slots[:refresh], :refresh, key),
25
+ fetch: pick(slots[:fetch], :fetch, key),
26
26
  handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
27
- promote: pick(slots[:promote], :promote, key),
27
+ guard: pick(slots[:guard], :guard, key),
28
28
  retention: pick(slots[:retention], :retention, key),
29
29
  )
30
30
  end
@@ -44,22 +44,22 @@ module Textus
44
44
  end
45
45
 
46
46
  class Block
47
- attr_reader :match, :refresh, :handler_allowlist, :promote, :retention
47
+ attr_reader :match, :fetch, :handler_allowlist, :guard, :retention
48
48
 
49
49
  def initialize(raw)
50
50
  @match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
51
- @refresh = parse_refresh(raw["refresh"])
51
+ @fetch = parse_fetch(raw["fetch"])
52
52
  @handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
53
- @promote = parse_promotion(raw["promotion"])
54
- @retention = raw["retention"] # reserved — passthrough only
53
+ @guard = parse_guard(raw["guard"])
54
+ @retention = parse_retention(raw["retention"])
55
55
  end
56
56
 
57
57
  private
58
58
 
59
- def parse_refresh(h)
59
+ def parse_fetch(h)
60
60
  return nil if h.nil?
61
61
 
62
- Textus::Domain::Policy::Refresh.new(
62
+ Textus::Domain::Policy::Fetch.new(
63
63
  ttl: h["ttl"],
64
64
  on_stale: h["on_stale"] || "warn",
65
65
  sync_budget_ms: h["sync_budget_ms"],
@@ -73,12 +73,23 @@ module Textus
73
73
  Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
74
74
  end
75
75
 
76
- def parse_promotion(h)
76
+ # A guard: block is a map of transition => [predicate specs]. Predicate
77
+ # names are validated at GuardFactory build time via Predicates::Registry
78
+ # (ADR 0031); here we only assert the structural shape.
79
+ def parse_guard(h)
77
80
  return nil if h.nil?
81
+ raise Textus::BadManifest.new("guard: must be a map of transition => [predicates]") unless h.is_a?(Hash)
78
82
 
79
- raise Textus::BadManifest.new("promotion: must be a hash with a 'requires:' array") unless h.is_a?(Hash) && h.key?("requires")
83
+ h
84
+ end
85
+
86
+ def parse_retention(h)
87
+ return nil if h.nil?
80
88
 
81
- Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
89
+ Textus::Domain::Policy::Retention.new(
90
+ expire_after: h["expire_after"],
91
+ archive_after: h["archive_after"],
92
+ )
82
93
  end
83
94
  end
84
95
  end
@@ -2,21 +2,38 @@ module Textus
2
2
  class Manifest
3
3
  module Schema
4
4
  ROOT_KEYS = %w[version roles zones entries rules audit].freeze
5
- ROLE_KEYS = %w[name kind].freeze
6
- ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
7
- ZONE_KEYS = %w[name write_policy read_policy].freeze
8
- ENTRY_KEYS = %w[
5
+ ROLE_KEYS = %w[name can].freeze
6
+ ZONE_KEYS = %w[name kind owner desc].freeze
7
+ # The closed coordination vocabulary (ADR 0028; completed at five in ADR
8
+ # 0033; unified here in ADR 0034). Each lane pairs a zone-kind with the
9
+ # single capability that authorizes originating bytes in it — a total
10
+ # bijection. This table is the ONE source of truth; the three legacy
11
+ # constants below are derived from it so a zone-kind and its required
12
+ # capability cannot drift. Key order is canon-first so the unknown-kind
13
+ # error message reads canon, workspace, quarantine, queue, derived.
14
+ LANES = {
15
+ "canon" => "author",
16
+ "workspace" => "keep",
17
+ "quarantine" => "fetch",
18
+ "queue" => "propose",
19
+ "derived" => "build",
20
+ }.freeze
21
+
22
+ ZONE_KINDS = LANES.keys.freeze
23
+ CAPABILITIES = LANES.values.freeze
24
+ KIND_REQUIRES_VERB = LANES
25
+ ENTRY_KEYS = %w[
9
26
  key path zone kind schema owner nested format
10
27
  compute template publish_to publish_each
11
28
  intake events inject_boot index_filename
12
29
  ].freeze
13
30
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
14
31
  INTAKE_KEYS = %w[handler config].freeze
15
- RULE_KEYS = %w[match refresh intake_handler_allowlist promotion retention].freeze
16
- REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
32
+ RULE_KEYS = %w[match fetch intake_handler_allowlist guard retention].freeze
33
+ FETCH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
17
34
  FETCH_TIMEOUT_SECONDS_CEILING = 3600
18
- PROMOTION_KEYS = %w[requires].freeze
19
- AUDIT_KEYS = %w[max_size keep].freeze
35
+ RETENTION_KEYS = %w[expire_after archive_after].freeze
36
+ AUDIT_KEYS = %w[max_size keep].freeze
20
37
 
21
38
  def self.validate!(raw)
22
39
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
@@ -27,12 +44,21 @@ module Textus
27
44
  validate_entries!(raw["entries"])
28
45
  validate_rules!(raw["rules"])
29
46
  walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
30
- validate_zone_writers_declared!(raw)
47
+ validate_single_queue!(raw)
48
+ validate_zone_kind_consistency!(raw)
31
49
  end
32
50
 
33
51
  def self.validate_zones!(zones)
34
52
  Array(zones).each_with_index do |z, i|
35
53
  walk(z, ZONE_KEYS, "$.zones[#{i}]")
54
+ if z["kind"].nil?
55
+ raise BadManifest.new("zone '#{z["name"]}' at '$.zones[#{i}]' must declare a kind (one of: #{ZONE_KINDS.join(", ")})")
56
+ end
57
+ next if ZONE_KINDS.include?(z["kind"])
58
+
59
+ raise BadManifest.new(
60
+ "unknown zone kind '#{z["kind"]}' at '$.zones[#{i}]' (known: #{ZONE_KINDS.join(", ")})",
61
+ )
36
62
  end
37
63
  end
38
64
 
@@ -49,27 +75,11 @@ module Textus
49
75
  Array(rules).each_with_index do |r, i|
50
76
  path = "$.rules[#{i}]"
51
77
  walk(r, RULE_KEYS, path)
52
- if r["refresh"].is_a?(Hash)
53
- walk(r["refresh"], REFRESH_KEYS, "#{path}.refresh")
54
- validate_fetch_timeout!(r["refresh"]["fetch_timeout_seconds"], "#{path}.refresh.fetch_timeout_seconds")
55
- end
56
- walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
57
- end
58
- end
59
-
60
- def self.validate_zone_writers_declared!(raw)
61
- return if raw["roles"].nil? # default mapping is permissive
62
-
63
- declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
64
- Array(raw["zones"]).each do |z|
65
- Array(z["write_policy"]).each_with_index do |w, j|
66
- next if declared.include?(w)
67
-
68
- raise BadManifest.new(
69
- "zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
70
- "(declared roles: #{declared.to_a.join(", ")})",
71
- )
78
+ if r["fetch"].is_a?(Hash)
79
+ walk(r["fetch"], FETCH_KEYS, "#{path}.fetch")
80
+ validate_fetch_timeout!(r["fetch"]["fetch_timeout_seconds"], "#{path}.fetch.fetch_timeout_seconds")
72
81
  end
82
+ walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
73
83
  end
74
84
  end
75
85
 
@@ -77,23 +87,25 @@ module Textus
77
87
  return if roles.nil?
78
88
  raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
79
89
 
80
- accept_authority_count = 0
81
90
  roles.each_with_index do |r, i|
82
91
  path = "$.roles[#{i}]"
83
92
  walk(r, ROLE_KEYS, path)
84
93
  name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
85
- kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
86
- unless ROLE_KINDS.include?(kind)
87
- raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
88
- end
94
+ Array(r["can"]).each do |verb|
95
+ next if CAPABILITIES.include?(verb)
89
96
 
90
- accept_authority_count += 1 if kind == "accept_authority"
97
+ raise BadManifest.new(
98
+ "unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
99
+ "(known: #{CAPABILITIES.join(", ")})",
100
+ )
101
+ end
91
102
  end
92
- return unless accept_authority_count > 1
103
+
104
+ author_holders = roles.count { |r| Array(r["can"]).include?("author") }
105
+ return if author_holders <= 1
93
106
 
94
107
  raise BadManifest.new(
95
- "manifest declares #{accept_authority_count} accept_authority roles; " \
96
- "at most one accept_authority role is allowed",
108
+ "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
97
109
  )
98
110
  end
99
111
 
@@ -115,6 +127,34 @@ module Textus
115
127
  raise BadManifest.new("unknown key '#{k}' at '#{path}'")
116
128
  end
117
129
  end
130
+
131
+ def self.validate_single_queue!(raw)
132
+ queues = Array(raw["zones"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
133
+ return if queues.size <= 1
134
+
135
+ raise BadManifest.new(
136
+ "at most one zone may declare kind: queue (found: #{queues.join(", ")})",
137
+ )
138
+ end
139
+
140
+ # Write authority is derived from capabilities (ADR 0030): a zone of a
141
+ # given kind can only be written by a role that holds the kind's required
142
+ # verb. Reject a manifest declaring a zone whose required verb is held by
143
+ # no role. Capabilities.resolve returns the defaults when `roles:` is nil,
144
+ # so the capability union is all four verbs and every kind is satisfied.
145
+ def self.validate_zone_kind_consistency!(raw)
146
+ held = Capabilities.resolve(raw["roles"]).values.flatten.uniq
147
+
148
+ Array(raw["zones"]).each_with_index do |z, i|
149
+ verb = KIND_REQUIRES_VERB[z["kind"]]
150
+ next if verb.nil? || held.include?(verb)
151
+
152
+ raise BadManifest.new(
153
+ "zone '#{z["name"]}' (#{z["kind"]}) at '$.zones[#{i}]' " \
154
+ "needs a role with capability '#{verb}'; none declared",
155
+ )
156
+ end
157
+ end
118
158
  end
119
159
  end
120
160
  end
@@ -4,12 +4,13 @@ module Textus
4
4
  # Manifest is the composition record for a parsed manifest. It bundles
5
5
  # four collaborators:
6
6
  #
7
- # * data — frozen value: raw, root, zones, entries, audit_config, role_mapping
7
+ # * data — frozen value: raw, root, zones, entries, audit_config, role_caps
8
8
  # * resolver — resolves keys → entry + path
9
- # * policy — zone/role authority (zone_writers, zone_kinds, permission_for, …)
10
- # * rules — match-block rule engine (refresh, handler allowlist, promotion, …)
9
+ # * policy — zone/role authority (zone_writers, declared_kind/derived_zone?/
10
+ # queue_zone?, permission_for, …)
11
+ # * rules — match-block rule engine (fetch, handler allowlist, promotion, …)
11
12
  #
12
- # Use `manifest.data.entries`, `manifest.policy.zone_kinds(z)`, etc.
13
+ # Use `manifest.data.entries`, `manifest.policy.declared_kind(z)`, etc.
13
14
  Manifest = Data.define(:data, :resolver, :policy, :rules)
14
15
  end
15
16
 
@@ -17,7 +18,7 @@ require_relative "manifest/schema"
17
18
  require_relative "manifest/data"
18
19
  require_relative "manifest/policy"
19
20
  require_relative "manifest/resolver"
20
- require_relative "manifest/role_kinds"
21
+ require_relative "manifest/capabilities"
21
22
 
22
23
  # Reopen Textus::Manifest (defined above as a Data.define) to attach
23
24
  # class-level loaders and helpers.
@@ -49,16 +49,8 @@ module Textus
49
49
  end
50
50
 
51
51
  def handle_initialize(rid, _params)
52
- proposer = @store.manifest.policy.roles_with_kind(:proposer).first
53
- propose_zone = nil
54
- if proposer
55
- @store.manifest.data.zones.each do |zname, writers|
56
- if writers.include?(proposer) && zname.include?("review")
57
- propose_zone = zname
58
- break
59
- end
60
- end
61
- end
52
+ proposer = @store.manifest.policy.proposer_role
53
+ propose_zone = @store.manifest.policy.propose_zone_for(proposer)
62
54
 
63
55
  @session = Session.new(
64
56
  role: @role,
@@ -1,29 +1,15 @@
1
1
  module Textus
2
2
  module MCP
3
- # Per-connection state held by the server. Immutable; advance_cursor
4
- # returns a new instance.
5
- class Session
6
- attr_reader :role, :cursor, :propose_zone, :manifest_etag
7
-
8
- def initialize(role:, cursor:, propose_zone:, manifest_etag:)
9
- @role = role
10
- @cursor = cursor
11
- @propose_zone = propose_zone
12
- @manifest_etag = manifest_etag
13
- end
14
-
15
- def advance_cursor(new_cursor)
16
- self.class.new(
17
- role: @role, cursor: new_cursor,
18
- propose_zone: @propose_zone, manifest_etag: @manifest_etag
19
- )
20
- end
3
+ # Per-connection state held by the server. Immutable Data value;
4
+ # advance_cursor returns a new instance via #with.
5
+ Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
6
+ def advance_cursor(new_cursor) = with(cursor: new_cursor)
21
7
 
22
8
  def check_etag!(observed_etag)
23
- return if observed_etag == @manifest_etag
9
+ return if observed_etag == manifest_etag
24
10
 
25
11
  raise ContractDrift.new(
26
- "manifest changed (was #{short_etag(@manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
12
+ "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
27
13
  )
28
14
  end
29
15
 
@@ -32,9 +18,7 @@ module Textus
32
18
  # First 8 hex chars after the "sha256:" prefix — a stable short id for
33
19
  # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
34
20
  # a no-op when the prefix is absent).
35
- def short_etag(etag)
36
- etag.to_s.delete_prefix("sha256:")[0, 8]
37
- end
21
+ def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
38
22
  end
39
23
  end
40
24
  end
@@ -29,16 +29,16 @@ module Textus
29
29
  "meta" => { "type" => "object" },
30
30
  "body" => { "type" => "string" },
31
31
  }, %w[key meta]),
32
- tool("refresh", "Run an intake refresh for one key. Returns the refresh Outcome.",
32
+ tool("fetch", "Run an intake fetch for one key. Returns the fetch Outcome.",
33
33
  { "key" => { "type" => "string" } }, ["key"]),
34
- tool("refresh_stale", "Refresh all stale intake entries, optionally scoped by zone/prefix.",
34
+ tool("fetch_stale", "Fetch all stale intake entries, optionally scoped by zone/prefix.",
35
35
  {
36
36
  "zone" => { "type" => "string" },
37
37
  "prefix" => { "type" => "string" },
38
38
  }, []),
39
39
  tool("schema", "Return the schema (field shape) for an entry family.",
40
40
  { "family" => { "type" => "string" } }, ["family"]),
41
- tool("rules", "Return effective rules for a key (refresh, promote, ...).",
41
+ tool("rules", "Return effective rules for a key (fetch, promote, ...).",
42
42
  { "key" => { "type" => "string" } }, ["key"]),
43
43
  tool("key_mv_prefix",
44
44
  "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
@@ -64,14 +64,14 @@ module Textus
64
64
  { "uid" => env.uid, "etag" => env.etag, "key" => target }
65
65
  end,
66
66
 
67
- "refresh" => lambda do |s, store, args|
68
- key = args.fetch("key") { raise ToolError.new("refresh: missing key") }
69
- outcome = ops_for(s, store).refresh(key)
67
+ "fetch" => lambda do |s, store, args|
68
+ key = args.fetch("key") { raise ToolError.new("fetch: missing key") }
69
+ outcome = ops_for(s, store).fetch(key)
70
70
  { "outcome" => outcome.class.name.split("::").last.downcase }
71
71
  end,
72
72
 
73
- "refresh_stale" => lambda do |s, store, args|
74
- ops_for(s, store).refresh_all(zone: args["zone"], prefix: args["prefix"])
73
+ "fetch_stale" => lambda do |s, store, args|
74
+ ops_for(s, store).fetch_all(zone: args["zone"], prefix: args["prefix"])
75
75
  end,
76
76
 
77
77
  "schema" => lambda do |_s, store, args|
@@ -83,8 +83,8 @@ module Textus
83
83
  key = args.fetch("key") { raise ToolError.new("rules: missing key") }
84
84
  set = store.manifest.rules.for(key)
85
85
  {
86
- "refresh" => set.refresh&.to_h,
87
- "promote" => set.respond_to?(:promote) ? set.promote&.to_h : nil,
86
+ "fetch" => set.fetch&.to_h,
87
+ "guard" => set.guard,
88
88
  }.compact
89
89
  end,
90
90
 
@@ -33,7 +33,7 @@ module Textus
33
33
  extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
34
34
  extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
35
35
  @audit_log.append(
36
- role: "runner", verb: "event_error", key: key,
36
+ role: "automation", verb: "event_error", key: key,
37
37
  etag_before: nil, etag_after: nil, extras: extras
38
38
  )
39
39
  end
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  module Ports
3
- module Refresh
3
+ module Fetch
4
4
  module Detached
5
5
  module_function
6
6
 
@@ -16,14 +16,14 @@ module Textus
16
16
  $stdout.reopen(File::NULL, "w")
17
17
  $stderr.reopen(File::NULL, "w")
18
18
 
19
- lock = Textus::Ports::Refresh::Lock.new(root: store_root, key: key)
19
+ lock = Textus::Ports::Fetch::Lock.new(root: store_root, key: key)
20
20
  exit(0) unless lock.try_acquire
21
21
 
22
22
  begin
23
23
  store = Textus::Store.new(store_root)
24
- store.as("runner").refresh(key)
24
+ store.as("automation").fetch(key)
25
25
  rescue StandardError
26
- # Already logged via :refresh_failed; exit cleanly.
26
+ # Already logged via :fetch_failed; exit cleanly.
27
27
  ensure
28
28
  lock.release
29
29
  exit(0)
@@ -2,7 +2,7 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Ports
5
- module Refresh
5
+ module Fetch
6
6
  class Lock
7
7
  def initialize(root:, key:)
8
8
  @root = root