textus 0.43.2 → 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 +57 -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 +3 -4
  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 +68 -8
  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 -9
  28. data/lib/textus/maintenance/rule_lint.rb +4 -3
  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 +6 -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 +86 -21
  39. data/lib/textus/read/list.rb +1 -0
  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 +3 -2
  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 +1 -0
  55. data/lib/textus/write/fetch_orchestrator.rb +1 -1
  56. data/lib/textus/write/fetch_worker.rb +1 -1
  57. data/lib/textus/write/mv.rb +16 -0
  58. data/lib/textus/write/propose.rb +7 -2
  59. data/lib/textus/write/put.rb +2 -2
  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 -25
@@ -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,54 +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
27
  arg :key, String, required: true, positional: true,
15
28
  description: "dotted entry key to read, e.g. 'knowledge.project'"
16
- response(&:to_h_for_wire)
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 }
17
34
 
18
- def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
35
+ def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator, orchestrator: nil)
19
36
  @container = container
20
37
  @call = call
21
38
  @manifest = container.manifest
22
39
  @file_store = container.file_store
23
40
  @evaluator = evaluator
41
+ @orchestrator = orchestrator # nil → built lazily on first fetch only
24
42
  end
25
43
 
26
- 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)
27
69
  envelope = read_raw_envelope(key)
28
70
  return nil if envelope.nil?
29
71
 
30
- policy_set = @manifest.rules.for(key)
31
- fetch_policy = policy_set.fetch
72
+ fetch_policy = fetch_policy_for(key)
32
73
  return annotate_fresh(envelope) if fetch_policy.nil?
33
74
 
34
- policy = fetch_policy.to_freshness_policy
75
+ policy = fetch_policy.to_freshness_policy
35
76
  verdict = @evaluator.call(policy, envelope, now: @call.now)
36
-
37
77
  envelope.with(freshness: Textus::Domain::Freshness.build(
38
- stale: verdict.stale?,
39
- reason: verdict.reason,
40
- fetching: false,
78
+ stale: verdict.stale?, reason: verdict.reason, fetching: false,
41
79
  ))
42
80
  end
43
81
 
44
- # Strict variant: raises UnknownKey when the entry is missing.
45
- # Used by consumers (e.g. Validator) that need to distinguish absence
46
- # from emptiness.
47
- def get(key)
48
- call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
82
+ def fetch_policy_for(key)
83
+ @manifest.rules.for(key).fetch
49
84
  end
50
85
 
51
- 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
52
101
 
53
102
  def read_raw_envelope(key)
54
103
  res = @manifest.resolver.resolve(key)
@@ -70,6 +119,22 @@ module Textus
70
119
  stale: false, reason: nil, fetching: false,
71
120
  ))
72
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
73
138
  end
74
139
  end
75
140
  end
@@ -8,6 +8,7 @@ module Textus
8
8
  surfaces :cli, :ruby, :mcp
9
9
  arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
10
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,9 +3,10 @@ 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
8
+ surfaces :cli, :ruby, :mcp
9
+ cli "schema show"
9
10
  arg :key, String, required: true, positional: true,
10
11
  description: "any key in the family whose schema you want; returns required/optional fields and their types"
11
12
 
@@ -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
@@ -6,7 +6,7 @@ module Textus
6
6
  module Tools
7
7
  # textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
8
8
  def self.init(store, name:, from:)
9
- env = store.as(Textus::Role::DEFAULT).get(from)
9
+ env = pure_get(store, Textus::Role::DEFAULT, from)
10
10
  meta = env.meta
11
11
  schema = {
12
12
  "name" => name,
@@ -25,7 +25,7 @@ module Textus
25
25
  schema = load_schema(store, name)
26
26
  drift = []
27
27
  store.manifest.resolver.enumerate.each do |row|
28
- env = store.as(Textus::Role::DEFAULT).get(row[:key])
28
+ env = pure_get(store, Textus::Role::DEFAULT, row[:key])
29
29
  begin
30
30
  schema.validate!(env.meta)
31
31
  rescue SchemaViolation => e
@@ -53,7 +53,7 @@ module Textus
53
53
  ops = store.as(authority)
54
54
  touched = []
55
55
  store.manifest.resolver.enumerate.each do |row|
56
- env = ops.get(row[:key])
56
+ env = pure_get(store, authority, row[:key])
57
57
  meta = env.meta.dup
58
58
  changed = false
59
59
  renames.each do |old, new|
@@ -81,6 +81,15 @@ module Textus
81
81
  end
82
82
  end
83
83
 
84
+ # Orchestrator-free read: schema tooling must never trigger a fetch
85
+ # while inspecting/migrating entries (ADR 0062).
86
+ def self.pure_get(store, role, key)
87
+ Textus::Read::Get.new(
88
+ container: store.as(role).container,
89
+ call: Textus::Call.build(role: role),
90
+ ).call(key)
91
+ end
92
+
84
93
  def self.load_schema(store, name)
85
94
  store.schemas.fetch(name)
86
95
  rescue IoError
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.43.2"
2
+ VERSION = "0.45.1"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -1,6 +1,14 @@
1
1
  module Textus
2
2
  module Write
3
3
  class Accept
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :accept
7
+ summary "apply a queued proposal to its target zone; requires the author capability"
8
+ surfaces :cli, :ruby
9
+ cli "accept"
10
+ arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
11
+
4
12
  def initialize(container:, call:)
5
13
  @container = container
6
14
  @call = call
@@ -1,14 +1,23 @@
1
1
  module Textus
2
2
  module Write
3
- # Single-pass publish use case: dispatches polymorphically to each
4
- # entry's `publish_via` method. Derived entries materialize their body
5
- # via Materializer; Nested entries mirror their subtree via publish_tree;
6
- # Leaf and Intake entries copy their stored body to publish_to targets.
7
- # The Publish layer owns wiring (context, accumulation) but not per-kind
8
- # logic.
3
+ # Single-pass build use case (the verb `build`, ADR 0061): dispatches
4
+ # polymorphically to each entry's `publish_via` method the copy-out step
5
+ # (`publish` is the output-destination concept the verb drives, not the verb).
6
+ # Derived entries materialize their body via Materializer; Nested entries
7
+ # mirror their subtree via publish_tree; Leaf and Intake entries copy their
8
+ # stored body to publish_to targets. The Build layer owns wiring (context,
9
+ # accumulation) but not per-kind logic.
9
10
  #
10
11
  # Return shape: { "protocol", "built", "published_leaves" }
11
- class Publish
12
+ class Build
13
+ extend Textus::Contract::DSL
14
+
15
+ verb :build
16
+ summary "materialize derived entries; publish_to and publish_tree fan out copies"
17
+ surfaces :cli, :ruby
18
+ cli "build"
19
+ arg :prefix, String, required: false, description: "limit the build to keys under this prefix"
20
+
12
21
  def initialize(container:, call:)
13
22
  @container = container
14
23
  @call = call