textus 0.30.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -241
  3. data/CHANGELOG.md +113 -0
  4. data/README.md +83 -62
  5. data/SPEC.md +352 -211
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +89 -74
  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/put.rb +1 -1
  15. data/lib/textus/cli/verb/rule_list.rb +7 -7
  16. data/lib/textus/cli.rb +2 -2
  17. data/lib/textus/container.rb +1 -2
  18. data/lib/textus/dispatcher.rb +3 -3
  19. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
  20. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  21. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  22. data/lib/textus/doctor.rb +2 -1
  23. data/lib/textus/domain/action.rb +3 -3
  24. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  25. data/lib/textus/domain/freshness/policy.rb +2 -2
  26. data/lib/textus/domain/freshness.rb +7 -7
  27. data/lib/textus/domain/outcome.rb +2 -2
  28. data/lib/textus/domain/permission.rb +2 -10
  29. data/lib/textus/domain/policy/base_guards.rb +25 -0
  30. data/lib/textus/domain/policy/evaluation.rb +18 -0
  31. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
  32. data/lib/textus/domain/policy/guard.rb +35 -0
  33. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  34. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  35. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  36. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  37. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  38. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  39. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  40. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  41. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  42. data/lib/textus/envelope.rb +2 -2
  43. data/lib/textus/errors.rb +25 -28
  44. data/lib/textus/hooks/event_bus.rb +4 -4
  45. data/lib/textus/init.rb +23 -18
  46. data/lib/textus/maintenance/zone_mv.rb +1 -1
  47. data/lib/textus/manifest/capabilities.rb +29 -0
  48. data/lib/textus/manifest/data.rb +14 -10
  49. data/lib/textus/manifest/policy.rb +37 -21
  50. data/lib/textus/manifest/rules.rb +16 -14
  51. data/lib/textus/manifest/schema.rb +48 -58
  52. data/lib/textus/manifest.rb +3 -3
  53. data/lib/textus/mcp/server.rb +1 -1
  54. data/lib/textus/mcp/tool_schemas.rb +3 -3
  55. data/lib/textus/mcp/tools.rb +7 -7
  56. data/lib/textus/ports/audit_subscriber.rb +1 -1
  57. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  58. data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
  59. data/lib/textus/projection.rb +1 -1
  60. data/lib/textus/read/freshness.rb +9 -9
  61. data/lib/textus/read/get.rb +8 -8
  62. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  63. data/lib/textus/read/policy_explain.rb +14 -10
  64. data/lib/textus/read/pulse.rb +5 -4
  65. data/lib/textus/read/validator.rb +1 -1
  66. data/lib/textus/schema/tools.rb +5 -5
  67. data/lib/textus/version.rb +1 -1
  68. data/lib/textus/write/accept.rb +19 -55
  69. data/lib/textus/write/delete.rb +14 -2
  70. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
  71. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  72. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +21 -14
  73. data/lib/textus/write/mv.rb +15 -3
  74. data/lib/textus/write/put.rb +14 -2
  75. data/lib/textus/write/reject.rb +11 -5
  76. metadata +24 -18
  77. data/lib/textus/cli/verb/refresh.rb +0 -14
  78. data/lib/textus/domain/authorizer.rb +0 -37
  79. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  80. data/lib/textus/domain/policy/promote.rb +0 -26
  81. data/lib/textus/domain/policy/promotion.rb +0 -57
  82. data/lib/textus/manifest/role_kinds.rb +0 -21
  83. data/lib/textus/write/authority_gate.rb +0 -24
data/lib/textus/errors.rb CHANGED
@@ -90,39 +90,21 @@ module Textus
90
90
  end
91
91
 
92
92
  class WriteForbidden < Error
93
- def initialize(k, z, writers: nil)
94
- writers_str =
95
- if writers && !writers.empty?
96
- writers.join(", ")
93
+ def initialize(k, z, verb: nil, holders: nil)
94
+ holders_str =
95
+ if holders && !holders.empty?
96
+ holders.join(", ")
97
97
  else
98
- "the role(s) listed in the manifest 'write_policy:'"
98
+ "no declared role"
99
99
  end
100
100
  details = { "key" => k, "zone" => z }
101
- details["writers"] = writers if writers
101
+ details["verb"] = verb if verb
102
+ details["holders"] = holders if holders
102
103
  super(
103
104
  "write_forbidden",
104
- "zone '#{z}' is not agent-writable for key '#{k}'",
105
+ "writing '#{k}' (zone '#{z}') needs capability '#{verb}'",
105
106
  details: details,
106
- hint: "this zone is writable by #{writers_str}; pass --as=<role>",
107
- )
108
- end
109
- end
110
-
111
- class ReadForbidden < Error
112
- def initialize(k, z, readers: nil)
113
- readers_str =
114
- if readers && !readers.empty?
115
- readers.join(", ")
116
- else
117
- "the role(s) listed in the manifest 'read_policy:'"
118
- end
119
- details = { "key" => k, "zone" => z }
120
- details["readers"] = readers if readers
121
- super(
122
- "read_forbidden",
123
- "zone '#{z}' is not readable by role for key '#{k}'",
124
- details: details,
125
- hint: "this zone is readable by #{readers_str}; pass --as=<role>",
107
+ hint: "held by: #{holders_str}; pass --as=<role>",
126
108
  )
127
109
  end
128
110
  end
@@ -163,7 +145,7 @@ module Textus
163
145
  "invalid_role",
164
146
  message || "role '#{r}' is not declared in any zone",
165
147
  details: { "role" => r },
166
- hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under zones[].write_policy",
148
+ hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under roles: (each with a can: list)",
167
149
  )
168
150
  end
169
151
  end
@@ -204,6 +186,21 @@ module Textus
204
186
  def initialize(m) = super("proposal_error", m)
205
187
  end
206
188
 
189
+ class GuardFailed < Error
190
+ def initialize(failed)
191
+ # failed: [[predicate_name, reason], ...]
192
+ rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
193
+ names = failed.map(&:first)
194
+ super(
195
+ "guard_failed",
196
+ "guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
197
+ details: { "failed" => rows },
198
+ hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
199
+ "unmet: #{names.join(", ")}",
200
+ )
201
+ end
202
+ end
203
+
207
204
  class FlagRenamed < Error
208
205
  def initialize(old_flag, new_flag)
209
206
  super(
@@ -10,16 +10,16 @@ module Textus
10
10
  EVENTS = {
11
11
  entry_put: %i[ctx key envelope],
12
12
  entry_deleted: %i[ctx key],
13
- entry_refreshed: %i[ctx key envelope change],
13
+ entry_fetched: %i[ctx key envelope change],
14
14
  entry_renamed: %i[ctx key from_key to_key envelope],
15
15
  build_completed: %i[ctx key envelope sources],
16
16
  proposal_accepted: %i[ctx key target_key],
17
17
  proposal_rejected: %i[ctx key target_key],
18
18
  file_published: %i[ctx key envelope source target],
19
19
  store_loaded: %i[ctx],
20
- refresh_started: %i[ctx key mode],
21
- refresh_failed: %i[ctx key error_class error_message],
22
- refresh_backgrounded: %i[ctx key started_at budget_ms],
20
+ fetch_started: %i[ctx key mode],
21
+ fetch_failed: %i[ctx key error_class error_message],
22
+ fetch_backgrounded: %i[ctx key started_at budget_ms],
23
23
  }.freeze
24
24
 
25
25
  RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
data/lib/textus/init.rb CHANGED
@@ -2,20 +2,25 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Init
5
- ZONES = %w[identity working intake review output].freeze
5
+ ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
6
6
 
7
7
  DEFAULT_MANIFEST = <<~YAML
8
8
  version: textus/3
9
+ roles:
10
+ - { name: human, can: [author, propose] }
11
+ - { name: agent, can: [propose, keep] }
12
+ - { name: automation, can: [fetch, build] }
9
13
  zones:
10
- - { name: identity, kind: origin, write_policy: [human], read_policy: [all] }
11
- - { name: working, kind: origin, write_policy: [human], read_policy: [all] }
12
- - { name: intake, kind: quarantine, write_policy: [runner], read_policy: [all] }
13
- - { name: review, kind: queue, write_policy: [agent, human], read_policy: [all] }
14
- - { name: output, kind: derived, write_policy: [builder], read_policy: [all] }
14
+ - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
15
+ - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
16
+ - { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
17
+ - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
18
+ - { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
15
19
  entries:
16
- - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
17
- - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
18
- - { key: review.notes, path: review/notes, zone: review, schema: null, owner: agent:self, nested: true, kind: nested }
20
+ - { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
21
+ - { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
+ - { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
+ - { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
19
24
  YAML
20
25
 
21
26
  HOOKS_README = <<~MD
@@ -31,12 +36,12 @@ module Textus
31
36
  ```ruby
32
37
  Textus.hook do |reg|
33
38
  reg.on(:resolve_intake, :my_source) do |config:, args:, **|
34
- { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
39
+ { _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
35
40
  end
36
41
 
37
42
  reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
38
43
  reg.on(:validate, :my_check) { |caps:, **| [] }
39
- reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
44
+ reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
40
45
 
41
46
  # Run a side-effect every time textus writes a file to your repo:
42
47
  reg.on(:file_published, :notify) do |key:, target:, **|
@@ -51,25 +56,25 @@ module Textus
51
56
 
52
57
  ```yaml
53
58
  entries:
54
- - key: intake.foo
59
+ - key: feeds.foo
55
60
  kind: intake
56
- path: intake/foo.md
57
- zone: intake
61
+ path: feeds/foo.md
62
+ zone: feeds
58
63
  intake:
59
64
  handler: my_source
60
65
 
61
66
  rules:
62
- - match: intake.foo
63
- refresh:
67
+ - match: feeds.foo
68
+ fetch:
64
69
  ttl: 10m
65
70
  on_stale: timed_sync # warn | sync | timed_sync (default: warn)
66
71
  ```
67
72
 
68
73
  Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
69
- :entry_put, :entry_deleted, :entry_refreshed, :entry_renamed,
74
+ :entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
70
75
  :build_completed, :proposal_accepted, :proposal_rejected,
71
76
  :file_published, :store_loaded,
72
- :refresh_started, :refresh_failed, :refresh_backgrounded (pub-sub — return discarded)
77
+ :fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
73
78
 
74
79
  See SPEC.md §5.10 for the full table.
75
80
  MD
@@ -15,7 +15,7 @@ module Textus
15
15
 
16
16
  def call(from:, to:, dry_run: false)
17
17
  raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
18
- raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
18
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
19
19
 
20
20
  dest_dir = File.join(@root, "zones", to)
21
21
  raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
@@ -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,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.
@@ -49,7 +49,7 @@ module Textus
49
49
  end
50
50
 
51
51
  def handle_initialize(rid, _params)
52
- proposer = @store.manifest.policy.roles_with_kind(:proposer).first
52
+ proposer = @store.manifest.policy.proposer_role
53
53
  propose_zone = @store.manifest.policy.propose_zone_for(proposer)
54
54
 
55
55
  @session = Session.new(