textus 0.55.1 → 0.55.2

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
@@ -0,0 +1,417 @@
1
+ module Textus
2
+ module VerbRegistry
3
+ ArgSpec = Data.define(
4
+ :name, :type, :required, :positional, :session_default,
5
+ :description, :wire_name, :default, :source, :coerce, :cli_default
6
+ ) do
7
+ def wire = wire_name || name
8
+
9
+ # rubocop:disable Metrics/ParameterLists
10
+ def self.arg(name:, type: String, required: false, positional: false,
11
+ session_default: nil, description: nil, wire_name: nil,
12
+ default: nil, source: nil, coerce: nil, cli_default: nil)
13
+ new(name:, type:, required:, positional:, session_default:,
14
+ description:, wire_name:, default:, source:, coerce:, cli_default:)
15
+ end
16
+ # rubocop:enable Metrics/ParameterLists
17
+ end
18
+
19
+ TYPE_MAP = {
20
+ String => "string", Integer => "integer", Hash => "object",
21
+ Array => "array", :boolean => "boolean"
22
+ }.freeze
23
+
24
+ VerbSpec = Data.define(:verb, :summary, :args, :surfaces, :views, :cli, :cli_stdin, :category) do
25
+ def mcp? = surfaces.include?(:mcp)
26
+ def cli? = surfaces.include?(:cli)
27
+ def view(surface = :default) = views[surface] || views.fetch(:default)
28
+ def cli_path = cli || verb.to_s
29
+ def cli_words = cli_path.split
30
+ def cli_group = cli_words.size > 1 ? cli_words.first : nil
31
+ def cli_leaf = cli_words.last
32
+ def required_args = args.select(&:required)
33
+ def read? = category == :read
34
+ def write? = category == :write
35
+ def maintenance? = category == :maintenance
36
+
37
+ def input_schema
38
+ props = args.to_h do |a|
39
+ json_type = VerbRegistry::TYPE_MAP[a.type] || "string"
40
+ h = { "type" => json_type }
41
+ h["description"] = a.description if a.description
42
+ [a.wire.to_s, h]
43
+ end
44
+ { type: "object", properties: props, required: required_args.map { |a| a.wire.to_s } }
45
+ end
46
+ end
47
+
48
+ VERBS = {}
49
+ POSITIONAL = {}
50
+
51
+ def self.register(spec)
52
+ VERBS[spec.verb] = spec
53
+ POSITIONAL[spec.verb] = spec.args.select(&:positional).map(&:name)
54
+ end
55
+
56
+ def self.for(verb) = VERBS[verb]
57
+ def self.positional_for(verb) = POSITIONAL[verb] || []
58
+ def self.summary_for(verb) = VERBS[verb]&.summary
59
+ def self.registered = VERBS.values
60
+ def self.contract_class_for(verb) = VERB_TO_CONTRACT[verb]
61
+
62
+ VERB_TO_CONTRACT = {
63
+ get: Dispatch::Contracts::GetEntry,
64
+ put: Dispatch::Contracts::PutEntry,
65
+ list: Dispatch::Contracts::ListKeys,
66
+ key_delete: Dispatch::Contracts::DeleteKey,
67
+ key_mv: Dispatch::Contracts::MoveKey,
68
+ propose: Dispatch::Contracts::ProposeEntry,
69
+ accept: Dispatch::Contracts::AcceptProposal,
70
+ reject: Dispatch::Contracts::RejectProposal,
71
+ enqueue: Dispatch::Contracts::EnqueueJob,
72
+ audit: Dispatch::Contracts::AuditEntries,
73
+ pulse: Dispatch::Contracts::PulseEntries,
74
+ blame: Dispatch::Contracts::BlameEntry,
75
+ where: Dispatch::Contracts::WhereEntry,
76
+ uid: Dispatch::Contracts::UidEntry,
77
+ deps: Dispatch::Contracts::DepsEntry,
78
+ rdeps: Dispatch::Contracts::RdepsEntry,
79
+ boot: Dispatch::Contracts::BootStore,
80
+ doctor: Dispatch::Contracts::DoctorStore,
81
+ published: Dispatch::Contracts::PublishedEntries,
82
+ rule_explain: Dispatch::Contracts::RuleExplain,
83
+ rule_list: Dispatch::Contracts::RuleList,
84
+ schema_show: Dispatch::Contracts::SchemaEnvelope,
85
+ drain: Dispatch::Contracts::DrainStore,
86
+ ingest: Dispatch::Contracts::IngestEntry,
87
+ jobs: Dispatch::Contracts::JobsAction,
88
+ rule_lint: Dispatch::Contracts::RuleLint,
89
+ data_mv: Dispatch::Contracts::DataMv,
90
+ key_mv_prefix: Dispatch::Contracts::KeyMvPrefix,
91
+ key_delete_prefix: Dispatch::Contracts::KeyDeletePrefix,
92
+ }.freeze
93
+
94
+ CONTRACT_TO_VERB = VERB_TO_CONTRACT.invert.freeze
95
+
96
+ private_constant :VERB_TO_CONTRACT, :CONTRACT_TO_VERB
97
+
98
+ def self.contract_to_verb(klass)
99
+ CONTRACT_TO_VERB[klass] || klass.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
100
+ end
101
+
102
+ def self.contract_to_verb!(klass)
103
+ CONTRACT_TO_VERB.fetch(klass) { raise "unknown contract class: #{klass}" }
104
+ end
105
+
106
+ identity = ->(v, _) { v }
107
+
108
+ # ── get ──────────────────────────────────────────────
109
+ register VerbSpec.new(
110
+ :get, "Read one entry — on-disk read with freshness verdict.",
111
+ [ArgSpec.arg(name: :key, required: true, positional: true,
112
+ description: "dotted entry key to read, e.g. 'knowledge.project'")],
113
+ %i[cli mcp], { default: ->(v, _i) { v&.to_h_for_wire } }, nil, nil, :read
114
+ )
115
+
116
+ # ── put ──────────────────────────────────────────────
117
+ register VerbSpec.new(
118
+ :put, "Create or update an entry. Schema-validated. Returns {uid, etag}.",
119
+ [ArgSpec.arg(name: :key, required: true, positional: true,
120
+ description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"),
121
+ ArgSpec.arg(name: :meta, type: Hash, wire_name: :_meta,
122
+ description: "frontmatter; reads back as `_meta`. Schema-validated — call `schema KEY` first"),
123
+ ArgSpec.arg(name: :body,
124
+ description: "markdown/text payload for md entries; use `content` for json/yaml"),
125
+ ArgSpec.arg(name: :content, type: Hash,
126
+ description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries"),
127
+ ArgSpec.arg(name: :if_etag,
128
+ description: "optimistic-concurrency guard; write rejected if entry changed since")],
129
+ %i[cli mcp], { default: ->(env, _) { { "uid" => env.uid, "etag" => env.etag } } }, nil, nil, :write
130
+ )
131
+
132
+ # ── list ─────────────────────────────────────────────
133
+ register VerbSpec.new(
134
+ :list, "List keys filtered by lane and/or prefix.",
135
+ [ArgSpec.arg(name: :prefix,
136
+ description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"),
137
+ ArgSpec.arg(name: :lane,
138
+ description: "restrict to one lane by name (see `boot` lanes)"),
139
+ ArgSpec.arg(name: :q,
140
+ description: "full-text search query over entry content (FTS5)"),
141
+ ArgSpec.arg(name: :schema,
142
+ description: "filter to entries whose schema matches this name")],
143
+ %i[cli mcp], { cli: ->(rows, _) { { "entries" => rows } }, default: identity }, nil, nil, :read
144
+ )
145
+
146
+ # ── delete ───────────────────────────────────────────
147
+ register VerbSpec.new(
148
+ :key_delete, "Delete one entry by key. Returns {ok, key, deleted}.",
149
+ [ArgSpec.arg(name: :key, required: true, positional: true,
150
+ description: "dotted entry key to delete"),
151
+ ArgSpec.arg(name: :if_etag,
152
+ description: "optimistic-concurrency guard: etag you last read")],
153
+ %i[cli mcp], { default: identity }, "key delete", nil, :write
154
+ )
155
+
156
+ # ── move ─────────────────────────────────────────────
157
+ register VerbSpec.new(
158
+ :key_mv, "Rename one entry (same zone + format). Refuses if target exists.",
159
+ [ArgSpec.arg(name: :old_key, required: true, positional: true, description: "current dotted key"),
160
+ ArgSpec.arg(name: :new_key, required: true, positional: true,
161
+ description: "new dotted key (same zone and format)"),
162
+ ArgSpec.arg(name: :dry_run, type: :boolean,
163
+ description: "when true, returns planned move without applying")],
164
+ %i[cli mcp], { default: identity }, "key mv", nil, :write
165
+ )
166
+
167
+ # ── propose ──────────────────────────────────────────
168
+ register VerbSpec.new(
169
+ :propose, "Write a proposal to the role's propose_lane. Auto-prefixes the key.",
170
+ [ArgSpec.arg(name: :key, required: true, positional: true,
171
+ description: "key relative to propose_lane, e.g. 'decisions.feature-x'"),
172
+ ArgSpec.arg(name: :meta, type: Hash, wire_name: :_meta,
173
+ description: "frontmatter. Include a 'proposal:' block naming the target_key"),
174
+ ArgSpec.arg(name: :body,
175
+ description: "markdown/text payload for markdown-format entries"),
176
+ ArgSpec.arg(name: :content, type: Hash,
177
+ description: "structured payload for json/yaml-format entries")],
178
+ %i[cli mcp], { default: ->(env, _) { env.to_h_for_wire } }, nil, :json, :write
179
+ )
180
+
181
+ # ── accept ───────────────────────────────────────────
182
+ register VerbSpec.new(
183
+ :accept, "Apply a queued proposal to its target zone; requires author.",
184
+ [ArgSpec.arg(name: :pending_key, required: true, positional: true,
185
+ description: "the queued proposal's key")],
186
+ %i[cli mcp], { default: identity }, "accept", nil, :write
187
+ )
188
+
189
+ # ── reject ───────────────────────────────────────────
190
+ register VerbSpec.new(
191
+ :reject, "Discard a queued proposal without applying it.",
192
+ [ArgSpec.arg(name: :pending_key, required: true, positional: true,
193
+ description: "the queued proposal's key")],
194
+ %i[cli mcp], { default: identity }, "reject", nil, :write
195
+ )
196
+
197
+ # ── enqueue ──────────────────────────────────────────
198
+ register VerbSpec.new(
199
+ :enqueue, "Push a registered job type onto the convergence queue.",
200
+ [ArgSpec.arg(name: :type, required: true, positional: true,
201
+ description: "registered job type (e.g. materialize, re-pull, sweep)"),
202
+ ArgSpec.arg(name: :args, type: Hash, default: {},
203
+ description: "type-specific arguments (e.g. { key: ... })")],
204
+ %i[cli mcp], { default: identity }, "enqueue", nil, :write
205
+ )
206
+
207
+ # ── ingest ───────────────────────────────────────────
208
+ register VerbSpec.new(
209
+ :ingest, "Capture external source material into the raw lane. Write-once.",
210
+ [ArgSpec.arg(name: :kind, required: true, positional: true,
211
+ description: "source kind: url | file | asset"),
212
+ ArgSpec.arg(name: :slug, required: true,
213
+ description: "human slug for the key suffix (kebab-case)"),
214
+ ArgSpec.arg(name: :url, description: "remote URL (required when kind=url)"),
215
+ ArgSpec.arg(name: :path,
216
+ description: "local file path (required when kind=file or kind=asset)"),
217
+ ArgSpec.arg(name: :lane,
218
+ description: "asset group subdirectory (required when kind=asset)"),
219
+ ArgSpec.arg(name: :label, description: "human label stored in source.label")],
220
+ %i[cli mcp], { default: ->(env, _) { { "key" => env.key, "uid" => env.uid, "etag" => env.etag } } }, nil, nil, :write
221
+ )
222
+
223
+ # ── where ────────────────────────────────────────────
224
+ register VerbSpec.new(
225
+ :where, "Resolve a key to its zone, owner, and path without reading the body.",
226
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted key to locate")],
227
+ %i[cli mcp], { default: identity }, nil, nil, :read
228
+ )
229
+
230
+ # ── uid ──────────────────────────────────────────────
231
+ register VerbSpec.new(
232
+ :uid, "Return the stable UID of an entry without reading its body.",
233
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "entry key")],
234
+ [:cli], { cli: ->(uid, inputs) { { "key" => inputs[:key], "uid" => uid } }, default: identity }, "key uid", nil, :read
235
+ )
236
+
237
+ # ── blame ────────────────────────────────────────────
238
+ register VerbSpec.new(
239
+ :blame, "Annotate audit rows with the git commit that introduced each file state.",
240
+ [ArgSpec.arg(name: :key, required: true, positional: true, description: "entry key to blame"),
241
+ ArgSpec.arg(name: :limit, type: Integer,
242
+ description: "maximum number of audit rows to return")],
243
+ [:cli], {
244
+ cli: ->(rows, inputs) { { "verb" => "blame", "key" => inputs[:key], "rows" => rows } },
245
+ default: identity,
246
+ }, "blame", nil, :read
247
+ )
248
+
249
+ # ── audit ────────────────────────────────────────────
250
+ register VerbSpec.new(
251
+ :audit, "Query the audit log with optional filters.",
252
+ [ArgSpec.arg(name: :key, description: "filter to rows for this key"),
253
+ ArgSpec.arg(name: :lane, description: "filter to keys in this lane"),
254
+ ArgSpec.arg(name: :role, description: "filter to rows written under this role"),
255
+ ArgSpec.arg(name: :verb, description: "filter to rows for this verb"),
256
+ ArgSpec.arg(name: :since,
257
+ description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"),
258
+ ArgSpec.arg(name: :seq_since, type: Integer,
259
+ description: "return rows with seq > this cursor value"),
260
+ ArgSpec.arg(name: :correlation_id,
261
+ description: "filter to rows with this correlation_id"),
262
+ ArgSpec.arg(name: :limit, type: Integer,
263
+ description: "maximum number of rows to return")],
264
+ [:cli], { cli: ->(rows, _) { { "verb" => "audit", "rows" => rows } }, default: identity }, "audit", nil, :read
265
+ )
266
+
267
+ # ── deps ─────────────────────────────────────────────
268
+ register VerbSpec.new(
269
+ :deps, "List the keys a derived entry depends on.",
270
+ [ArgSpec.arg(name: :key, required: true, positional: true,
271
+ description: "dotted key of the derived entry whose source keys you want")],
272
+ %i[cli mcp], { default: identity }, nil, nil, :read
273
+ )
274
+
275
+ # ── rdeps ────────────────────────────────────────────
276
+ register VerbSpec.new(
277
+ :rdeps, "List the derived entries that depend on a key (reverse deps).",
278
+ [ArgSpec.arg(name: :key, required: true, positional: true,
279
+ description: "dotted key whose dependents you want")],
280
+ %i[cli mcp], { default: identity }, nil, nil, :read
281
+ )
282
+
283
+ # ── pulse ────────────────────────────────────────────
284
+ register VerbSpec.new(
285
+ :pulse, "Delta since cursor — changed entries, pending proposals, index freshness.",
286
+ [ArgSpec.arg(name: :since, type: Integer, session_default: :cursor,
287
+ description: "audit seq to diff from; defaults to the session cursor")],
288
+ %i[cli mcp], { default: identity }, nil, nil, :read
289
+ )
290
+
291
+ # ── rule_explain ─────────────────────────────────────
292
+ register VerbSpec.new(
293
+ :rule_explain, "Effective rules for a key. Lean by default; detail: true adds matched blocks.",
294
+ [ArgSpec.arg(name: :key, required: true, positional: true,
295
+ description: "dotted key whose effective rules you want"),
296
+ ArgSpec.arg(name: :detail, type: :boolean,
297
+ description: "detail: true adds matched blocks + guard predicates")],
298
+ %i[cli mcp], {
299
+ cli: ->(r, _) { { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) },
300
+ default: identity,
301
+ }, "rule explain", nil, :read
302
+ )
303
+
304
+ # ── rule_list ────────────────────────────────────────
305
+ register VerbSpec.new(
306
+ :rule_list, "List every rule block in the manifest.",
307
+ [], [:cli], { cli: ->(p, _) { { "verb" => "rule_list", "policies" => p } }, default: identity }, "rule list", nil, :read
308
+ )
309
+
310
+ # ── published ────────────────────────────────────────
311
+ register VerbSpec.new(
312
+ :published, "List all entries that declare a publish target.",
313
+ [], [:cli], { default: identity }, "published", nil, :read
314
+ )
315
+
316
+ # ── schema_show ──────────────────────────────────────
317
+ register VerbSpec.new(
318
+ :schema_show, "Return the schema (field shape) for an entry's family.",
319
+ [ArgSpec.arg(name: :key, required: true, positional: true,
320
+ description: "any key in the family whose schema you want")],
321
+ %i[cli mcp], { default: identity }, "schema show", nil, :read
322
+ )
323
+
324
+ # ── doctor ───────────────────────────────────────────
325
+ register VerbSpec.new(
326
+ :doctor, "Run health checks on the textus store.",
327
+ [ArgSpec.arg(name: :checks, type: Array,
328
+ description: "subset of check names to run (default: all)")],
329
+ [:cli], { default: identity }, "doctor", nil, :read
330
+ )
331
+
332
+ # ── boot ─────────────────────────────────────────────
333
+ register VerbSpec.new(
334
+ :boot, "Return the orientation contract: lanes, agent_quickstart, agent_protocol.",
335
+ [], %i[cli mcp], { default: identity }, nil, nil, :read
336
+ )
337
+
338
+ # ── jobs ─────────────────────────────────────────────
339
+ register VerbSpec.new(
340
+ :jobs, "List queued jobs by state; retry a dead-lettered job or purge.",
341
+ [ArgSpec.arg(name: :state, default: "ready",
342
+ description: "ready|leased|done|failed"),
343
+ ArgSpec.arg(name: :action, description: "retry|purge (optional)"),
344
+ ArgSpec.arg(name: :job_id, description: "job id (required for action=retry)")],
345
+ %i[cli mcp], { default: identity }, "jobs", nil, :maintenance
346
+ )
347
+
348
+ # ── data_mv ──────────────────────────────────────────
349
+ register VerbSpec.new(
350
+ :data_mv, "Rename a data lane — manifest + files. Refuses if destination exists.",
351
+ [ArgSpec.arg(name: :from, required: true, positional: true, description: "current data lane name"),
352
+ ArgSpec.arg(name: :to, required: true, positional: true, description: "new data lane name"),
353
+ ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
354
+ description: "when true, returns planned zone move without applying")],
355
+ %i[cli mcp], { default: ->(v, _) { v.to_h } }, "data mv", nil, :write
356
+ )
357
+
358
+ # ── key_mv_prefix ────────────────────────────────────
359
+ register VerbSpec.new(
360
+ :key_mv_prefix, "Bulk-rename every leaf key under from_prefix to to_prefix.",
361
+ [ArgSpec.arg(name: :from_prefix, required: true, positional: true,
362
+ description: "dotted prefix whose leaf keys are renamed"),
363
+ ArgSpec.arg(name: :to_prefix, required: true, positional: true,
364
+ description: "dotted prefix the keys are renamed to"),
365
+ ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
366
+ description: "when true, returns planned moves without applying")],
367
+ %i[cli mcp], { default: ->(v, _) { v.to_h } }, "key mv-prefix", nil, :write
368
+ )
369
+
370
+ # ── key_delete_prefix ────────────────────────────────
371
+ register VerbSpec.new(
372
+ :key_delete_prefix, "Bulk-delete every leaf key under prefix.",
373
+ [ArgSpec.arg(name: :prefix, required: true, positional: true,
374
+ description: "every leaf key under this dotted prefix is deleted"),
375
+ ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
376
+ description: "when true, returns keys that would be deleted without deleting")],
377
+ %i[cli mcp], { default: ->(v, _) { v.to_h } }, "key delete-prefix", nil, :write
378
+ )
379
+
380
+ # ── drain ────────────────────────────────────────────
381
+ register VerbSpec.new(
382
+ :drain, "Seed materialize + sweep jobs then drain the queue to empty.",
383
+ [ArgSpec.arg(name: :prefix, description: "restrict to keys under this dotted prefix"),
384
+ ArgSpec.arg(name: :lane, description: "restrict to entries in this lane")],
385
+ %i[cli mcp], { default: identity }, nil, nil, :maintenance
386
+ )
387
+
388
+ # ── rule_lint ────────────────────────────────────────
389
+ register VerbSpec.new(
390
+ :rule_lint, "Diff candidate manifest rules against the live manifest.",
391
+ [ArgSpec.arg(name: :candidate_yaml, required: true,
392
+ wire_name: :against, source: :file,
393
+ description: "path to candidate manifest YAML")],
394
+ %i[cli mcp], { default: ->(v, _) { v.to_h } }, "rule lint", nil, :maintenance
395
+ )
396
+ end
397
+ end
398
+
399
+ # Generate explicit methods on Store for each registered verb so the API
400
+ # is statically discoverable by IDEs and documentation tools.
401
+ Textus::Store.class_eval do
402
+ Textus::VerbRegistry::VERBS.each do |verb, spec|
403
+ positional_names = Textus::VerbRegistry::POSITIONAL[verb] || []
404
+ define_method(verb) do |*args, **kwargs|
405
+ if args.size > positional_names.size
406
+ raise ArgumentError.new("#{verb} accepts #{positional_names.size} positional argument(s) (got #{args.size})")
407
+ end
408
+
409
+ positional_inputs = positional_names.zip(args).to_h.compact
410
+ inputs = positional_inputs.merge(kwargs)
411
+ pending = Textus::Dispatch::Binder.command(spec, inputs)
412
+ call = Textus::Value::Call.build(role: @role, correlation_id: @correlation_id)
413
+ result = @container.pipeline.dispatch(pending, call: call)
414
+ Textus::Value::Result.extract(result)
415
+ end
416
+ end
417
+ end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.55.1"
2
+ VERSION = "0.55.2"
3
3
  PROTOCOL = "textus/4"
4
4
  end
@@ -2,7 +2,7 @@ module Textus
2
2
  module Workflow
3
3
  class Loader
4
4
  def self.load_all(root)
5
- geometry = Textus::Store::Geometry.new(root)
5
+ geometry = Textus::Store::Layout.new(root)
6
6
  registry = Registry.new
7
7
  return registry unless File.directory?(geometry.workflow_dir)
8
8
 
@@ -58,27 +58,19 @@ module Textus
58
58
 
59
59
  def built_in_publish(key, data, ctx)
60
60
  normalized = Textus::Format.data_to_payload(data, ctx.entry.format)
61
- Gate::Auth.new(@container).check_action!(action: :converge, actor: @call.role, key: key)
62
- Textus::Store::Envelope::Writer.from(container: @container, call: @call).put(
61
+ guard_map = @container.manifest.rules.for(key).guard
62
+ rule_preds = guard_map ? Array(guard_map["converge"]) : []
63
+ Textus::Manifest::Policy::Predicates.evaluate(
64
+ manifest: @container.manifest, schemas: @container.schemas,
65
+ action: :converge, actor: @call.role, key: key,
66
+ rule_predicates: rule_preds
67
+ )
68
+ Textus::Store::Entry::Writer.from(container: @container, call: @call).put(
63
69
  key,
64
70
  mentry: ctx.entry,
65
- payload: Textus::Store::Envelope::Writer::Payload.new(**normalized),
66
- )
67
- publish_external(key, ctx)
68
- end
69
-
70
- def publish_external(key, ctx)
71
- entry = ctx.entry
72
- return unless entry.publish_tree || !Array(entry.publish_to).empty?
73
-
74
- entry_path = @container.manifest.resolver.resolve(key).path
75
- return unless entry.publish_tree || File.exist?(entry_path)
76
-
77
- reader = Textus::Store::Envelope::Reader.from(container: @container)
78
- pctx = Textus::Manifest::Entry::Base::PublishContext.new(
79
- container: @container, call: @call, reader: reader.method(:read),
71
+ payload: Textus::Value::Payload.new(**normalized),
80
72
  )
81
- entry.publish_via(pctx)
73
+ Textus::Produce::Publisher.call(container: @container, call: @call, key: key)
82
74
  end
83
75
  end
84
76
  end
data/lib/textus.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  require "zeitwerk"
2
- require "dry-monads"
3
2
  require_relative "textus/version"
4
3
  require_relative "textus/errors"
5
4
  require_relative "textus/surface/mcp"
@@ -18,76 +17,13 @@ loader.ignore(File.expand_path("textus/errors.rb", __dir__))
18
17
  loader.ignore(File.expand_path("textus/surface/mcp.rb", __dir__))
19
18
  loader.ignore(File.expand_path("textus/surface/mcp/errors.rb", __dir__))
20
19
  loader.ignore(File.expand_path("textus/workflow/errors.rb", __dir__))
21
- # Scaffold sources copied verbatim into user stores by `textus init`. They are
22
- # templates for user-owned step classes, not gem constants — Zeitwerk must not
23
- # manage or eager-load them.
24
20
  loader.ignore(File.expand_path("textus/init/templates", __dir__))
25
21
  loader.ignore(File.expand_path("textus/produce/acquire", __dir__))
26
22
  loader.setup
27
23
  loader.eager_load
28
24
 
29
- # Verb symbol → Action class mapping. Replaces Textus::Dispatcher::VERBS.
30
- Textus::Action::VERBS = {
31
- put: Textus::Action::Put,
32
- propose: Textus::Action::Propose,
33
- key_delete: Textus::Action::KeyDelete,
34
- key_mv: Textus::Action::KeyMv,
35
- accept: Textus::Action::Accept,
36
- reject: Textus::Action::Reject,
37
- enqueue: Textus::Action::Enqueue,
38
- get: Textus::Action::Get,
39
- ingest: Textus::Action::Ingest,
40
- list: Textus::Action::List,
41
- where: Textus::Action::Where,
42
- uid: Textus::Action::Uid,
43
- blame: Textus::Action::Blame,
44
- audit: Textus::Action::Audit,
45
- # materialize, refresh, sweep are Worker-only — not in VERBS
46
- deps: Textus::Action::Deps,
47
- rdeps: Textus::Action::Rdeps,
48
- pulse: Textus::Action::Pulse,
49
- rule_explain: Textus::Action::RuleExplain,
50
- rule_list: Textus::Action::RuleList,
51
- published: Textus::Action::Published,
52
- schema_show: Textus::Action::SchemaEnvelope,
53
- doctor: Textus::Action::Doctor,
54
- boot: Textus::Action::Boot,
55
- jobs: Textus::Action::Jobs,
56
- data_mv: Textus::Action::DataMv,
57
- key_mv_prefix: Textus::Action::KeyMvPrefix,
58
- key_delete_prefix: Textus::Action::KeyDeletePrefix,
59
- drain: Textus::Action::Drain,
60
- rule_lint: Textus::Action::RuleLint,
61
- }.freeze
62
-
63
- # Derive CLI_VERBS after VERBS is available.
64
25
  Textus::Boot::CLI_VERBS = Textus::Boot.build_cli_verbs.freeze
65
26
 
66
- # Dynamic verb methods on Store (deferred after VERBS is defined).
67
- Textus::Action::VERBS.each_key do |verb|
68
- Textus::Store.define_method(verb) do |*args, role: Textus::Value::Role::DEFAULT, **kwargs|
69
- as(role).public_send(verb, *args, **kwargs)
70
- end
71
-
72
- Textus::Surface::RoleScope.define_method(verb) do |*args, **kwargs|
73
- klass = Textus::Action::VERBS[verb]
74
- inputs = if klass.respond_to?(:contract?) && klass.contract?
75
- Textus::Gate::Binder.inputs_from_ordered(klass.contract, args, kwargs)
76
- else
77
- kwargs.transform_keys(&:to_sym)
78
- end
79
-
80
- role_value = if klass.respond_to?(:contract?) && klass.contract? &&
81
- klass.contract.args.any? { |a| a.name == :role }
82
- inputs[:role]
83
- else
84
- @role
85
- end
86
-
87
- @container.gate.dispatch(spec: klass.contract, inputs: inputs, role: role_value, correlation_id: @correlation_id)
88
- end
89
- end
90
-
91
27
  module Textus
92
28
  def self.workflow(name, &)
93
29
  collector = Workflow::Collector.current