textus 0.43.1 → 0.45.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +3 -3
  4. data/SPEC.md +15 -13
  5. data/docs/architecture/README.md +28 -9
  6. data/docs/reference/conventions.md +8 -9
  7. data/lib/textus/boot.rb +10 -7
  8. data/lib/textus/cli/group/fetch.rb +2 -2
  9. data/lib/textus/cli/group.rb +1 -0
  10. data/lib/textus/cli/runner.rb +187 -0
  11. data/lib/textus/cli/verb/build.rb +4 -4
  12. data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
  13. data/lib/textus/cli/verb/get.rb +6 -5
  14. data/lib/textus/cli/verb/put.rb +3 -3
  15. data/lib/textus/cli/verb.rb +3 -0
  16. data/lib/textus/cli.rb +8 -2
  17. data/lib/textus/contract/around.rb +29 -0
  18. data/lib/textus/contract/binder.rb +88 -0
  19. data/lib/textus/contract/resources/cursor.rb +26 -0
  20. data/lib/textus/contract/sources.rb +39 -0
  21. data/lib/textus/contract/view.rb +15 -0
  22. data/lib/textus/contract.rb +78 -10
  23. data/lib/textus/dispatcher.rb +5 -6
  24. data/lib/textus/hooks/context.rb +24 -2
  25. data/lib/textus/maintenance/key_delete_prefix.rb +6 -4
  26. data/lib/textus/maintenance/key_mv_prefix.rb +7 -5
  27. data/lib/textus/maintenance/migrate.rb +12 -8
  28. data/lib/textus/maintenance/rule_lint.rb +4 -2
  29. data/lib/textus/maintenance/zone_mv.rb +7 -5
  30. data/lib/textus/manifest/entry/base.rb +1 -1
  31. data/lib/textus/mcp/catalog.rb +17 -33
  32. data/lib/textus/projection.rb +2 -2
  33. data/lib/textus/read/audit.rb +19 -0
  34. data/lib/textus/read/blame.rb +11 -1
  35. data/lib/textus/read/deps.rb +15 -1
  36. data/lib/textus/read/doctor.rb +8 -0
  37. data/lib/textus/read/freshness.rb +10 -0
  38. data/lib/textus/read/get.rb +88 -22
  39. data/lib/textus/read/list.rb +3 -2
  40. data/lib/textus/read/published.rb +7 -0
  41. data/lib/textus/read/pulse.rb +1 -0
  42. data/lib/textus/read/rdeps.rb +14 -0
  43. data/lib/textus/read/rule_explain.rb +84 -0
  44. data/lib/textus/read/rule_list.rb +39 -0
  45. data/lib/textus/read/schema_envelope.rb +5 -3
  46. data/lib/textus/read/uid.rb +9 -0
  47. data/lib/textus/read/where.rb +8 -0
  48. data/lib/textus/role_scope.rb +34 -6
  49. data/lib/textus/schema/tools.rb +12 -3
  50. data/lib/textus/version.rb +1 -1
  51. data/lib/textus/write/accept.rb +8 -0
  52. data/lib/textus/write/{publish.rb → build.rb} +16 -7
  53. data/lib/textus/write/delete.rb +13 -0
  54. data/lib/textus/write/fetch_all.rb +3 -2
  55. data/lib/textus/write/fetch_orchestrator.rb +1 -1
  56. data/lib/textus/write/fetch_worker.rb +3 -2
  57. data/lib/textus/write/mv.rb +16 -0
  58. data/lib/textus/write/propose.rb +12 -4
  59. data/lib/textus/write/put.rb +11 -6
  60. data/lib/textus/write/reject.rb +8 -0
  61. data/lib/textus/write/retention_sweep.rb +9 -0
  62. metadata +11 -29
  63. data/lib/textus/cli/verb/accept.rb +0 -16
  64. data/lib/textus/cli/verb/audit.rb +0 -34
  65. data/lib/textus/cli/verb/blame.rb +0 -17
  66. data/lib/textus/cli/verb/delete.rb +0 -17
  67. data/lib/textus/cli/verb/deps.rb +0 -14
  68. data/lib/textus/cli/verb/freshness.rb +0 -17
  69. data/lib/textus/cli/verb/key_delete.rb +0 -24
  70. data/lib/textus/cli/verb/list.rb +0 -16
  71. data/lib/textus/cli/verb/migrate.rb +0 -18
  72. data/lib/textus/cli/verb/mv.rb +0 -27
  73. data/lib/textus/cli/verb/propose.rb +0 -28
  74. data/lib/textus/cli/verb/published.rb +0 -13
  75. data/lib/textus/cli/verb/pulse.rb +0 -26
  76. data/lib/textus/cli/verb/rdeps.rb +0 -14
  77. data/lib/textus/cli/verb/reject.rb +0 -16
  78. data/lib/textus/cli/verb/retain.rb +0 -19
  79. data/lib/textus/cli/verb/rule_explain.rb +0 -16
  80. data/lib/textus/cli/verb/rule_lint.rb +0 -18
  81. data/lib/textus/cli/verb/rule_list.rb +0 -29
  82. data/lib/textus/cli/verb/schema.rb +0 -15
  83. data/lib/textus/cli/verb/uid.rb +0 -15
  84. data/lib/textus/cli/verb/where.rb +0 -14
  85. data/lib/textus/cli/verb/zone_mv.rb +0 -19
  86. data/lib/textus/read/get_or_fetch.rb +0 -69
  87. data/lib/textus/read/policy_explain.rb +0 -46
  88. data/lib/textus/read/rules.rb +0 -24
@@ -4,7 +4,7 @@ module Textus
4
4
  # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
5
5
  # tools/call dispatch: map JSON args -> (positional, keyword) per the
6
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.
7
+ # return value with the contract's default view. No per-tool code.
8
8
  module Catalog
9
9
  module_function
10
10
 
@@ -35,6 +35,17 @@ module Textus
35
35
  .keys.map(&:to_s)
36
36
  end
37
37
 
38
+ # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
39
+ # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
40
+ # from this so it advertises bare verb names the agent can call (no `--as`/
41
+ # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
42
+ # (ADR 0056, ADR 0057).
43
+ def write_verbs
44
+ Textus::Dispatcher::VERBS
45
+ .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Write::") }
46
+ .keys.map(&:to_s)
47
+ end
48
+
38
49
  def mcp_surfaced?(klass)
39
50
  klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
40
51
  end
@@ -44,43 +55,16 @@ module Textus
44
55
  raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
45
56
 
46
57
  spec = klass.contract
47
- pos, kw = map_args(spec, args || {}, session)
48
- result = store.as(session.role).public_send(spec.verb, *pos, **kw)
49
- spec.response.call(result)
58
+ inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
59
+ result = store.as(session.role).dispatch_bound(spec.verb, inputs, session: session)
60
+ Textus::Contract::View.render(spec, :default, result, inputs)
61
+ rescue Textus::Contract::MissingArgs => e
62
+ raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
50
63
  rescue ContractDrift, CursorExpired
51
64
  raise
52
65
  rescue Textus::Error => e
53
66
  raise ToolError.new("#{name}: #{e.message}")
54
67
  end
55
-
56
- # Splits the raw JSON arg hash into the positional list and keyword hash
57
- # the use-case expects, validating required presence first.
58
- # Session-default args (session_default: :method_name) are injected from
59
- # the session when absent from the wire; they are never treated as missing.
60
- # Positional args are emitted in contract declaration order; use-case signatures must match.
61
- def map_args(spec, raw, session = nil)
62
- missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
63
- raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
64
-
65
- positional = []
66
- keyword = {}
67
- spec.args.each do |a|
68
- if raw.key?(a.name.to_s)
69
- value = raw[a.name.to_s]
70
- elsif a.session_default && session
71
- value = session.public_send(a.session_default)
72
- else
73
- next
74
- end
75
-
76
- if a.positional
77
- positional << value
78
- else
79
- keyword[a.name] = value
80
- end
81
- end
82
- [positional, keyword]
83
- end
84
68
  end
85
69
  end
86
70
  end
@@ -7,8 +7,8 @@ module Textus
7
7
  REDUCER_TIMEOUT_SECONDS = 2
8
8
 
9
9
  # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
10
- # semantics: pure read (`ops.get`) for materialization paths;
11
- # `ops.get_or_fetch` if you want fetch-on-stale.
10
+ # semantics: pure read (`Read::Get.new(...).call(key)`, fetch:false default) for
11
+ # materialization paths; `ops.get` (read-through, fetch:true injected) for 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:`.
@@ -30,6 +30,25 @@ module Textus
30
30
  end
31
31
  end
32
32
 
33
+ extend Textus::Contract::DSL
34
+
35
+ verb :audit
36
+ summary "Query the audit log with optional filters."
37
+ surfaces :cli, :ruby
38
+ cli "audit"
39
+ # #call(**filters) — args map to Query.build keyword params (ADR 0063)
40
+ arg :key, String, required: false, description: "filter to rows for this key"
41
+ arg :zone, String, required: false, description: "filter to keys in this zone"
42
+ arg :role, String, required: false, description: "filter to rows written under this role"
43
+ arg :verb, String, required: false, description: "filter to rows for this verb"
44
+ arg :since, String, required: false,
45
+ coerce: ->(s) { Textus::Read::Audit.parse_since(s, now: Time.now) },
46
+ description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
47
+ arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
48
+ arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
49
+ arg :limit, Integer, required: false, description: "maximum number of rows to return"
50
+ view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
51
+
33
52
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
34
53
  @manifest = container.manifest
35
54
  @root = container.root
@@ -7,13 +7,23 @@ module Textus
7
7
  # row. Falls back to `git => nil` when not in a git repo or when the
8
8
  # file is untracked.
9
9
  class Blame
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :blame
13
+ summary "Annotate audit rows for a key with the git commit that introduced each file state."
14
+ surfaces :cli, :ruby
15
+ cli "blame"
16
+ arg :key, String, required: true, positional: true, description: "entry key to blame"
17
+ arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
18
+ view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
19
+
10
20
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
11
21
  @container = container
12
22
  @manifest = container.manifest
13
23
  @root = container.root
14
24
  end
15
25
 
16
- def call(key:, limit: nil)
26
+ def call(key, limit: nil)
17
27
  audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
18
28
  path = resolve_path(key)
19
29
  return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
@@ -1,12 +1,26 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Deps
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :deps
7
+ summary "List the keys a derived entry depends on (its projection/external sources)."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :key, String, required: true, positional: true,
10
+ description: "dotted key of the derived entry whose source keys you want"
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end
7
15
 
8
16
  def call(key)
9
- entry = @manifest.data.entries.find { |e| e.key == key } or return []
17
+ { "key" => key, "deps" => sources_for(key) }
18
+ end
19
+
20
+ private
21
+
22
+ def sources_for(key)
23
+ entry = @manifest.data.entries.find { |e| e.key == key }
10
24
  return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
11
25
 
12
26
  src = entry.source
@@ -6,6 +6,14 @@ module Textus
6
6
  # The acting role is irrelevant to a read-only health check, so `call`
7
7
  # is not consulted.
8
8
  class Doctor
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :doctor
12
+ summary "Run health checks on the textus store and report any issues."
13
+ surfaces :cli, :ruby
14
+ cli "doctor"
15
+ arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
16
+
9
17
  def initialize(container:, call:)
10
18
  @container = container
11
19
  @call = call
@@ -7,6 +7,16 @@ module Textus
7
7
  # current status. Status is one of :fresh, :stale, :never_fetched, or
8
8
  # :no_policy.
9
9
  class Freshness
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :freshness
13
+ summary "Report the fetch-freshness status of every entry with a fetch policy."
14
+ surfaces :cli, :ruby
15
+ cli "freshness"
16
+ arg :prefix, String, required: false, description: "filter to keys with this prefix"
17
+ arg :zone, String, required: false, description: "filter to entries in this zone"
18
+ view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
19
+
10
20
  def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
11
21
  @container = container
12
22
  @call = call
@@ -1,53 +1,103 @@
1
1
  module Textus
2
2
  module Read
3
- # Pure read: returns the on-disk envelope annotated with a freshness
4
- # verdict. Never triggers fetch; never invokes the orchestrator.
3
+ # The one read path. `fetch:` controls behavior:
4
+ # fetch: false (default) pure read: the on-disk envelope annotated with
5
+ # a freshness verdict. NEVER builds the orchestrator (no threads/forks/
6
+ # locks/events). This is the safe default for direct (in-process)
7
+ # callers — accept/reject/publish, materializer, uid, validate_all/
8
+ # validator, schema tooling, and the hook context — that must read
9
+ # persisted truth without triggering a fetch.
10
+ # fetch: true — read-through: after a stale verdict, hands off to the
11
+ # fetch orchestrator per the entry's fetch rule (degrades to the pure
12
+ # result when the key has no rule).
5
13
  #
6
- # For interactive reads that want fetch-on-stale, use
7
- # `Read::GetOrFetch`, which composes this with the orchestrator.
14
+ # The public `get` verb is read-through because the contract declares
15
+ # `arg :fetch, default: true`, injected on every verb surface (RoleScope +
16
+ # MCP map_args, ADR 0062 amendment). Direct construction bypasses that
17
+ # injection and so gets the safe `fetch: false` method default.
8
18
  class Get
9
19
  extend Textus::Contract::DSL
10
20
 
11
21
  verb :get
12
- summary "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness)."
22
+ summary "Read one entry. Read-through by default fetches on stale per " \
23
+ "the entry's fetch rule, degrading to a pure read when the key " \
24
+ "has no rule. Pass fetch:false for a guaranteed pure on-disk " \
25
+ "read. Returns the envelope (uid, etag, _meta, body, freshness)."
13
26
  surfaces :cli, :ruby, :mcp
14
- arg :key, String, required: true, positional: true
15
- response(&:to_h_for_wire)
27
+ arg :key, String, required: true, positional: true,
28
+ description: "dotted entry key to read, e.g. 'knowledge.project'"
29
+ arg :fetch, :boolean, default: true,
30
+ description: "read-through (fetch on stale per the " \
31
+ "entry's fetch rule) when true, the default; " \
32
+ "false returns the on-disk envelope without ever fetching"
33
+ view { |v, _i| v.to_h_for_wire }
16
34
 
17
- def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
35
+ def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator, orchestrator: nil)
18
36
  @container = container
19
37
  @call = call
20
38
  @manifest = container.manifest
21
39
  @file_store = container.file_store
22
40
  @evaluator = evaluator
41
+ @orchestrator = orchestrator # nil → built lazily on first fetch only
23
42
  end
24
43
 
25
- def call(key)
44
+ def call(key, fetch: false)
45
+ envelope = annotated_envelope(key)
46
+ return envelope if envelope.nil?
47
+ return envelope unless fetch && envelope.freshness&.stale
48
+
49
+ fetch_policy = fetch_policy_for(key)
50
+ return envelope if fetch_policy.nil?
51
+
52
+ policy = fetch_policy.to_freshness_policy
53
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
54
+ outcome = orchestrator.execute(policy.decide(verdict), key: key)
55
+ resolve(outcome, envelope)
56
+ end
57
+
58
+ # Strict variant: raises UnknownKey when the entry is missing.
59
+ # Used by consumers (e.g. uid, Validator) that distinguish absence.
60
+ def get(key, fetch: false)
61
+ call(key, fetch: fetch) ||
62
+ raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
63
+ end
64
+
65
+ private
66
+
67
+ # Pure read + freshness verdict; no orchestrator dependency.
68
+ def annotated_envelope(key)
26
69
  envelope = read_raw_envelope(key)
27
70
  return nil if envelope.nil?
28
71
 
29
- policy_set = @manifest.rules.for(key)
30
- fetch_policy = policy_set.fetch
72
+ fetch_policy = fetch_policy_for(key)
31
73
  return annotate_fresh(envelope) if fetch_policy.nil?
32
74
 
33
- policy = fetch_policy.to_freshness_policy
75
+ policy = fetch_policy.to_freshness_policy
34
76
  verdict = @evaluator.call(policy, envelope, now: @call.now)
35
-
36
77
  envelope.with(freshness: Textus::Domain::Freshness.build(
37
- stale: verdict.stale?,
38
- reason: verdict.reason,
39
- fetching: false,
78
+ stale: verdict.stale?, reason: verdict.reason, fetching: false,
40
79
  ))
41
80
  end
42
81
 
43
- # Strict variant: raises UnknownKey when the entry is missing.
44
- # Used by consumers (e.g. Validator) that need to distinguish absence
45
- # from emptiness.
46
- def get(key)
47
- call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
82
+ def fetch_policy_for(key)
83
+ @manifest.rules.for(key).fetch
48
84
  end
49
85
 
50
- private
86
+ def resolve(outcome, envelope)
87
+ case outcome
88
+ when Textus::Domain::Outcome::Skipped
89
+ envelope
90
+ when Textus::Domain::Outcome::Fetched
91
+ outcome.envelope.with(
92
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
93
+ )
94
+ when Textus::Domain::Outcome::Detached
95
+ envelope.with(freshness: envelope.freshness.with(fetching: true))
96
+ when Textus::Domain::Outcome::Failed
97
+ envelope.with(freshness: envelope.freshness.with(fetch_error: outcome.error.message))
98
+ else raise "unexpected fetch outcome: #{outcome.class}"
99
+ end
100
+ end
51
101
 
52
102
  def read_raw_envelope(key)
53
103
  res = @manifest.resolver.resolve(key)
@@ -69,6 +119,22 @@ module Textus
69
119
  stale: false, reason: nil, fetching: false,
70
120
  ))
71
121
  end
122
+
123
+ def orchestrator
124
+ @orchestrator ||= build_orchestrator
125
+ end
126
+
127
+ def build_orchestrator
128
+ worker = Textus::Write::FetchWorker.new(container: @container, call: @call)
129
+ Textus::Write::FetchOrchestrator.new(
130
+ worker: worker, store_root: @container.root, events: @container.events,
131
+ hook_context: hook_context
132
+ )
133
+ end
134
+
135
+ def hook_context
136
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
137
+ end
72
138
  end
73
139
  end
74
140
  end
@@ -6,8 +6,9 @@ module Textus
6
6
  verb :list
7
7
  summary "List keys filtered by zone and/or prefix."
8
8
  surfaces :cli, :ruby, :mcp
9
- arg :prefix, String
10
- arg :zone, String
9
+ arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
10
+ arg :zone, String, description: "restrict to one zone by name (see `boot` zones); combine with prefix to narrow further"
11
+ view(:cli) { |rows| { "entries" => rows } }
11
12
 
12
13
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
13
14
  @manifest = container.manifest
@@ -1,6 +1,13 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Published
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :published
7
+ summary "List all entries that declare a publish_to target."
8
+ surfaces :cli, :ruby
9
+ cli "published"
10
+
4
11
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
12
  @manifest = container.manifest
6
13
  end
@@ -12,6 +12,7 @@ module Textus
12
12
  verb :pulse
13
13
  summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
14
14
  surfaces :cli, :ruby, :mcp
15
+ around :cursor
15
16
  arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
16
17
 
17
18
  def initialize(container:, call:)
@@ -1,11 +1,25 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Rdeps
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :rdeps
7
+ summary "List the derived entries that depend on a key (reverse deps / impact set)."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :key, String, required: true, positional: true,
10
+ description: "dotted key whose dependents (what would be stranded if it moved) you want"
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end
7
15
 
8
16
  def call(key)
17
+ { "key" => key, "rdeps" => dependents_of(key) }
18
+ end
19
+
20
+ private
21
+
22
+ def dependents_of(key)
9
23
  @manifest.data.entries.each_with_object([]) do |e, acc|
10
24
  next unless e.is_a?(Textus::Manifest::Entry::Derived)
11
25
 
@@ -0,0 +1,84 @@
1
+ module Textus
2
+ module Read
3
+ # Effective rules for a key, at two depths (ADR 0059). Lean by default —
4
+ # `{ fetch, guard }`, the agent-cheap read that was the `rules` verb. With
5
+ # `detail: true` it returns the verbose explanation — every matching policy
6
+ # block plus the per-transition guard predicate names — that was
7
+ # `policy_explain`. One verb, one name across CLI/MCP/method; the audience
8
+ # split is a parameter, not two tools.
9
+ class RuleExplain
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :rule_explain
13
+ summary "Effective rules for a key. Lean {fetch, guard} by default; detail: true adds matched blocks + guard predicates."
14
+ surfaces :cli, :ruby, :mcp
15
+ cli "rule explain"
16
+ arg :key, String, required: true, positional: true,
17
+ description: "dotted key whose effective rules you want (fetch ttl/action, write guard, ...)"
18
+ arg :detail, :boolean,
19
+ description: "defaults false: lean {fetch, guard}. detail: true adds matched blocks + guard predicates per transition."
20
+ view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
21
+
22
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
23
+ @manifest = container.manifest
24
+ @schemas = container.schemas
25
+ end
26
+
27
+ def call(key, detail: false)
28
+ detail ? explain(key) : effective(key)
29
+ end
30
+
31
+ private
32
+
33
+ # Lean: the effective winners only (formerly Read::Rules / the `rules` verb).
34
+ def effective(key)
35
+ set = @manifest.rules.for(key)
36
+ {
37
+ "fetch" => set.fetch && {
38
+ "ttl_seconds" => set.fetch.ttl_seconds,
39
+ "on_stale" => set.fetch.on_stale,
40
+ "sync_budget_ms" => set.fetch.sync_budget_ms,
41
+ "fetch_timeout_seconds" => set.fetch.fetch_timeout_seconds,
42
+ },
43
+ "guard" => set.guard,
44
+ }.compact
45
+ end
46
+
47
+ # Verbose: every matching block, per-slot effective value, and the
48
+ # effective guard predicate names for each write transition (formerly
49
+ # Read::PolicyExplain, ADR 0031).
50
+ def explain(key)
51
+ matching = @manifest.rules.explain(key)
52
+ winners = @manifest.rules.for(key)
53
+ factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
54
+
55
+ {
56
+ key: key,
57
+ matched_blocks: matching.map do |b|
58
+ {
59
+ match: b.match,
60
+ fetch: !b.fetch.nil?,
61
+ handler_allowlist: !b.handler_allowlist.nil?,
62
+ guard: !b.guard.nil?,
63
+ retention: !b.retention.nil?,
64
+ }
65
+ end,
66
+ effective: {
67
+ fetch: winners.fetch && {
68
+ ttl_seconds: winners.fetch.ttl_seconds,
69
+ on_stale: winners.fetch.on_stale,
70
+ },
71
+ handler_allowlist: winners.handler_allowlist&.handlers,
72
+ retention: winners.retention && {
73
+ expire_after: winners.retention.expire_after,
74
+ archive_after: winners.retention.archive_after,
75
+ },
76
+ },
77
+ guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
78
+ [transition, factory.for(transition, key).predicates.map(&:name)]
79
+ end,
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ module Read
3
+ # Enumerate every declared rule block in the manifest, in order. This is
4
+ # the whole-manifest view; `rule_explain` is the for-key view. Extracted
5
+ # from the CLI verb so the rule family is fully use-case-backed (ADR 0059);
6
+ # CLI-only (no MCP contract) — an agent reasons per-key via rule_explain.
7
+ class RuleList
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :rule_list
11
+ summary "List every rule block in the manifest."
12
+ surfaces :cli, :ruby
13
+ cli "rule list"
14
+ view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
15
+
16
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
17
+ @manifest = container.manifest
18
+ end
19
+
20
+ def call
21
+ @manifest.rules.blocks.map do |b|
22
+ row = { "match" => b.match }
23
+ if b.fetch
24
+ row["fetch"] = {
25
+ "ttl_seconds" => b.fetch.ttl_seconds,
26
+ "on_stale" => b.fetch.on_stale,
27
+ "sync_budget_ms" => b.fetch.sync_budget_ms,
28
+ "fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
29
+ }
30
+ end
31
+ row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
32
+ row["guard"] = b.guard if b.guard
33
+ row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
34
+ row
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -3,10 +3,12 @@ module Textus
3
3
  class SchemaEnvelope
4
4
  extend Textus::Contract::DSL
5
5
 
6
- verb :schema
6
+ verb :schema_show
7
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
8
+ surfaces :cli, :ruby, :mcp
9
+ cli "schema show"
10
+ arg :key, String, required: true, positional: true,
11
+ description: "any key in the family whose schema you want; returns required/optional fields and their types"
10
12
 
11
13
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
12
14
  @manifest = container.manifest
@@ -1,6 +1,15 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Uid
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :uid
7
+ summary "Return the stable UID of an entry without reading its body."
8
+ surfaces :cli, :ruby
9
+ cli "key uid"
10
+ arg :key, String, required: true, positional: true, description: "entry key"
11
+ view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
12
+
4
13
  def initialize(container:, call:)
5
14
  @container = container
6
15
  @call = call
@@ -1,6 +1,14 @@
1
1
  module Textus
2
2
  module Read
3
3
  class Where
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :where
7
+ summary "Resolve a key to its zone, owner, and path without reading the body."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :key, String, required: true, positional: true,
10
+ description: "dotted key to locate (returns zone, owner, path; does not read content)"
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end
@@ -36,14 +36,42 @@ module Textus
36
36
  self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
37
37
  end
38
38
 
39
+ # Single bind + invoke site for every surface. `inputs` is the uniform
40
+ # by-name hash (the binder's currency). MCP/CLI build it from their raw
41
+ # transport shape and call this directly; the per-verb Ruby methods below
42
+ # normalize positional+keyword Ruby args into `inputs` and delegate here.
43
+ def dispatch_bound(verb, inputs, session: nil)
44
+ klass = Textus::Dispatcher::VERBS[verb]
45
+ spec = (klass.contract if klass.respond_to?(:contract?) && klass.contract?)
46
+
47
+ invoke = lambda do |effective_inputs|
48
+ args, kwargs =
49
+ if spec
50
+ Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
51
+ else
52
+ [[], effective_inputs]
53
+ end
54
+ call_value = Textus::Call.build(role: @role, correlation_id: @correlation_id, dry_run: @dry_run)
55
+ Textus::Dispatcher.invoke(verb, container: @container, call: call_value, args: args, kwargs: kwargs)
56
+ end
57
+
58
+ if spec&.around
59
+ Textus::Contract::Around.with(spec.around, scope: self, inputs: inputs, session: session, &invoke)
60
+ else
61
+ invoke.call(inputs)
62
+ end
63
+ end
64
+
39
65
  Textus::Dispatcher::VERBS.each_key do |verb|
40
66
  define_method(verb) do |*args, **kwargs|
41
- call_value = Textus::Call.build(
42
- role: @role, correlation_id: @correlation_id, dry_run: @dry_run,
43
- )
44
- Textus::Dispatcher.invoke(
45
- verb, container: @container, call: call_value, args: args, kwargs: kwargs
46
- )
67
+ klass = Textus::Dispatcher::VERBS[verb]
68
+ inputs =
69
+ if klass.respond_to?(:contract?) && klass.contract?
70
+ Textus::Contract::Binder.inputs_from_ordered(klass.contract, args, kwargs)
71
+ else
72
+ kwargs
73
+ end
74
+ dispatch_bound(verb, inputs)
47
75
  end
48
76
  end
49
77
  end