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,129 +1,14 @@
1
1
  module Textus
2
2
  module MCP
3
- # Dispatch table for MCP tool names → implementations. Each implementation
4
- # receives (session:, store:, args:) and returns a JSON-encodable value.
5
- # Tool errors are wrapped in ToolError; ContractDrift / CursorExpired
6
- # propagate verbatim so the server can map them to JSON-RPC codes.
3
+ # Thin delegator kept for name stability (ADR 0039). The dispatch table
4
+ # and JSON schemas are now DERIVED from per-verb contracts by MCP::Catalog;
5
+ # this module only forwards.
7
6
  module Tools
8
7
  module_function
9
8
 
10
9
  def call(name, session:, store:, args:)
11
- impl = REGISTRY[name] or raise ToolError.new("unknown tool: #{name}")
12
- impl.call(session, store, args || {})
13
- rescue ContractDrift, CursorExpired
14
- raise
15
- rescue Textus::Error => e
16
- raise ToolError.new("#{name}: #{e.message}")
10
+ Catalog.call(name, session: session, store: store, args: args || {})
17
11
  end
18
-
19
- def ops_for(session, store)
20
- store.as(session.role)
21
- end
22
-
23
- REGISTRY = {
24
- "boot" => ->(_s, store, _a) { store.boot },
25
-
26
- "find" => lambda do |s, store, args|
27
- ops_for(s, store).list(zone: args["zone"], prefix: args["prefix"])
28
- end,
29
-
30
- "read" => lambda do |s, store, args|
31
- key = args.fetch("key") { raise ToolError.new("read: missing key") }
32
- env = ops_for(s, store).get(key)
33
- env.to_h_for_wire
34
- end,
35
-
36
- "tick" => lambda do |s, store, args|
37
- since = (args["since"] || s.cursor).to_i
38
- ops_for(s, store).pulse(since: since)
39
- end,
40
-
41
- "write" => lambda do |s, store, args|
42
- key = args.fetch("key") { raise ToolError.new("write: missing key") }
43
- env = ops_for(s, store).put(
44
- key,
45
- meta: args["meta"] || {},
46
- body: args["body"],
47
- content: args["content"],
48
- if_etag: args["if_etag"],
49
- )
50
- { "uid" => env.uid, "etag" => env.etag }
51
- end,
52
-
53
- "propose" => lambda do |s, store, args|
54
- raise ToolError.new("propose: session has no propose_zone") unless s.propose_zone
55
-
56
- rel = args.fetch("key") { raise ToolError.new("propose: missing key") }
57
- target = "#{s.propose_zone}.#{rel}"
58
- env = ops_for(s, store).put(
59
- target,
60
- meta: args["meta"] || {},
61
- body: args["body"],
62
- content: args["content"],
63
- )
64
- { "uid" => env.uid, "etag" => env.etag, "key" => target }
65
- end,
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)
70
- { "outcome" => outcome.class.name.split("::").last.downcase }
71
- end,
72
-
73
- "refresh_stale" => lambda do |s, store, args|
74
- ops_for(s, store).refresh_all(zone: args["zone"], prefix: args["prefix"])
75
- end,
76
-
77
- "schema" => lambda do |_s, store, args|
78
- family = args.fetch("family") { raise ToolError.new("schema: missing family") }
79
- store.schemas.fetch(family)
80
- end,
81
-
82
- "rules" => lambda do |_s, store, args|
83
- key = args.fetch("key") { raise ToolError.new("rules: missing key") }
84
- set = store.manifest.rules.for(key)
85
- {
86
- "refresh" => set.refresh&.to_h,
87
- "promote" => set.respond_to?(:promote) ? set.promote&.to_h : nil,
88
- }.compact
89
- end,
90
-
91
- "key_mv_prefix" => lambda do |s, store, args|
92
- ops_for(s, store).key_mv_prefix(
93
- from_prefix: args.fetch("from_prefix") { raise ToolError.new("key_mv_prefix: missing from_prefix") },
94
- to_prefix: args.fetch("to_prefix") { raise ToolError.new("key_mv_prefix: missing to_prefix") },
95
- dry_run: args["dry_run"] || false,
96
- ).to_h
97
- end,
98
-
99
- "key_delete_prefix" => lambda do |s, store, args|
100
- ops_for(s, store).key_delete_prefix(
101
- prefix: args.fetch("prefix") { raise ToolError.new("key_delete_prefix: missing prefix") },
102
- dry_run: args["dry_run"] || false,
103
- ).to_h
104
- end,
105
-
106
- "zone_mv" => lambda do |s, store, args|
107
- ops_for(s, store).zone_mv(
108
- from: args.fetch("from") { raise ToolError.new("zone_mv: missing from") },
109
- to: args.fetch("to") { raise ToolError.new("zone_mv: missing to") },
110
- dry_run: args["dry_run"] || false,
111
- ).to_h
112
- end,
113
-
114
- "rule_lint" => lambda do |s, store, args|
115
- ops_for(s, store).rule_lint(
116
- candidate_yaml: args.fetch("candidate_yaml") { raise ToolError.new("rule_lint: missing candidate_yaml") },
117
- ).to_h
118
- end,
119
-
120
- "migrate" => lambda do |s, store, args|
121
- ops_for(s, store).migrate(
122
- plan_yaml: args.fetch("plan_yaml") { raise ToolError.new("migrate: missing plan_yaml") },
123
- dry_run: args["dry_run"] || false,
124
- ).to_h
125
- end,
126
- }.freeze
127
12
  end
128
13
  end
129
14
  end
@@ -10,7 +10,7 @@ module Textus
10
10
 
11
11
  def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
12
12
  @root = root
13
- @path = File.join(root, "audit.log")
13
+ @path = Textus::Layout.audit_log(root)
14
14
  @max_size = max_size
15
15
  @keep = keep
16
16
  end
@@ -54,6 +54,7 @@ module Textus
54
54
  end
55
55
 
56
56
  def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
57
+ FileUtils.mkdir_p(File.dirname(@path))
57
58
  File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
58
59
  f.flock(File::LOCK_EX)
59
60
  next_seq = current_max_seq_unlocked + 1
@@ -81,6 +82,14 @@ module Textus
81
82
 
82
83
  private
83
84
 
85
+ def rotated(n)
86
+ File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}")
87
+ end
88
+
89
+ def rotated_meta(n)
90
+ File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}.meta.json")
91
+ end
92
+
84
93
  # Caller holds the flock. Returns the highest seq across the active log,
85
94
  # OR the most-recent rotated file's max_seq if the active log is empty.
86
95
  def current_max_seq_unlocked
@@ -113,7 +122,7 @@ module Textus
113
122
  end
114
123
 
115
124
  def read_meta(n)
116
- path = File.join(@root, "audit.log.#{n}.meta.json")
125
+ path = rotated_meta(n)
117
126
  return nil unless File.exist?(path)
118
127
 
119
128
  JSON.parse(File.read(path))
@@ -151,25 +160,18 @@ module Textus
151
160
  meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
152
161
 
153
162
  # Drop the file that would be shifted past @keep.
154
- oldest = File.join(@root, "audit.log.#{@keep}")
155
- oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
156
- FileUtils.rm_f(oldest)
157
- FileUtils.rm_f(oldest_meta)
163
+ FileUtils.rm_f(rotated(@keep))
164
+ FileUtils.rm_f(rotated_meta(@keep))
158
165
 
159
166
  # Shift .N → .(N+1) for N = keep-1 down to 1.
160
167
  (@keep - 1).downto(1) do |n|
161
- src = File.join(@root, "audit.log.#{n}")
162
- dst = File.join(@root, "audit.log.#{n + 1}")
163
- File.rename(src, dst) if File.exist?(src)
164
-
165
- src_meta = File.join(@root, "audit.log.#{n}.meta.json")
166
- dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
167
- File.rename(src_meta, dst_meta) if File.exist?(src_meta)
168
+ File.rename(rotated(n), rotated(n + 1)) if File.exist?(rotated(n))
169
+ File.rename(rotated_meta(n), rotated_meta(n + 1)) if File.exist?(rotated_meta(n))
168
170
  end
169
171
 
170
172
  # Active log → .1
171
- File.rename(@path, File.join(@root, "audit.log.1"))
172
- File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
173
+ File.rename(@path, rotated(1))
174
+ File.write(rotated_meta(1), JSON.generate(meta) + "\n")
173
175
  # Next append will create a fresh audit.log via File::CREAT.
174
176
  end
175
177
 
@@ -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
@@ -5,7 +5,6 @@ require "time"
5
5
  module Textus
6
6
  module Ports
7
7
  class BuildLock
8
- LOCK_FILENAME = ".build.lock"
9
8
  MAX_HOLDER_BYTES = 512
10
9
 
11
10
  def self.with(root:, &)
@@ -13,7 +12,7 @@ module Textus
13
12
  end
14
13
 
15
14
  def initialize(root:)
16
- @path = File.join(root, LOCK_FILENAME)
15
+ @path = Textus::Layout.build_lock(root)
17
16
  @file = nil
18
17
  end
19
18
 
@@ -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,12 +2,12 @@ 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
9
9
  @key = key
10
- @path = File.join(root, ".locks", "#{safe_key}.lock")
10
+ @path = File.join(Textus::Layout.locks(root), "#{safe_key}.lock")
11
11
  @file = nil
12
12
  end
13
13
 
@@ -8,7 +8,7 @@ module Textus
8
8
 
9
9
  # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
10
10
  # semantics: pure read (`ops.get`) for materialization paths;
11
- # `ops.get_or_refresh` if you want refresh-on-stale.
11
+ # `ops.get_or_fetch` if you want fetch-on-stale.
12
12
  # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
13
13
  # `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
14
14
  # `transform_context` — capability object handed to transform reducers as `caps:`.
@@ -3,7 +3,7 @@ require "time"
3
3
 
4
4
  module Textus
5
5
  module Read
6
- # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
6
+ # Queries .textus/.run/audit/audit.log. Filters: key, zone, role, verb, since,
7
7
  # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
8
8
  # rows produce nil and are skipped).
9
9
  class Audit
@@ -33,7 +33,7 @@ module Textus
33
33
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
34
34
  @manifest = container.manifest
35
35
  @root = container.root
36
- @log_path = File.join(container.root, "audit.log")
36
+ @log_path = Textus::Layout.audit_log(container.root)
37
37
  @audit_log = container.audit_log
38
38
  end
39
39
 
@@ -84,7 +84,7 @@ module Textus
84
84
  end
85
85
 
86
86
  def all_log_files
87
- rotated = Dir.glob(File.join(@root, "audit.log.*"))
87
+ rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
88
88
  .reject { |p| p.end_with?(".meta.json") }
89
89
  .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
90
90
  active = File.exist?(@log_path) ? [@log_path] : []
@@ -5,6 +5,12 @@ module Textus
5
5
  # (container:, call:) entry point that Dispatcher::VERBS resolves to.
6
6
  # Boot is role-independent, so `call` is not consulted.
7
7
  class Boot
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :boot
11
+ summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
12
+ surfaces :cli, :ruby, :mcp
13
+
8
14
  def initialize(container:, call:)
9
15
  @container = container
10
16
  @call = call
@@ -3,8 +3,8 @@ require "time"
3
3
  module Textus
4
4
  module Read
5
5
  # Per-entry freshness report. Walks every entry declared in the manifest,
6
- # consults `rules_for(key)` for a refresh rule, and reports the
7
- # current status. Status is one of :fresh, :stale, :never_refreshed, or
6
+ # consults `rules_for(key)` for a fetch rule, and reports the
7
+ # current status. Status is one of :fresh, :stale, :never_fetched, or
8
8
  # :no_policy.
9
9
  class Freshness
10
10
  def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
@@ -16,7 +16,7 @@ module Textus
16
16
  @cache = {}
17
17
  end
18
18
 
19
- # Returns the soonest `next_due_at` across all entries with a refresh
19
+ # Returns the soonest `next_due_at` across all entries with a fetch
20
20
  # policy, as an ISO-8601 string, or nil if none.
21
21
  def soonest_due(prefix: nil, zone: nil)
22
22
  times = call(prefix: prefix, zone: zone)
@@ -43,17 +43,17 @@ module Textus
43
43
 
44
44
  def row_for(mentry)
45
45
  set = @manifest.rules.for(mentry.key)
46
- refresh = set.refresh
46
+ fetch = set.fetch
47
47
  envelope = safe_get(mentry.key)
48
- last = envelope&.meta&.dig("last_refreshed_at")
48
+ last = envelope&.meta&.dig("last_fetched_at")
49
49
 
50
- return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
50
+ return base_row(mentry, last).merge(status: :no_policy) if fetch.nil?
51
51
 
52
- fp = refresh.to_freshness_policy
52
+ fp = fetch.to_freshness_policy
53
53
  cache_key = [mentry.key, last]
54
54
  verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
55
55
  status = if verdict.fresh? then :fresh
56
- elsif last.nil? then :never_refreshed
56
+ elsif last.nil? then :never_fetched
57
57
  else :stale
58
58
  end
59
59
 
@@ -69,7 +69,7 @@ module Textus
69
69
  {
70
70
  key: mentry.key,
71
71
  zone: mentry.zone,
72
- last_refreshed_at: last,
72
+ last_fetched_at: last,
73
73
  age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
74
74
  }
75
75
  end
@@ -1,11 +1,19 @@
1
1
  module Textus
2
2
  module Read
3
3
  # Pure read: returns the on-disk envelope annotated with a freshness
4
- # verdict. Never triggers refresh; never invokes the orchestrator.
4
+ # verdict. Never triggers fetch; never invokes the orchestrator.
5
5
  #
6
- # For interactive reads that want refresh-on-stale, use
7
- # `Read::GetOrRefresh`, which composes this with the orchestrator.
6
+ # For interactive reads that want fetch-on-stale, use
7
+ # `Read::GetOrFetch`, which composes this with the orchestrator.
8
8
  class Get
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :get
12
+ summary "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness)."
13
+ surfaces :cli, :ruby, :mcp
14
+ arg :key, String, required: true, positional: true
15
+ response(&:to_h_for_wire)
16
+
9
17
  def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
10
18
  @container = container
11
19
  @call = call
@@ -19,16 +27,16 @@ module Textus
19
27
  return nil if envelope.nil?
20
28
 
21
29
  policy_set = @manifest.rules.for(key)
22
- refresh_policy = policy_set.refresh
23
- return annotate_fresh(envelope) if refresh_policy.nil?
30
+ fetch_policy = policy_set.fetch
31
+ return annotate_fresh(envelope) if fetch_policy.nil?
24
32
 
25
- policy = refresh_policy.to_freshness_policy
33
+ policy = fetch_policy.to_freshness_policy
26
34
  verdict = @evaluator.call(policy, envelope, now: @call.now)
27
35
 
28
36
  envelope.with(freshness: Textus::Domain::Freshness.build(
29
37
  stale: verdict.stale?,
30
38
  reason: verdict.reason,
31
- refreshing: false,
39
+ fetching: false,
32
40
  ))
33
41
  end
34
42
 
@@ -58,7 +66,7 @@ module Textus
58
66
 
59
67
  def annotate_fresh(envelope)
60
68
  envelope.with(freshness: Textus::Domain::Freshness.build(
61
- stale: false, reason: nil, refreshing: false,
69
+ stale: false, reason: nil, fetching: false,
62
70
  ))
63
71
  end
64
72
  end
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  module Read
3
- # Composes pure `Read::Get` with the refresh orchestrator: runs Get
3
+ # Composes pure `Read::Get` with the fetch orchestrator: runs Get
4
4
  # to obtain the envelope and freshness verdict, then if the verdict
5
5
  # is stale and the rule's `on_stale` policy demands action, hands
6
6
  # off to the orchestrator. Use for interactive reads where the
@@ -8,7 +8,7 @@ module Textus
8
8
  #
9
9
  # Pure reads (build, projection, schema tooling) should use
10
10
  # `Read::Get` directly; it has no orchestrator dependency.
11
- class GetOrRefresh
11
+ class GetOrFetch
12
12
  def initialize(container:, call:, get: nil, orchestrator: nil)
13
13
  @container = container
14
14
  @call = call
@@ -24,10 +24,10 @@ module Textus
24
24
  end
25
25
 
26
26
  def build_orchestrator
27
- worker = Textus::Write::RefreshWorker.new(
27
+ worker = Textus::Write::FetchWorker.new(
28
28
  container: @container, call: @call,
29
29
  )
30
- Textus::Write::RefreshOrchestrator.new(
30
+ Textus::Write::FetchOrchestrator.new(
31
31
  worker: worker, store_root: @container.root, events: @container.events,
32
32
  hook_context: hook_context
33
33
  )
@@ -41,10 +41,10 @@ module Textus
41
41
  return envelope unless envelope.freshness&.stale
42
42
 
43
43
  policy_set = @manifest.rules.for(key)
44
- refresh_policy = policy_set.refresh
45
- return envelope if refresh_policy.nil?
44
+ fetch_policy = policy_set.fetch
45
+ return envelope if fetch_policy.nil?
46
46
 
47
- policy = refresh_policy.to_freshness_policy
47
+ policy = fetch_policy.to_freshness_policy
48
48
  verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
49
49
  action = policy.decide(verdict)
50
50
  outcome = @orchestrator.execute(action, key: key)
@@ -52,15 +52,15 @@ module Textus
52
52
  case outcome
53
53
  when Textus::Domain::Outcome::Skipped
54
54
  envelope
55
- when Textus::Domain::Outcome::Refreshed
55
+ when Textus::Domain::Outcome::Fetched
56
56
  outcome.envelope.with(
57
- freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
57
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
58
58
  )
59
59
  when Textus::Domain::Outcome::Detached
60
- envelope.with(freshness: envelope.freshness.with(refreshing: true))
60
+ envelope.with(freshness: envelope.freshness.with(fetching: true))
61
61
  when Textus::Domain::Outcome::Failed
62
62
  envelope.with(
63
- freshness: envelope.freshness.with(refresh_error: outcome.error.message),
63
+ freshness: envelope.freshness.with(fetch_error: outcome.error.message),
64
64
  )
65
65
  end
66
66
  end
@@ -1,6 +1,14 @@
1
1
  module Textus
2
2
  module Read
3
3
  class List
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :list
7
+ summary "List keys filtered by zone and/or prefix."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :prefix, String
10
+ arg :zone, String
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end
@@ -1,40 +1,44 @@
1
1
  module Textus
2
2
  module Read
3
3
  # For one key, surface every matching policy block along with the
4
- # per-slot effective value (which loses ties win-by-specificity).
4
+ # per-slot effective value (which loses ties win-by-specificity) and the
5
+ # effective guard predicate names for every write transition (ADR 0031).
5
6
  class PolicyExplain
6
7
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
7
8
  @manifest = container.manifest
9
+ @schemas = container.schemas
8
10
  end
9
11
 
10
12
  def call(key:)
11
- policies = @manifest.rules
12
- matching = policies.explain(key)
13
- winners = policies.for(key)
13
+ matching = @manifest.rules.explain(key)
14
+ winners = @manifest.rules.for(key)
15
+ factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
14
16
 
15
17
  {
16
18
  key: key,
17
19
  matched_blocks: matching.map do |b|
18
20
  {
19
21
  match: b.match,
20
- refresh: !b.refresh.nil?,
22
+ fetch: !b.fetch.nil?,
21
23
  handler_allowlist: !b.handler_allowlist.nil?,
22
- promote: !b.promote.nil?,
24
+ guard: !b.guard.nil?,
23
25
  retention: !b.retention.nil?,
24
26
  }
25
27
  end,
26
28
  effective: {
27
- refresh: winners.refresh && {
28
- ttl_seconds: winners.refresh.ttl_seconds,
29
- on_stale: winners.refresh.on_stale,
29
+ fetch: winners.fetch && {
30
+ ttl_seconds: winners.fetch.ttl_seconds,
31
+ on_stale: winners.fetch.on_stale,
30
32
  },
31
33
  handler_allowlist: winners.handler_allowlist&.handlers,
32
- promotion: winners.promote && { requires: winners.promote.requires },
33
34
  retention: winners.retention && {
34
35
  expire_after: winners.retention.expire_after,
35
36
  archive_after: winners.retention.archive_after,
36
37
  },
37
38
  },
39
+ guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
40
+ [transition, factory.for(transition, key).predicates.map(&:name)]
41
+ end,
38
42
  }
39
43
  end
40
44
  end
@@ -7,6 +7,13 @@ module Textus
7
7
  # APIs; pulse is sugar with a stable envelope shape and a monotonic
8
8
  # cursor (seq).
9
9
  class Pulse
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :pulse
13
+ summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
14
+ surfaces :cli, :ruby, :mcp
15
+ arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
16
+
10
17
  def initialize(container:, call:)
11
18
  @container = container
12
19
  @call = call
@@ -49,11 +56,12 @@ module Textus
49
56
  end
50
57
 
51
58
  def review_keys
52
- # List constructor takes only manifest:; returns hashes with string keys.
53
- # Guard: zones is a Hash keyed by name string.
54
- return [] unless @manifest.data.zones.key?("review")
59
+ # The single queue zone (kind: queue; schema guarantees ≤1), derived
60
+ # from the manifest rather than a hardcoded zone name (ADR 0034 / D1).
61
+ queue = @manifest.policy.queue_zone
62
+ return [] unless queue
55
63
 
56
- rows = Read::List.new(container: @container).call(zone: "review")
64
+ rows = Read::List.new(container: @container).call(zone: queue)
57
65
  rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
58
66
  end
59
67
 
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Read
3
+ # Effective rule set (fetch + guard) for a key. Was the inlined MCP
4
+ # `rules` tool; promoted to a first-class verb so MCP is a pure projection
5
+ # (ADR 0039).
6
+ class Rules
7
+ extend Textus::Contract::DSL
8
+
9
+ verb :rules
10
+ summary "Return effective rules for a key (fetch, guard, ...)."
11
+ surfaces :ruby, :mcp
12
+ arg :key, String, required: true, positional: true
13
+
14
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
15
+ @manifest = container.manifest
16
+ end
17
+
18
+ def call(key)
19
+ set = @manifest.rules.for(key)
20
+ { "fetch" => set.fetch&.to_h, "guard" => set.guard }.compact
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,6 +1,13 @@
1
1
  module Textus
2
2
  module Read
3
3
  class SchemaEnvelope
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :schema
7
+ summary "Return the schema (field shape) for an entry's family, by key."
8
+ surfaces :ruby, :mcp
9
+ arg :key, String, required: true, positional: true
10
+
4
11
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
12
  @manifest = container.manifest
6
13
  @schemas = container.schemas
@@ -54,7 +54,7 @@ module Textus
54
54
  last_writer = @audit_log.last_writer_for(key)
55
55
  return if last_writer.nil?
56
56
 
57
- last_writer_is_authority = @manifest.policy.role_kind(last_writer) == :accept_authority
57
+ last_writer_is_authority = @manifest.policy.roles_with_capability("author").include?(last_writer)
58
58
 
59
59
  env.meta.each_key do |field|
60
60
  owner = schema.maintained_by(field)
data/lib/textus/role.rb CHANGED
@@ -2,9 +2,13 @@ module Textus
2
2
  module Role
3
3
  PATTERN = /\A[a-z][a-z0-9_-]*\z/
4
4
  DEFAULT = "human".freeze
5
+ # The default acting identity for the MCP transport (ADR 0040): an agent
6
+ # over stdio proposes; it does not inherit the human's authority. CLI
7
+ # callers keep the `human` DEFAULT.
8
+ AGENT = "agent".freeze
5
9
 
6
- def self.resolve(root:, flag: nil, env: ENV)
7
- candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || DEFAULT
10
+ def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
11
+ candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
8
12
  raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
9
13
 
10
14
  candidate