textus 0.30.0 → 0.38.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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -241
  3. data/CHANGELOG.md +221 -0
  4. data/README.md +89 -69
  5. data/SPEC.md +359 -212
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +122 -87
  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/hooks.rb +1 -1
  14. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  15. data/lib/textus/cli/verb/propose.rb +28 -0
  16. data/lib/textus/cli/verb/pulse.rb +12 -3
  17. data/lib/textus/cli/verb/put.rb +1 -1
  18. data/lib/textus/cli/verb/rule_list.rb +7 -7
  19. data/lib/textus/cli/verb/schema.rb +1 -1
  20. data/lib/textus/cli/verb.rb +3 -2
  21. data/lib/textus/cli.rb +2 -2
  22. data/lib/textus/container.rb +1 -2
  23. data/lib/textus/contract.rb +106 -0
  24. data/lib/textus/cursor_store.rb +24 -0
  25. data/lib/textus/dispatcher.rb +6 -4
  26. data/lib/textus/doctor/check/audit_log.rb +1 -1
  27. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
  28. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  29. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  30. data/lib/textus/doctor.rb +2 -1
  31. data/lib/textus/domain/action.rb +3 -3
  32. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  33. data/lib/textus/domain/freshness/policy.rb +2 -2
  34. data/lib/textus/domain/freshness.rb +7 -7
  35. data/lib/textus/domain/outcome.rb +2 -2
  36. data/lib/textus/domain/permission.rb +2 -10
  37. data/lib/textus/domain/policy/base_guards.rb +25 -0
  38. data/lib/textus/domain/policy/evaluation.rb +15 -0
  39. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
  40. data/lib/textus/domain/policy/guard.rb +35 -0
  41. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  42. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  43. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  44. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  45. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  46. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  47. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  48. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  49. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +4 -4
  53. data/lib/textus/init.rb +27 -18
  54. data/lib/textus/layout.rb +41 -0
  55. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  56. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  57. data/lib/textus/maintenance/migrate.rb +9 -0
  58. data/lib/textus/maintenance/rule_lint.rb +8 -0
  59. data/lib/textus/maintenance/zone_mv.rb +11 -1
  60. data/lib/textus/manifest/capabilities.rb +29 -0
  61. data/lib/textus/manifest/data.rb +14 -10
  62. data/lib/textus/manifest/policy.rb +37 -21
  63. data/lib/textus/manifest/rules.rb +16 -14
  64. data/lib/textus/manifest/schema.rb +48 -58
  65. data/lib/textus/manifest.rb +3 -3
  66. data/lib/textus/mcp/catalog.rb +72 -0
  67. data/lib/textus/mcp/server.rb +8 -5
  68. data/lib/textus/mcp/session.rb +3 -20
  69. data/lib/textus/mcp/tool_schemas.rb +6 -62
  70. data/lib/textus/mcp/tools.rb +4 -119
  71. data/lib/textus/ports/audit_log.rb +17 -15
  72. data/lib/textus/ports/audit_subscriber.rb +1 -1
  73. data/lib/textus/ports/build_lock.rb +1 -2
  74. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  75. data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/read/audit.rb +3 -3
  78. data/lib/textus/read/boot.rb +6 -0
  79. data/lib/textus/read/freshness.rb +9 -9
  80. data/lib/textus/read/get.rb +16 -8
  81. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  82. data/lib/textus/read/list.rb +8 -0
  83. data/lib/textus/read/policy_explain.rb +14 -10
  84. data/lib/textus/read/pulse.rb +12 -4
  85. data/lib/textus/read/rules.rb +24 -0
  86. data/lib/textus/read/schema_envelope.rb +7 -0
  87. data/lib/textus/read/validator.rb +1 -1
  88. data/lib/textus/role.rb +6 -2
  89. data/lib/textus/schema/tools.rb +5 -5
  90. data/lib/textus/session.rb +24 -0
  91. data/lib/textus/store.rb +11 -0
  92. data/lib/textus/version.rb +1 -1
  93. data/lib/textus/write/accept.rb +19 -55
  94. data/lib/textus/write/delete.rb +14 -2
  95. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
  96. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  97. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
  98. data/lib/textus/write/mv.rb +15 -3
  99. data/lib/textus/write/propose.rb +46 -0
  100. data/lib/textus/write/put.rb +26 -2
  101. data/lib/textus/write/reject.rb +11 -5
  102. data/lib/textus.rb +4 -0
  103. metadata +36 -21
  104. data/lib/textus/cli/verb/refresh.rb +0 -14
  105. data/lib/textus/domain/authorizer.rb +0 -37
  106. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  107. data/lib/textus/domain/policy/promote.rb +0 -26
  108. data/lib/textus/domain/policy/promotion.rb +0 -57
  109. data/lib/textus/manifest/role_kinds.rb +0 -21
  110. data/lib/textus/write/authority_gate.rb +0 -24
@@ -1,18 +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, :declared_zone_kinds,
15
- :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
16
17
 
17
18
  def self.validate_key!(key)
18
19
  raise UsageError.new("empty key") if key.nil? || key.empty?
@@ -34,16 +35,19 @@ module Textus
34
35
  def initialize(raw:, root:)
35
36
  @raw = raw
36
37
  @root = root
37
- @zones = Array(raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
38
- @zone_readers = Array(raw["zones"]).to_h do |z|
39
- rp = z["read_policy"]
40
- [z["name"], rp.nil? ? :all : Array(rp)]
41
- end
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
42
  @declared_zone_kinds = Array(raw["zones"]).to_h do |z|
43
43
  [z["name"], z["kind"]&.to_sym]
44
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
45
49
  @audit_config = build_audit_config(raw)
46
- @role_mapping = RoleKinds.resolve(raw["roles"])
50
+ @role_caps = Capabilities.resolve(raw["roles"])
47
51
  # Policy is constructed before entries because Entry validators
48
52
  # call `entry.in_generator_zone?(policy)` and similar helpers
49
53
  # that take Policy as an argument.
@@ -2,28 +2,50 @@ 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, permission_for, role_kind, roles_with_kind). Derived /
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 /
6
9
  # proposal-queue status is authoritative via the declared-kind family
7
- # (declared_kind, derived_zone?, queue_zone?, queue_zone), not inferred
8
- # from writers.
10
+ # (declared_kind, derived_zone?, queue_zone?, queue_zone).
9
11
  class Policy
10
12
  def initialize(data)
11
13
  @data = data
12
14
  end
13
15
 
14
- def zone_writers(zone_name)
15
- @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
27
+ end
28
+
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
16
35
  end
17
36
 
18
- def zone_readers
19
- @data.zone_readers
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))
20
43
  end
21
44
 
22
45
  def permission_for(zone_name)
23
46
  Textus::Domain::Permission.new(
24
47
  zone: zone_name,
25
- write_policy: zone_writers(zone_name),
26
- read_policy: @data.zone_readers[zone_name] || :all,
48
+ writers: zone_writers(zone_name),
27
49
  )
28
50
  end
29
51
 
@@ -32,6 +54,12 @@ module Textus
32
54
  @data.declared_zone_kinds[zone_name]
33
55
  end
34
56
 
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
61
+ end
62
+
35
63
  # The single zone declaring `kind: queue`, or nil. Schema guarantees <=1.
36
64
  def queue_zone
37
65
  @data.declared_zone_kinds.key(:queue)
@@ -47,18 +75,6 @@ module Textus
47
75
  declared_kind(zone_name) == :queue
48
76
  end
49
77
 
50
- def role_mapping
51
- @data.role_mapping
52
- end
53
-
54
- def role_kind(name)
55
- @data.role_mapping[name]
56
- end
57
-
58
- def roles_with_kind(kind)
59
- @data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
60
- end
61
-
62
78
  # The zone a proposer role writes proposals into: the single zone that
63
79
  # declares kind: queue, when the role can write it. Returns nil if there
64
80
  # is no queue zone or the role cannot write it.
@@ -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"])
53
+ @guard = parse_guard(raw["guard"])
54
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,14 @@ 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")
80
-
81
- Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
83
+ h
82
84
  end
83
85
 
84
86
  def parse_retention(h)
@@ -2,15 +2,26 @@ 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 kind write_policy read_policy].freeze
8
- ZONE_KINDS = %w[origin quarantine queue derived].freeze
9
- KIND_REQUIRES_ROLE_KIND = {
10
- "derived" => "generator",
11
- "queue" => "proposer",
12
- "quarantine" => "runner",
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",
13
20
  }.freeze
21
+
22
+ ZONE_KINDS = LANES.keys.freeze
23
+ CAPABILITIES = LANES.values.freeze
24
+ KIND_REQUIRES_VERB = LANES
14
25
  ENTRY_KEYS = %w[
15
26
  key path zone kind schema owner nested format
16
27
  compute template publish_to publish_each
@@ -18,11 +29,10 @@ module Textus
18
29
  ].freeze
19
30
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
20
31
  INTAKE_KEYS = %w[handler config].freeze
21
- RULE_KEYS = %w[match refresh intake_handler_allowlist promotion retention].freeze
22
- 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
23
34
  FETCH_TIMEOUT_SECONDS_CEILING = 3600
24
- PROMOTION_KEYS = %w[requires].freeze
25
- RETENTION_KEYS = %w[expire_after archive_after].freeze
35
+ RETENTION_KEYS = %w[expire_after archive_after].freeze
26
36
  AUDIT_KEYS = %w[max_size keep].freeze
27
37
 
28
38
  def self.validate!(raw)
@@ -34,7 +44,6 @@ module Textus
34
44
  validate_entries!(raw["entries"])
35
45
  validate_rules!(raw["rules"])
36
46
  walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
37
- validate_zone_writers_declared!(raw)
38
47
  validate_single_queue!(raw)
39
48
  validate_zone_kind_consistency!(raw)
40
49
  end
@@ -66,52 +75,37 @@ module Textus
66
75
  Array(rules).each_with_index do |r, i|
67
76
  path = "$.rules[#{i}]"
68
77
  walk(r, RULE_KEYS, path)
69
- if r["refresh"].is_a?(Hash)
70
- walk(r["refresh"], REFRESH_KEYS, "#{path}.refresh")
71
- validate_fetch_timeout!(r["refresh"]["fetch_timeout_seconds"], "#{path}.refresh.fetch_timeout_seconds")
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
73
- walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
74
82
  walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
75
83
  end
76
84
  end
77
85
 
78
- def self.validate_zone_writers_declared!(raw)
79
- return if raw["roles"].nil? # default mapping is permissive
80
-
81
- declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
82
- Array(raw["zones"]).each do |z|
83
- Array(z["write_policy"]).each_with_index do |w, j|
84
- next if declared.include?(w)
85
-
86
- raise BadManifest.new(
87
- "zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
88
- "(declared roles: #{declared.to_a.join(", ")})",
89
- )
90
- end
91
- end
92
- end
93
-
94
86
  def self.validate_roles!(roles)
95
87
  return if roles.nil?
96
88
  raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
97
89
 
98
- accept_authority_count = 0
99
90
  roles.each_with_index do |r, i|
100
91
  path = "$.roles[#{i}]"
101
92
  walk(r, ROLE_KEYS, path)
102
93
  name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
103
- kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
104
- unless ROLE_KINDS.include?(kind)
105
- raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
106
- end
94
+ Array(r["can"]).each do |verb|
95
+ next if CAPABILITIES.include?(verb)
107
96
 
108
- 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
109
102
  end
110
- return unless accept_authority_count > 1
103
+
104
+ author_holders = roles.count { |r| Array(r["can"]).include?("author") }
105
+ return if author_holders <= 1
111
106
 
112
107
  raise BadManifest.new(
113
- "manifest declares #{accept_authority_count} accept_authority roles; " \
114
- "at most one accept_authority role is allowed",
108
+ "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
115
109
  )
116
110
  end
117
111
 
@@ -143,28 +137,24 @@ module Textus
143
137
  )
144
138
  end
145
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.
146
145
  def self.validate_zone_kind_consistency!(raw)
147
- mapping = role_kind_mapping(raw)
148
- Array(raw["zones"]).each do |z|
149
- required = KIND_REQUIRES_ROLE_KIND[z["kind"]] or next
150
- writers = Array(z["write_policy"])
151
- next if writers.any? { |w| mapping[w] == required }
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)
152
151
 
153
152
  raise BadManifest.new(
154
- "zone '#{z["name"]}' declares kind: #{z["kind"]} but no writer is a #{required} " \
155
- "(writers: #{writers.join(", ")})",
153
+ "zone '#{z["name"]}' (#{z["kind"]}) at '$.zones[#{i}]' " \
154
+ "needs a role with capability '#{verb}'; none declared",
156
155
  )
157
156
  end
158
157
  end
159
-
160
- # name => kind string, honouring an explicit roles: block or the default mapping.
161
- def self.role_kind_mapping(raw)
162
- if raw["roles"].nil?
163
- RoleKinds::DEFAULT_MAPPING.transform_values(&:to_s)
164
- else
165
- Array(raw["roles"]).to_h { |r| [r["name"], r["kind"]] }
166
- end
167
- end
168
158
  end
169
159
  end
170
160
  end
@@ -4,11 +4,11 @@ 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
9
  # * policy — zone/role authority (zone_writers, declared_kind/derived_zone?/
10
10
  # queue_zone?, permission_for, …)
11
- # * rules — match-block rule engine (refresh, handler allowlist, promotion, …)
11
+ # * rules — match-block rule engine (fetch, handler allowlist, promotion, …)
12
12
  #
13
13
  # Use `manifest.data.entries`, `manifest.policy.declared_kind(z)`, etc.
14
14
  Manifest = Data.define(:data, :resolver, :policy, :rules)
@@ -18,7 +18,7 @@ require_relative "manifest/schema"
18
18
  require_relative "manifest/data"
19
19
  require_relative "manifest/policy"
20
20
  require_relative "manifest/resolver"
21
- require_relative "manifest/role_kinds"
21
+ require_relative "manifest/capabilities"
22
22
 
23
23
  # Reopen Textus::Manifest (defined above as a Data.define) to attach
24
24
  # class-level loaders and helpers.
@@ -0,0 +1,72 @@
1
+ module Textus
2
+ module MCP
3
+ # Derives the entire MCP tool surface from the per-verb contracts
4
+ # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
5
+ # tools/call dispatch: map JSON args -> (positional, keyword) per the
6
+ # contract, invoke the verb through the role scope, then shape the
7
+ # return value with the contract's response block. No per-tool code.
8
+ module Catalog
9
+ module_function
10
+
11
+ # Contracts of every MCP-surfaced verb, in Dispatcher order.
12
+ def specs
13
+ Textus::Dispatcher::VERBS.values
14
+ .select { |k| k.respond_to?(:contract?) && k.contract? && k.contract.mcp? }
15
+ .map(&:contract)
16
+ end
17
+
18
+ def tool_schemas
19
+ specs.map do |s|
20
+ { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
21
+ end.freeze
22
+ end
23
+
24
+ def names
25
+ specs.map { |s| s.verb.to_s }
26
+ end
27
+
28
+ def call(name, session:, store:, args:)
29
+ klass = Textus::Dispatcher::VERBS[name.to_sym]
30
+ raise ToolError.new("unknown tool: #{name}") unless klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
31
+
32
+ spec = klass.contract
33
+ pos, kw = map_args(spec, args || {}, session)
34
+ result = store.as(session.role).public_send(spec.verb, *pos, **kw)
35
+ spec.response.call(result)
36
+ rescue ContractDrift, CursorExpired
37
+ raise
38
+ rescue Textus::Error => e
39
+ raise ToolError.new("#{name}: #{e.message}")
40
+ end
41
+
42
+ # Splits the raw JSON arg hash into the positional list and keyword hash
43
+ # the use-case expects, validating required presence first.
44
+ # Session-default args (session_default: :method_name) are injected from
45
+ # the session when absent from the wire; they are never treated as missing.
46
+ # Positional args are emitted in contract declaration order; use-case signatures must match.
47
+ def map_args(spec, raw, session = nil)
48
+ missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
49
+ raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
50
+
51
+ positional = []
52
+ keyword = {}
53
+ spec.args.each do |a|
54
+ if raw.key?(a.name.to_s)
55
+ value = raw[a.name.to_s]
56
+ elsif a.session_default && session
57
+ value = session.public_send(a.session_default)
58
+ else
59
+ next
60
+ end
61
+
62
+ if a.positional
63
+ positional << value
64
+ else
65
+ keyword[a.name] = value
66
+ end
67
+ end
68
+ [positional, keyword]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -49,8 +49,11 @@ 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 = @store.manifest.policy.propose_zone_for(proposer)
52
+ # The acting role IS the resolved connection role (ADR 0040): the MCP
53
+ # transport defaults to `agent`, which can write the queue, so its
54
+ # propose_zone resolves directly. If a connection's role cannot propose,
55
+ # propose_zone is nil and the `propose` tool reports that honestly.
56
+ propose_zone = @store.manifest.policy.propose_zone_for(@role)
54
57
 
55
58
  @session = Session.new(
56
59
  role: @role,
@@ -67,7 +70,7 @@ module Textus
67
70
  end
68
71
 
69
72
  def handle_tools_list(rid)
70
- emit_result(rid, { "tools" => ToolSchemas.all })
73
+ emit_result(rid, { "tools" => Catalog.tool_schemas })
71
74
  end
72
75
 
73
76
  def handle_tools_call(rid, params)
@@ -80,8 +83,8 @@ module Textus
80
83
 
81
84
  name = params["name"]
82
85
  args = params["arguments"] || {}
83
- result = Tools.call(name, session: @session, store: @store, args: args)
84
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
86
+ result = Catalog.call(name, session: @session, store: @store, args: args)
87
+ @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
85
88
 
86
89
  emit_result(rid, {
87
90
  "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
@@ -1,24 +1,7 @@
1
1
  module Textus
2
2
  module MCP
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)
7
-
8
- def check_etag!(observed_etag)
9
- return if observed_etag == manifest_etag
10
-
11
- raise ContractDrift.new(
12
- "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
13
- )
14
- end
15
-
16
- private
17
-
18
- # First 8 hex chars after the "sha256:" prefix — a stable short id for
19
- # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
20
- # a no-op when the prefix is absent).
21
- def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
22
- end
3
+ # The session value now lives in core (ADR 0036); retained here as an
4
+ # alias so existing MCP references keep resolving.
5
+ Session = Textus::Session
23
6
  end
24
7
  end
@@ -1,70 +1,14 @@
1
1
  module Textus
2
2
  module MCP
3
- # JSON-Schema definitions for every MCP tool's inputSchema. Returned by
4
- # the server in tools/list. Static today a follow-up will enrich with
5
- # manifest-derived enums for `zone`, `key`, etc.
3
+ # Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
4
+ # per-verb contracts; this delegates to MCP::Catalog. The hand-written
5
+ # array is gone a kwarg rename now updates the schema automatically (and
6
+ # the signature guard fails if the contract lags the use-case).
6
7
  module ToolSchemas
7
8
  module_function
8
9
 
9
- def all # rubocop:disable Metrics/MethodLength
10
- [
11
- tool("boot", "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart.", {}, []),
12
- tool("tick", "Delta since cursor. Returns {cursor, changed, stale, pending_review, doctor}.",
13
- { "since" => { "type" => "integer", "minimum" => 0 } }, []),
14
- tool("find", "List keys filtered by zone and/or prefix.",
15
- { "zone" => { "type" => "string" }, "prefix" => { "type" => "string" } }, []),
16
- tool("read", "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness).",
17
- { "key" => { "type" => "string" } }, ["key"]),
18
- tool("write", "Create or update an entry. Schema-validated. Returns {uid, etag}.",
19
- {
20
- "key" => { "type" => "string" },
21
- "meta" => { "type" => "object" },
22
- "body" => { "type" => "string" },
23
- "content" => { "type" => "object" },
24
- "if_etag" => { "type" => "string" },
25
- }, %w[key meta]),
26
- tool("propose", "Write a proposal to the session's propose_zone. Auto-prefixes the key.",
27
- {
28
- "key" => { "type" => "string", "description" => "Key relative to propose_zone, e.g. 'proposal.feature-x'" },
29
- "meta" => { "type" => "object" },
30
- "body" => { "type" => "string" },
31
- }, %w[key meta]),
32
- tool("refresh", "Run an intake refresh for one key. Returns the refresh Outcome.",
33
- { "key" => { "type" => "string" } }, ["key"]),
34
- tool("refresh_stale", "Refresh all stale intake entries, optionally scoped by zone/prefix.",
35
- {
36
- "zone" => { "type" => "string" },
37
- "prefix" => { "type" => "string" },
38
- }, []),
39
- tool("schema", "Return the schema (field shape) for an entry family.",
40
- { "family" => { "type" => "string" } }, ["family"]),
41
- tool("rules", "Return effective rules for a key (refresh, promote, ...).",
42
- { "key" => { "type" => "string" } }, ["key"]),
43
- tool("key_mv_prefix",
44
- "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
45
- { "from_prefix" => { "type" => "string" }, "to_prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
46
- %w[from_prefix to_prefix]),
47
- tool("key_delete_prefix", "Bulk-delete every leaf key under prefix.",
48
- { "prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
49
- ["prefix"]),
50
- tool("zone_mv", "Rename a zone — manifest + files. Refuses if destination exists.",
51
- { "from" => { "type" => "string" }, "to" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
52
- %w[from to]),
53
- tool("rule_lint", "Diff candidate manifest YAML's rules against the live manifest. No writes.",
54
- { "candidate_yaml" => { "type" => "string" } },
55
- ["candidate_yaml"]),
56
- tool("migrate", "Run a YAML migration plan (multi-op).",
57
- { "plan_yaml" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
58
- ["plan_yaml"]),
59
- ].freeze
60
- end
61
-
62
- def tool(name, description, properties, required)
63
- {
64
- name: name,
65
- description: description,
66
- inputSchema: { type: "object", properties: properties, required: required },
67
- }
10
+ def all
11
+ Catalog.tool_schemas
68
12
  end
69
13
  end
70
14
  end