textus 0.54.2 → 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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class RuleList < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :rule_list
9
- summary "List every rule block in the manifest."
10
- surfaces :cli
11
- cli "rule list"
12
- view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
13
-
14
- def call(container:, **)
15
- manifest = container.manifest
16
- manifest.rules.blocks.map do |block|
17
- row = { "match" => block.match }
18
- self.class::LIST_FIELDS.each do |field|
19
- value = block.public_send(field)
20
- row[field.to_s] = serialize(field, value) unless value.nil?
21
- end
22
- row
23
- end
24
- end
25
-
26
- LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
27
-
28
- private
29
-
30
- def serialize(field, value)
31
- case field
32
- when :retention
33
- { "ttl_seconds" => value.ttl_seconds, "action" => value.action.to_s }
34
- when :react
35
- value.to_h
36
- else
37
- value
38
- end
39
- end
40
- end
41
- end
42
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class SchemaEnvelope < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :schema_show
9
- summary "Return the schema (field shape) for an entry's family, by key."
10
- surfaces :cli, :mcp
11
- cli "schema show"
12
- arg :key, String, required: true, positional: true,
13
- description: "any key in the family whose schema you want; returns required/optional fields and their types"
14
-
15
- def initialize(key:)
16
- super()
17
- @key = key
18
- end
19
-
20
- def call(container:, **)
21
- manifest = container.manifest
22
- schemas = container.schemas
23
- mentry = manifest.resolver.resolve(@key).entry
24
- schema = schemas.fetch_or_nil(mentry.schema)
25
- { "protocol" => PROTOCOL, "key" => @key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
26
- end
27
- end
28
- end
29
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Uid < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :uid
9
- summary "Return the stable UID of an entry without reading its body."
10
- surfaces :cli
11
- cli "key uid"
12
- arg :key, String, required: true, positional: true, description: "entry key"
13
- view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
14
-
15
- def initialize(key:)
16
- super()
17
- @key = key
18
- end
19
-
20
- def call(container:, call:)
21
- Textus::Action::Get.new(key: @key).call(container: container, call: call).uid
22
- end
23
-
24
- def self.new(*args, **kwargs)
25
- return super(**kwargs) unless args.any?
26
-
27
- positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
28
- mapped = positional.zip(args).to_h
29
- super(**mapped.merge(kwargs))
30
- end
31
- end
32
- end
33
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Where < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :where
9
- summary "Resolve a key to its zone, owner, and path without reading the body."
10
- surfaces :cli, :mcp
11
- arg :key, String, required: true, positional: true,
12
- description: "dotted key to locate (returns zone, owner, path; does not read content)"
13
-
14
- def initialize(key:)
15
- super()
16
- @key = key
17
- end
18
-
19
- def call(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
20
- manifest = container.manifest
21
- res = manifest.resolver.resolve(@key)
22
- mentry = res.entry
23
- path = res.path
24
- { "protocol" => PROTOCOL, "key" => @key, "lane" => mentry.lane, "owner" => mentry.owner, "path" => path }
25
- end
26
-
27
- def self.new(*args, **kwargs)
28
- return super(**kwargs) unless args.any?
29
-
30
- positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
31
- mapped = positional.zip(args).to_h
32
- super(**mapped.merge(kwargs))
33
- end
34
- end
35
- end
36
- end
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class WriteVerb < Base
6
- private
7
-
8
- def auth(container)
9
- Textus::Gate::Auth.new(container)
10
- end
11
-
12
- def writer(container, call)
13
- Textus::Envelope::Writer.from(container: container, call: call)
14
- end
15
-
16
- def reader(container)
17
- Textus::Envelope::Reader.from(container: container)
18
- end
19
-
20
- def run_with_cascade(target_key, container:, call:)
21
- result = yield
22
- cascade_to_rdeps(target_key, container, call) if target_key
23
- result
24
- end
25
-
26
- def cascade_to_rdeps(key, container, call)
27
- rdeps = Textus::Action::Rdeps.new(key: key).call(container: container, call: call).fetch("rdeps", [])
28
- producible = rdeps.select { |dep_key| producible?(dep_key, container) }
29
- return if producible.empty?
30
-
31
- producible.each do |dep_key|
32
- Textus::Jobs::Materialize.new(key: dep_key).call(container:, call:)
33
- end
34
- end
35
-
36
- def producible?(key, container)
37
- entry = container.manifest.resolver.resolve(key).entry
38
- !entry.publish_tree.nil?
39
- rescue Textus::Error
40
- false
41
- end
42
- end
43
- end
44
- end
data/lib/textus/call.rb DELETED
@@ -1,28 +0,0 @@
1
- require "securerandom"
2
-
3
- module Textus
4
- # Immutable per-invocation value. Carries who is acting (role), the
5
- # request correlation id, the wall clock, and the dry_run flag — the
6
- # bits Use Cases need that are not part of the Container.
7
- Call = Data.define(:role, :correlation_id, :now, :dry_run) do
8
- def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
9
- new(
10
- role: role.to_s,
11
- correlation_id: correlation_id || SecureRandom.uuid,
12
- now: now || Textus::Ports::Clock.new.now,
13
- dry_run: dry_run,
14
- )
15
- end
16
-
17
- def dry_run? = dry_run
18
-
19
- def with_role(new_role)
20
- self.class.new(
21
- role: new_role.to_s,
22
- correlation_id: correlation_id,
23
- now: now,
24
- dry_run: dry_run,
25
- )
26
- end
27
- end
28
- end
@@ -1,41 +0,0 @@
1
- module Textus
2
- module Command
3
- Get = Data.define(:key, :role)
4
- Put = Data.define(:key, :meta, :body, :content, :if_etag, :role)
5
- Propose = Data.define(:key, :meta, :body, :content, :role, :pending_key) do
6
- def initialize(key:, role:, meta: nil, body: nil, content: nil, pending_key: nil)
7
- super
8
- end
9
- end
10
- KeyDelete = Data.define(:key, :if_etag, :role)
11
- KeyMv = Data.define(:old_key, :new_key, :dry_run, :role)
12
- Accept = Data.define(:pending_key, :role)
13
- Reject = Data.define(:pending_key, :role)
14
- Enqueue = Data.define(:type, :args, :role)
15
- Ingest = Data.define(:kind, :slug, :url, :path, :zone, :label, :role) do
16
- def initialize(kind:, slug:, role:, url: nil, path: nil, zone: nil, label: nil)
17
- super
18
- end
19
- end
20
- List = Data.define(:prefix, :lane, :role)
21
- Where = Data.define(:key, :role)
22
- Uid = Data.define(:key, :role)
23
- Blame = Data.define(:key, :limit, :role)
24
- Audit = Data.define(:key, :lane, :verb, :since, :seq_since, :correlation_id, :limit, :role)
25
- Deps = Data.define(:key, :role)
26
- Rdeps = Data.define(:key, :role)
27
- Pulse = Data.define(:since, :role)
28
- RuleExplain = Data.define(:key, :detail, :role)
29
- RuleList = Data.define(:role)
30
- RuleLint = Data.define(:candidate_yaml, :role)
31
- Published = Data.define(:role)
32
- SchemaShow = Data.define(:key, :role)
33
- Doctor = Data.define(:checks, :role)
34
- Boot = Data.define(:role)
35
- Jobs = Data.define(:state, :action, :job_id, :role)
36
- DataMv = Data.define(:from, :to, :dry_run, :role)
37
- KeyMvPrefix = Data.define(:from_prefix, :to_prefix, :dry_run, :role)
38
- KeyDeletePrefix = Data.define(:prefix, :dry_run, :role)
39
- Drain = Data.define(:prefix, :lane, :role)
40
- end
41
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry-struct"
4
-
5
- module Textus
6
- # Single capability record handed to every use case. Replaces the
7
- # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
8
- # (see Store#initialize); Store delegates its readers to this record,
9
- # so this struct is the single source of truth for the field set.
10
- class Container < Dry::Struct
11
- attribute :manifest, Types::Any
12
- attribute :file_store, Types::Any
13
- attribute :schemas, Types::Any
14
- attribute :root, Types::String
15
- attribute :audit_log, Types::Any
16
- attribute :workflows, Types::Any
17
- attribute :gate, Types::Any
18
-
19
- def with(**attrs) = self.class.new(to_h.merge(attrs))
20
-
21
- def initialize(*)
22
- super
23
- freeze
24
- end
25
- end
26
- end
@@ -1,29 +0,0 @@
1
- module Textus
2
- module Contract
3
- # Registry of named, stateful wrappers a verb may declare via `around :name`.
4
- # A resource implements
5
- # `wrap(scope:, inputs:, session:) { |effective_inputs| ... }`:
6
- # it may adjust the inputs before the call and post-process the result after
7
- # — exactly what build's lock and pulse's cursor need, without a hand-authored
8
- # CLI class (ADR 0068). `session:` is the dispatching session (nil for the
9
- # sessionless CLI/Ruby surfaces, present for MCP), so a session-aware resource
10
- # like the cursor can defer to the session's own state instead of its file.
11
- module Around
12
- @registry = {}
13
-
14
- module_function
15
-
16
- def register(name, resource)
17
- @registry[name] = resource
18
- end
19
-
20
- def fetch(name)
21
- @registry.fetch(name) { raise "no around resource registered: #{name.inspect}" }
22
- end
23
-
24
- def with(name, scope:, inputs:, session: nil, &call)
25
- fetch(name).wrap(scope: scope, inputs: inputs, session: session, &call)
26
- end
27
- end
28
- end
29
- end
@@ -1,88 +0,0 @@
1
- module Textus
2
- module Contract
3
- # Raised when a required arg is absent from the bound input. Surface
4
- # adapters translate this to their native error (MCP ToolError, CLI
5
- # UsageError); a direct Ruby call lets it surface as-is.
6
- class MissingArgs < Textus::Error
7
- attr_reader :spec, :missing
8
-
9
- def initialize(spec, missing)
10
- @spec = spec
11
- @missing = missing
12
- super("missing_args", "#{spec.verb}: missing #{missing.map(&:wire).join(", ")}")
13
- end
14
- end
15
-
16
- # The single argument binder for every surface (spike: collapses the three
17
- # historical implementations — MCP::Catalog.map_args, CLI::Runner.call_args,
18
- # and RoleScope's default-injection loop — into one algorithm).
19
- #
20
- # Input is a uniform `inputs` hash keyed by arg NAME (the use-case kwarg
21
- # name, never the wire name): each surface normalizes its own raw transport
22
- # shape (MCP JSON keyed by wire-name, CLI argv+flags, Ruby args+kwargs) into
23
- # this hash. Binder owns the shared algorithm and nothing transport-specific:
24
- #
25
- # 1. validate every required arg is present in `inputs`;
26
- # 2. for absentees, fall back to session_default (when a session is given)
27
- # then to the literal default; otherwise omit the arg entirely;
28
- # 3. split into the (positional, keyword) pair to splat into the use-case,
29
- # routing by `arg.positional`.
30
- #
31
- # Returns `[positional_array, keyword_hash]` — exactly what
32
- # `RoleScope#<verb>(*pos, **kw)` expects.
33
- module Binder
34
- module_function
35
-
36
- # Validation is unconditional: a `required:` arg absent from `inputs` is a
37
- # contract violation on every surface (ADR 0069). `required:` is now an
38
- # honest contract invariant, not a surface policy — args the use-case
39
- # treats as optional (e.g. `meta`, whose real requiredness lives in schema
40
- # validation downstream) are declared `required: false`, so this check
41
- # never fires spuriously and never needs an opt-out.
42
- def bind(spec, inputs, session: nil)
43
- missing = spec.required_args.reject { |a| inputs.key?(a.name) }
44
- raise MissingArgs.new(spec, missing) unless missing.empty?
45
-
46
- pos = []
47
- kw = {}
48
- spec.args.each do |a|
49
- if inputs.key?(a.name)
50
- value = inputs[a.name]
51
- elsif a.session_default && session
52
- value = session.public_send(a.session_default)
53
- elsif !a.default.nil?
54
- value = a.default
55
- else
56
- next
57
- end
58
-
59
- if a.positional
60
- pos << value
61
- else
62
- kw[a.name] = value
63
- end
64
- end
65
- [pos, kw]
66
- end
67
-
68
- # Normalize an ordered positional list + a by-name keyword hash (the shape
69
- # CLI argv+flags and Ruby args+kwargs both arrive in) into the uniform
70
- # by-name `inputs` hash bind expects. Positionals beyond what was supplied
71
- # are dropped so bind's required-check sees them as absent.
72
- def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
73
- names = spec.args.select(&:positional).map(&:name)
74
- names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
75
- end
76
-
77
- # Normalize a raw transport hash keyed by WIRE name (the shape MCP JSON
78
- # arrives in) into the uniform by-name `inputs` hash bind expects. Keys
79
- # not declared on the contract are ignored.
80
- def inputs_from_wire(spec, raw)
81
- raw ||= {}
82
- spec.args.each_with_object({}) do |a, h|
83
- h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
84
- end
85
- end
86
- end
87
- end
88
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Contract
3
- module Resources
4
- # Serializes builds across every surface (CLI, MCP, Ruby). Previously the
5
- # CLI verb wrapped each build in a BuildLock by hand; lifting it into the
6
- # contract means the MCP surface inherits the single-writer guarantee and
7
- # cannot collide with a concurrent CLI or background build.
8
- class BuildLock
9
- def wrap(scope:, inputs:, session: nil) # rubocop:disable Lint/UnusedMethodArgument
10
- Textus::Ports::BuildLock.with(root: scope.container.root) { yield(inputs) }
11
- end
12
- end
13
- end
14
- end
15
- end
16
-
17
- Textus::Contract::Around.register(:build_lock, Textus::Contract::Resources::BuildLock.new)
@@ -1,26 +0,0 @@
1
- module Textus
2
- module Contract
3
- module Resources
4
- # Reads the persisted file cursor as the `since` default when the caller
5
- # did not supply one, runs pulse, then persists the returned cursor.
6
- # Replaces CLI::Verb::Pulse's hand-coded CursorStore read/write (ADR 0068).
7
- #
8
- # A session-bearing surface (MCP) carries its own cursor via the contract's
9
- # `session_default: :cursor`, so this defers entirely when a session is
10
- # present — it is the sessionless CLI/Ruby surfaces that need the file.
11
- class Cursor
12
- def wrap(scope:, inputs:, session:)
13
- return yield(inputs) if session
14
-
15
- store = Textus::CursorStore.new(root: scope.container.root, role: scope.role)
16
- effective = inputs.key?(:since) ? inputs : inputs.merge(since: store.read)
17
- result = yield(effective)
18
- store.write(result["cursor"]) if result.is_a?(Hash) && result["cursor"]
19
- result
20
- end
21
- end
22
- end
23
- end
24
- end
25
-
26
- Textus::Contract::Around.register(:cursor, Textus::Contract::Resources::Cursor.new)
@@ -1,39 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module Contract
5
- # CLI-only input acquisition. Transforms entries of the uniform `inputs`
6
- # hash that declare a `source:`/`coerce:`, and builds `inputs` from a
7
- # `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
8
- # hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
9
- # never run there.
10
- module Sources
11
- module_function
12
-
13
- # Apply per-arg :file sources (value is a path -> file contents) and
14
- # :coerce callables to a by-name inputs hash. Returns a new hash.
15
- def acquire(spec, inputs)
16
- spec.args.each_with_object(inputs.dup) do |a, h|
17
- next unless h.key?(a.name)
18
-
19
- h[a.name] = File.read(h[a.name]) if a.source == :file
20
- h[a.name] = a.coerce.call(h[a.name]) if a.coerce
21
- end
22
- end
23
-
24
- # Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
25
- # envelope keys (wire-names) to arg names.
26
- def from_stdin(spec, stream)
27
- return {} unless spec.cli_stdin == :json
28
-
29
- raw = stream.read.to_s
30
- return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
31
-
32
- envelope = JSON.parse(raw)
33
- spec.args.each_with_object({}) do |a, h|
34
- h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- module Contract
3
- # Renders a use-case result for a surface, using the verb's declared view
4
- # (falling back to the default). The single replacement for the old
5
- # response/cli_response split and the Proc#arity sniff: views are always
6
- # called as (result, inputs); a one-parameter view ignores inputs.
7
- module View
8
- module_function
9
-
10
- def render(spec, surface, result, inputs)
11
- spec.view(surface).call(result, inputs)
12
- end
13
- end
14
- end
15
- end
@@ -1,174 +0,0 @@
1
- module Textus
2
- # Declarative, co-located interface contract for a verb. One source of truth
3
- # for the agent-facing summary, the argument schema, which transports expose
4
- # the verb, and how the return value is shaped for the wire. CLI/Ruby/MCP and
5
- # boot project from this; the MCP catalog is fully derived from it (ADR 0039).
6
- module Contract
7
- # One argument of a verb. `positional: true` means it is passed to the
8
- # use-case as a positional (e.g. `get(key)`); otherwise as a keyword.
9
- # `session_default` names a zero-arg method on `Textus::Session` (Symbol)
10
- # that supplies the value when the wire arg is absent; `nil` means no default.
11
- # `wire_name` is the name the arg carries on the wire (MCP JSON property / CLI
12
- # envelope key) when it must differ from the use-case kwarg `name` — e.g. `put`
13
- # takes the `meta:` kwarg but exposes `_meta` on the wire to match what `get`
14
- # returns and what the CLI `--stdin` envelope already speaks (ADR 0057).
15
- # `source: :file` (CLI only) reads the arg's value as a path -> file
16
- # contents; `coerce:` is a callable applied to the raw value (CLI only);
17
- # `cli_default:` supplies a CLI-specific default that diverges from the
18
- # contract `default` the agent surfaces use (`:__unset` sentinel = none).
19
- Arg = Data.define(
20
- :name, :type, :required, :positional, :session_default,
21
- :description, :wire_name, :default, :source, :coerce, :cli_default
22
- ) do
23
- # The name used on the wire (defaults to the kwarg name).
24
- def wire = wire_name || name
25
- end
26
-
27
- JSON_TYPES = {
28
- String => "string", Integer => "integer", Hash => "object",
29
- Array => "array", :boolean => "boolean"
30
- }.freeze
31
-
32
- def self.json_type(type)
33
- JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
34
- end
35
-
36
- Spec = Data.define(:verb, :summary, :args, :surfaces, :views, :cli, :around, :cli_stdin) do
37
- def mcp? = surfaces.include?(:mcp)
38
- def cli? = surfaces.include?(:cli)
39
-
40
- # The output shaper for a surface; falls back to the default view. Every
41
- # view is invoked uniformly as `view.call(result, inputs)` — a view that
42
- # declares one parameter ignores `inputs` (procs tolerate extra args).
43
- def view(surface = :default) = views[surface] || views.fetch(:default)
44
-
45
- # Operator-facing command path. Defaults to the verb token; grouped verbs
46
- # declare e.g. `cli "schema show"`.
47
- def cli_path = cli || verb.to_s
48
- def cli_words = cli_path.split
49
- def cli_group = cli_words.size > 1 ? cli_words.first : nil
50
- def cli_leaf = cli_words.last
51
-
52
- def required_args = args.select(&:required)
53
-
54
- # JSON-Schema object for MCP tools/list inputSchema.
55
- # Outer keys (:type, :properties, :required) are symbols; inner property
56
- # keys are strings — matches the MCP/JSON wire shape expected by clients.
57
- def input_schema
58
- props = args.to_h do |a|
59
- h = { "type" => Contract.json_type(a.type) }
60
- h["description"] = a.description if a.description
61
- [a.wire.to_s, h]
62
- end
63
- { type: "object", properties: props, required: required_args.map { |a| a.wire.to_s } }
64
- end
65
- end
66
-
67
- # Mixed onto a use-case class via `extend`. Calls accumulate into ivars,
68
- # frozen into a Spec on first read of `.contract`.
69
- module DSL
70
- def verb(name = nil)
71
- if name
72
- raise "contract already built; declare verb before reading .contract" if defined?(@__contract) && @__contract
73
-
74
- @__verb = name
75
- else
76
- @__verb
77
- end
78
- end
79
-
80
- def summary(text = nil)
81
- if text
82
- raise "contract already built; declare summary before reading .contract" if defined?(@__contract) && @__contract
83
-
84
- @__summary = text
85
- else
86
- @__summary
87
- end
88
- end
89
-
90
- def surfaces(*list)
91
- if list.empty?
92
- @__surfaces ||= []
93
- else
94
- raise "contract already built; declare surfaces before reading .contract" if defined?(@__contract) && @__contract
95
-
96
- @__surfaces = list
97
- end
98
- end
99
-
100
- def cli(path = nil)
101
- if path
102
- raise "contract already built; declare cli before reading .contract" if defined?(@__contract) && @__contract
103
-
104
- @__cli = path.to_s
105
- else
106
- @__cli
107
- end
108
- end
109
-
110
- # Declare a stateful wrapper resource (Contract::Around) to run around
111
- # dispatch — e.g. `around :cursor` (pulse) or `around :build_lock` (build).
112
- def around(name = nil)
113
- return @__around unless name
114
-
115
- raise "contract already built; declare around before reading .contract" if defined?(@__contract) && @__contract
116
-
117
- @__around = name
118
- end
119
-
120
- def arg(name, type, required: false, positional: false, session_default: nil, description: nil, wire_name: nil, default: nil, source: nil, coerce: nil, cli_default: :__unset) # rubocop:disable Metrics/ParameterLists,Layout/LineLength
121
- raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
122
-
123
- (@__args ||= []) << Arg.new(
124
- name: name, type: type, required: required,
125
- positional: positional, session_default: session_default,
126
- description: description, wire_name: wire_name, default: default,
127
- source: source, coerce: coerce, cli_default: cli_default
128
- )
129
- end
130
-
131
- # Verb-level: the CLI reads its inputs from a stdin envelope of this mode.
132
- # `:json` parses stdin as a JSON object and distributes its keys to args
133
- # by wire-name. nil means no stdin acquisition.
134
- def cli_stdin(mode = :__read)
135
- return @__cli_stdin if mode == :__read
136
-
137
- raise "contract already built; declare cli_stdin before reading .contract" if defined?(@__contract) && @__contract
138
-
139
- @__cli_stdin = mode
140
- end
141
-
142
- # Declare an output shaper. `view { ... }` is the default (MCP + Ruby);
143
- # `view(:cli) { ... }` overrides for the CLI. Both receive (result, inputs).
144
- def view(surface = :default, &blk)
145
- return (@__views ||= {})[surface] unless blk
146
-
147
- raise "contract already built; declare view before reading .contract" if defined?(@__contract) && @__contract
148
-
149
- (@__views ||= {})[surface] = blk
150
- end
151
-
152
- def contract?
153
- !@__verb.nil?
154
- end
155
-
156
- # rubocop:disable Naming/MemoizedInstanceVariableName
157
- # @__contract uses double-underscore to match the other accumulator ivars
158
- # (@__verb, @__args, etc.) and avoid name collision with user-defined `@contract`.
159
- def contract
160
- @__contract ||= Spec.new(
161
- verb: @__verb,
162
- summary: @__summary,
163
- args: (@__args || []).freeze,
164
- surfaces: (@__surfaces || []).freeze,
165
- views: ((@__views ||= {})[:default] ||= ->(v, _i) { v }) && @__views,
166
- cli: @__cli,
167
- around: @__around,
168
- cli_stdin: @__cli_stdin,
169
- )
170
- end
171
- # rubocop:enable Naming/MemoizedInstanceVariableName
172
- end
173
- end
174
- end