textus 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -2768
  3. data/README.md +17 -18
  4. data/lib/textus/artifact.rb +8 -0
  5. data/lib/textus/errors.rb +2 -39
  6. data/lib/textus/human.rb +7 -0
  7. data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
  8. data/lib/textus/infra/clock.rb +7 -0
  9. data/lib/textus/infra/database.rb +127 -0
  10. data/lib/textus/infra/file_system.rb +54 -0
  11. data/lib/textus/infra/locks.rb +50 -0
  12. data/lib/textus/infra/port/clock.rb +18 -0
  13. data/lib/textus/infra/port/publisher.rb +71 -0
  14. data/lib/textus/infra/port/sentinel.rb +25 -0
  15. data/lib/textus/infra/port/sentinel_store.rb +93 -0
  16. data/lib/textus/infra/store/audit_log.rb +42 -0
  17. data/lib/textus/infra/store/base.rb +25 -0
  18. data/lib/textus/infra/store/cursor_store.rb +27 -0
  19. data/lib/textus/infra/store/entry_index.rb +31 -0
  20. data/lib/textus/infra/store/envelope/meta.rb +63 -0
  21. data/lib/textus/infra/store/file_store.rb +23 -0
  22. data/lib/textus/infra/store/index/builder.rb +28 -0
  23. data/lib/textus/infra/store/index/lookup.rb +23 -0
  24. data/lib/textus/infra/store/link_store.rb +43 -0
  25. data/lib/textus/infra/store/workflow_queue.rb +54 -0
  26. data/lib/textus/infra/store.rb +29 -0
  27. data/lib/textus/ingest.rb +8 -0
  28. data/lib/textus/lanes/artifact/cache.rb +35 -0
  29. data/lib/textus/lanes/artifact/handlers.rb +29 -0
  30. data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
  31. data/lib/textus/lanes/artifact/verbs.rb +18 -0
  32. data/lib/textus/lanes/ingest/dedup.rb +17 -0
  33. data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
  34. data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
  35. data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
  36. data/lib/textus/lanes/ingest/entry_types.rb +25 -0
  37. data/lib/textus/lanes/ingest/handlers.rb +54 -0
  38. data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
  39. data/lib/textus/lanes/ingest/key_builder.rb +33 -0
  40. data/lib/textus/lanes/ingest/resolver.rb +21 -0
  41. data/lib/textus/lanes/knowledge/freeform.rb +15 -0
  42. data/lib/textus/lanes/knowledge/handlers.rb +53 -0
  43. data/lib/textus/lanes/knowledge/runner.rb +45 -0
  44. data/lib/textus/lanes/knowledge/verbs.rb +16 -0
  45. data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
  46. data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
  47. data/lib/textus/{gate → protocol}/binder.rb +2 -10
  48. data/lib/textus/protocol/boot/catalog.rb +34 -0
  49. data/lib/textus/protocol/boot/context.rb +124 -0
  50. data/lib/textus/protocol/boot/protocol.rb +33 -0
  51. data/lib/textus/protocol/boot.rb +42 -0
  52. data/lib/textus/protocol/builder/domain.rb +17 -0
  53. data/lib/textus/protocol/builder/infrastructure.rb +17 -0
  54. data/lib/textus/protocol/builder/runtime.rb +46 -0
  55. data/lib/textus/protocol/builder.rb +11 -0
  56. data/lib/textus/protocol/container.rb +22 -0
  57. data/lib/textus/protocol/diff.rb +170 -0
  58. data/lib/textus/protocol/entry_constraint.rb +54 -0
  59. data/lib/textus/protocol/envelope.rb +70 -0
  60. data/lib/textus/protocol/format/base.rb +80 -0
  61. data/lib/textus/protocol/format/bash.rb +10 -0
  62. data/lib/textus/protocol/format/javascript.rb +10 -0
  63. data/lib/textus/protocol/format/json.rb +82 -0
  64. data/lib/textus/protocol/format/markdown.rb +53 -0
  65. data/lib/textus/protocol/format/python.rb +10 -0
  66. data/lib/textus/protocol/format/ruby.rb +10 -0
  67. data/lib/textus/protocol/format/script.rb +55 -0
  68. data/lib/textus/protocol/format/text.rb +48 -0
  69. data/lib/textus/protocol/format/yaml.rb +105 -0
  70. data/lib/textus/protocol/format.rb +73 -0
  71. data/lib/textus/protocol/freshness/ttl_evaluator.rb +61 -0
  72. data/lib/textus/protocol/gate.rb +61 -0
  73. data/lib/textus/protocol/handlers/command_context.rb +19 -0
  74. data/lib/textus/protocol/handlers/concern.rb +15 -0
  75. data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
  76. data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
  77. data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
  78. data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
  79. data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
  80. data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
  81. data/lib/textus/protocol/handlers/query_context.rb +41 -0
  82. data/lib/textus/protocol/handlers/read.rb +67 -0
  83. data/lib/textus/protocol/handlers/system.rb +93 -0
  84. data/lib/textus/protocol/handlers/write.rb +57 -0
  85. data/lib/textus/protocol/init.rb +143 -0
  86. data/lib/textus/protocol/key/grammar.rb +35 -0
  87. data/lib/textus/protocol/key/matching.rb +23 -0
  88. data/lib/textus/protocol/key/path.rb +23 -0
  89. data/lib/textus/protocol/key.rb +25 -0
  90. data/lib/textus/protocol/lane.rb +12 -0
  91. data/lib/textus/{store/geometry.rb → protocol/layout.rb} +32 -22
  92. data/lib/textus/protocol/links/resolver.rb +33 -0
  93. data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
  94. data/lib/textus/protocol/links.rb +10 -0
  95. data/lib/textus/protocol/manifest/data.rb +90 -0
  96. data/lib/textus/protocol/manifest/entry/base.rb +95 -0
  97. data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
  98. data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
  99. data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
  100. data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
  101. data/lib/textus/protocol/manifest/entry.rb +10 -0
  102. data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
  103. data/lib/textus/protocol/manifest/policy.rb +69 -0
  104. data/lib/textus/protocol/manifest/resolver.rb +156 -0
  105. data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
  106. data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
  107. data/lib/textus/protocol/manifest/schema.rb +40 -0
  108. data/lib/textus/protocol/manifest.rb +62 -0
  109. data/lib/textus/protocol/produce/render/context.rb +43 -0
  110. data/lib/textus/protocol/produce/render.rb +44 -0
  111. data/lib/textus/protocol/schema/registry.rb +44 -0
  112. data/lib/textus/protocol/schema/tools.rb +104 -0
  113. data/lib/textus/protocol/schema.rb +117 -0
  114. data/lib/textus/protocol/session/bridge.rb +11 -0
  115. data/lib/textus/protocol/session/context.rb +5 -0
  116. data/lib/textus/protocol/session.rb +87 -0
  117. data/lib/textus/protocol/store_engine/delete.rb +37 -0
  118. data/lib/textus/protocol/store_engine/move.rb +109 -0
  119. data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
  120. data/lib/textus/protocol/store_engine/put.rb +104 -0
  121. data/lib/textus/protocol/store_engine.rb +86 -0
  122. data/lib/textus/protocol/v4.rb +25 -0
  123. data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
  124. data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
  125. data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
  126. data/lib/textus/protocol/verb_registry.rb +42 -0
  127. data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
  128. data/lib/textus/surface/cli/runner.rb +25 -25
  129. data/lib/textus/surface/cli/sources.rb +1 -3
  130. data/lib/textus/surface/cli/verb/get.rb +3 -6
  131. data/lib/textus/surface/cli/verb/init.rb +1 -1
  132. data/lib/textus/surface/cli/verb/put.rb +8 -6
  133. data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
  134. data/lib/textus/surface/cli.rb +25 -6
  135. data/lib/textus/surface/dispatch.rb +17 -0
  136. data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
  137. data/lib/textus/surface/mcp/catalog.rb +26 -62
  138. data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
  139. data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
  140. data/lib/textus/surface/mcp/errors.rb +0 -10
  141. data/lib/textus/surface/mcp/server.rb +46 -46
  142. data/lib/textus/surface/watcher.rb +18 -12
  143. data/lib/textus/surface/web.rb +50 -0
  144. data/lib/textus/value/call.rb +1 -1
  145. data/lib/textus/{core → value}/duration.rb +1 -4
  146. data/lib/textus/value/etag.rb +7 -7
  147. data/lib/textus/value/payload.rb +7 -0
  148. data/lib/textus/version.rb +1 -1
  149. data/lib/textus/workflow/consumer.rb +39 -0
  150. data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
  151. data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
  152. data/lib/textus/workflow/dsl/step_builder.rb +56 -0
  153. data/lib/textus/workflow/dsl.rb +111 -21
  154. data/lib/textus/workflow/event_emitter.rb +21 -0
  155. data/lib/textus/workflow/publisher.rb +59 -0
  156. data/lib/textus/workflow/registry.rb +31 -8
  157. data/lib/textus/workflow/runner.rb +25 -61
  158. data/lib/textus/workflow/scheduler.rb +45 -0
  159. data/lib/textus/workflow/step_executor.rb +78 -0
  160. data/lib/textus/workflow/step_helpers.rb +102 -0
  161. data/lib/textus/workflow.rb +2 -5
  162. data/lib/textus.rb +16 -80
  163. metadata +167 -183
  164. data/SPEC.md +0 -908
  165. data/docs/architecture/README.md +0 -273
  166. data/docs/reference/conventions.md +0 -138
  167. data/lib/textus/action/accept.rb +0 -46
  168. data/lib/textus/action/audit.rb +0 -94
  169. data/lib/textus/action/base.rb +0 -42
  170. data/lib/textus/action/blame.rb +0 -79
  171. data/lib/textus/action/boot.rb +0 -15
  172. data/lib/textus/action/data_mv.rb +0 -58
  173. data/lib/textus/action/deps.rb +0 -19
  174. data/lib/textus/action/doctor.rb +0 -17
  175. data/lib/textus/action/drain.rb +0 -31
  176. data/lib/textus/action/enqueue.rb +0 -37
  177. data/lib/textus/action/get.rb +0 -34
  178. data/lib/textus/action/ingest.rb +0 -199
  179. data/lib/textus/action/jobs.rb +0 -27
  180. data/lib/textus/action/key_delete.rb +0 -26
  181. data/lib/textus/action/key_delete_prefix.rb +0 -35
  182. data/lib/textus/action/key_mv.rb +0 -122
  183. data/lib/textus/action/key_mv_prefix.rb +0 -48
  184. data/lib/textus/action/list.rb +0 -28
  185. data/lib/textus/action/propose.rb +0 -42
  186. data/lib/textus/action/published.rb +0 -22
  187. data/lib/textus/action/pulse.rb +0 -49
  188. data/lib/textus/action/put.rb +0 -38
  189. data/lib/textus/action/rdeps.rb +0 -24
  190. data/lib/textus/action/reject.rb +0 -28
  191. data/lib/textus/action/rule_explain.rb +0 -81
  192. data/lib/textus/action/rule_lint.rb +0 -62
  193. data/lib/textus/action/rule_list.rb +0 -38
  194. data/lib/textus/action/schema_envelope.rb +0 -22
  195. data/lib/textus/action/uid.rb +0 -19
  196. data/lib/textus/action/where.rb +0 -21
  197. data/lib/textus/boot.rb +0 -202
  198. data/lib/textus/contract/arg.rb +0 -10
  199. data/lib/textus/contract/dsl.rb +0 -88
  200. data/lib/textus/contract/spec.rb +0 -25
  201. data/lib/textus/contract.rb +0 -12
  202. data/lib/textus/core/freshness/evaluator.rb +0 -150
  203. data/lib/textus/core/freshness/verdict.rb +0 -34
  204. data/lib/textus/core/freshness.rb +0 -11
  205. data/lib/textus/core/retention/sweep.rb +0 -57
  206. data/lib/textus/core/retention.rb +0 -11
  207. data/lib/textus/core/sentinel.rb +0 -30
  208. data/lib/textus/doctor/check/audit_log.rb +0 -34
  209. data/lib/textus/doctor/check/generator_drift.rb +0 -29
  210. data/lib/textus/doctor/check/illegal_keys.rb +0 -60
  211. data/lib/textus/doctor/check/manifest_files.rb +0 -25
  212. data/lib/textus/doctor/check/notebook_sources.rb +0 -50
  213. data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
  214. data/lib/textus/doctor/check/proposal_targets.rb +0 -45
  215. data/lib/textus/doctor/check/protocol_version.rb +0 -47
  216. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
  217. data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
  218. data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
  219. data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
  220. data/lib/textus/doctor/check/schema_violations.rb +0 -28
  221. data/lib/textus/doctor/check/schemas.rb +0 -26
  222. data/lib/textus/doctor/check/sentinels.rb +0 -60
  223. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
  224. data/lib/textus/doctor/check/templates.rb +0 -29
  225. data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
  226. data/lib/textus/doctor/check.rb +0 -41
  227. data/lib/textus/doctor/validator.rb +0 -93
  228. data/lib/textus/doctor.rb +0 -54
  229. data/lib/textus/format/base.rb +0 -49
  230. data/lib/textus/format/json.rb +0 -102
  231. data/lib/textus/format/markdown.rb +0 -80
  232. data/lib/textus/format/shared.rb +0 -17
  233. data/lib/textus/format/text.rb +0 -55
  234. data/lib/textus/format/yaml.rb +0 -125
  235. data/lib/textus/format.rb +0 -46
  236. data/lib/textus/gate/auth.rb +0 -212
  237. data/lib/textus/gate.rb +0 -92
  238. data/lib/textus/init.rb +0 -157
  239. data/lib/textus/jobs.rb +0 -9
  240. data/lib/textus/key/distance.rb +0 -55
  241. data/lib/textus/key/grammar.rb +0 -33
  242. data/lib/textus/key/matching.rb +0 -24
  243. data/lib/textus/key/path.rb +0 -28
  244. data/lib/textus/manifest/capabilities.rb +0 -29
  245. data/lib/textus/manifest/data.rb +0 -102
  246. data/lib/textus/manifest/entry/base.rb +0 -105
  247. data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
  248. data/lib/textus/manifest/entry/leaf.rb +0 -17
  249. data/lib/textus/manifest/entry/nested.rb +0 -37
  250. data/lib/textus/manifest/entry/parser.rb +0 -99
  251. data/lib/textus/manifest/entry/produced.rb +0 -36
  252. data/lib/textus/manifest/entry/publish/mode.rb +0 -45
  253. data/lib/textus/manifest/entry/publish/none.rb +0 -14
  254. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
  255. data/lib/textus/manifest/entry/publish/template.rb +0 -16
  256. data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
  257. data/lib/textus/manifest/entry/publish/tree.rb +0 -50
  258. data/lib/textus/manifest/entry/publish.rb +0 -45
  259. data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
  260. data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
  261. data/lib/textus/manifest/entry/validators/publish.rb +0 -30
  262. data/lib/textus/manifest/entry/validators.rb +0 -18
  263. data/lib/textus/manifest/entry.rb +0 -8
  264. data/lib/textus/manifest/policy/matcher.rb +0 -51
  265. data/lib/textus/manifest/policy/publish_target.rb +0 -34
  266. data/lib/textus/manifest/policy/react.rb +0 -30
  267. data/lib/textus/manifest/policy/retention.rb +0 -29
  268. data/lib/textus/manifest/policy/source.rb +0 -30
  269. data/lib/textus/manifest/policy.rb +0 -95
  270. data/lib/textus/manifest/resolver.rb +0 -118
  271. data/lib/textus/manifest/rules.rb +0 -86
  272. data/lib/textus/manifest/schema/contract.rb +0 -61
  273. data/lib/textus/manifest/schema/keys.rb +0 -90
  274. data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
  275. data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
  276. data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
  277. data/lib/textus/manifest/schema/semantics.rb +0 -27
  278. data/lib/textus/manifest/schema/validator.rb +0 -48
  279. data/lib/textus/manifest/schema/vocabulary.rb +0 -25
  280. data/lib/textus/manifest/schema.rb +0 -30
  281. data/lib/textus/manifest.rb +0 -65
  282. data/lib/textus/meta.rb +0 -54
  283. data/lib/textus/port/audit_log.rb +0 -268
  284. data/lib/textus/port/build_lock.rb +0 -64
  285. data/lib/textus/port/clock.rb +0 -10
  286. data/lib/textus/port/publisher.rb +0 -60
  287. data/lib/textus/port/sentinel_store.rb +0 -83
  288. data/lib/textus/port/storage/file_stat.rb +0 -19
  289. data/lib/textus/port/storage/file_store.rb +0 -26
  290. data/lib/textus/port/store.rb +0 -93
  291. data/lib/textus/port/watcher_lock.rb +0 -48
  292. data/lib/textus/produce/engine.rb +0 -50
  293. data/lib/textus/produce/render.rb +0 -20
  294. data/lib/textus/schema/tools.rb +0 -115
  295. data/lib/textus/schema.rb +0 -100
  296. data/lib/textus/schemas.rb +0 -54
  297. data/lib/textus/store/compositor.rb +0 -34
  298. data/lib/textus/store/container.rb +0 -43
  299. data/lib/textus/store/cursor.rb +0 -26
  300. data/lib/textus/store/envelope/reader.rb +0 -43
  301. data/lib/textus/store/envelope/writer.rb +0 -195
  302. data/lib/textus/store/index/builder.rb +0 -74
  303. data/lib/textus/store/index/lookup.rb +0 -60
  304. data/lib/textus/store/jobs/base.rb +0 -13
  305. data/lib/textus/store/jobs/index.rb +0 -15
  306. data/lib/textus/store/jobs/materialize.rb +0 -15
  307. data/lib/textus/store/jobs/plan.rb +0 -11
  308. data/lib/textus/store/jobs/planner.rb +0 -104
  309. data/lib/textus/store/jobs/queue.rb +0 -154
  310. data/lib/textus/store/jobs/registry.rb +0 -19
  311. data/lib/textus/store/jobs/retention.rb +0 -50
  312. data/lib/textus/store/jobs/sweep.rb +0 -21
  313. data/lib/textus/store/jobs/worker.rb +0 -64
  314. data/lib/textus/store/session.rb +0 -37
  315. data/lib/textus/store.rb +0 -99
  316. data/lib/textus/surface/cli/verb/doctor.rb +0 -19
  317. data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
  318. data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
  319. data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
  320. data/lib/textus/surface/mcp.rb +0 -8
  321. data/lib/textus/surface/projector.rb +0 -27
  322. data/lib/textus/surface/role_scope.rb +0 -34
  323. data/lib/textus/value/command.rb +0 -16
  324. data/lib/textus/value/envelope.rb +0 -89
  325. data/lib/textus/value/result.rb +0 -26
  326. data/lib/textus/workflow/collector.rb +0 -27
  327. data/lib/textus/workflow/context.rb +0 -5
  328. data/lib/textus/workflow/loader.rb +0 -17
  329. data/lib/textus/workflow/pattern.rb +0 -18
@@ -0,0 +1,209 @@
1
+ require "tempfile"
2
+
3
+ module Textus
4
+ module Protocol
5
+ class Manifest
6
+ class Entry
7
+ module Publish
8
+ def self.resolve(entry)
9
+ set = []
10
+ set << "publish.to" unless Array(entry.publish_to).empty?
11
+ set << "publish.tree" unless entry.publish_tree.nil?
12
+
13
+ if set.length > 1
14
+ raise Textus::UsageError.new(
15
+ "entry '#{entry.key}': #{set.join(" and ")} are mutually exclusive — an entry publishes exactly one way",
16
+ )
17
+ end
18
+
19
+ mode_for(entry, set.first)
20
+ end
21
+
22
+ def self.mode_for(entry, key)
23
+ case key
24
+ when "publish.to" then ToPaths.new(entry)
25
+ when "publish.tree" then Tree.new(entry)
26
+ else None.new(entry)
27
+ end
28
+ end
29
+ private_class_method :mode_for
30
+
31
+ class Mode
32
+ def initialize(entry)
33
+ @entry = entry
34
+ end
35
+
36
+ attr_reader :entry
37
+
38
+ def validate!; end
39
+
40
+ def keyless? = false
41
+
42
+ private
43
+
44
+ def repo_abs(pctx, rel)
45
+ File.expand_path(File.join(pctx.repo_root, rel))
46
+ end
47
+
48
+ def inside_repo?(pctx, abs)
49
+ abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
50
+ end
51
+
52
+ def store_base(pctx)
53
+ File.join(pctx.root, Textus::Protocol::Key::Path.normalize_relative_path(entry.path))
54
+ end
55
+ end
56
+
57
+ class None < Mode
58
+ def publish(_pctx, prefix: nil) = nil
59
+ end
60
+
61
+ # publish.to: render or copy the entry's stored data to each fixed repo path.
62
+ class ToPaths < Mode
63
+ def initialize(entry, publisher: Textus::Infra::Port::Publisher.new)
64
+ super(entry)
65
+ @publisher = publisher
66
+ end
67
+
68
+ def publish(pctx, prefix: nil)
69
+ targets = entry.publish_targets.select(&:to_target?)
70
+ return nil if targets.empty?
71
+
72
+ data_path = pctx.manifest.resolver.resolve(entry.key).path
73
+ envelope = pctx.reader.call(key: entry.key)
74
+ content = nil
75
+
76
+ targets.each do |t|
77
+ if t.renders?
78
+ content ||= Textus::Protocol::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
79
+ publish_bytes(render_bytes(t, content, pctx), entry.key, t, pctx, data_path, envelope)
80
+ elsif strip_meta?(entry)
81
+ content ||= Textus::Protocol::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
82
+ bytes = Textus::Protocol::Format.for(entry.format).serialize(meta: {}, body: "", content: content)
83
+ publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
84
+ else
85
+ target_abs = File.join(pctx.repo_root, t.to)
86
+ @publisher.publish(source: data_path, target: target_abs, store_root: pctx.root)
87
+ pctx.emit(:entry_published, key: entry.key, envelope: envelope, source: data_path, target: target_abs)
88
+ end
89
+ end
90
+
91
+ { kind: :built, value: { "key" => entry.key, "path" => data_path, "published_to" => targets.map(&:to) } }
92
+ end
93
+
94
+ private
95
+
96
+ def strip_meta?(entry)
97
+ %w[json yaml].include?(entry.format.to_s)
98
+ end
99
+
100
+ def render_bytes(target, content, pctx)
101
+ boot = target.inject_boot ? Textus::Protocol::Boot.build(container: pctx.container) : nil
102
+ renderer = Textus::Protocol::Produce::Render.new(
103
+ template_loader: ->(n) { pctx.read_template(n) },
104
+ manifest: pctx.manifest,
105
+ source_publish_path: target.to,
106
+ entry_key: entry.key,
107
+ edge_store: pctx.container.store.link_store,
108
+ )
109
+ renderer.bytes_for(target: target, data: content, boot: boot)
110
+ end
111
+
112
+ def publish_bytes(bytes, key, target, pctx, data_path, envelope)
113
+ target_abs = File.join(pctx.repo_root, target.to)
114
+ Tempfile.create(["textus-publish", File.extname(target.to)]) do |f|
115
+ f.binmode
116
+ f.write(bytes)
117
+ f.flush
118
+ @publisher.publish(
119
+ source: f.path, target: target_abs, store_root: pctx.root, provenance_source: data_path,
120
+ )
121
+ end
122
+ pctx.emit(:entry_published, key: key, envelope: envelope, source: data_path, target: target_abs)
123
+ end
124
+ end
125
+
126
+ # publish.tree: mirror this entry's whole subtree to one target dir.
127
+ class Tree < Mode
128
+ def keyless? = true
129
+
130
+ def publish(pctx, prefix: nil)
131
+ target_rel = entry.publish_tree
132
+ target_dir = repo_abs(pctx, target_rel)
133
+ unless inside_repo?(pctx, target_dir)
134
+ raise Textus::PublishError.new(
135
+ "entry '#{entry.key}': publish_tree target '#{target_rel}' escapes repo root",
136
+ )
137
+ end
138
+
139
+ result = SubtreeMirror.new(entry, pctx).mirror(
140
+ walk_root: store_base(pctx),
141
+ target_dir: target_dir,
142
+ key: entry.key,
143
+ envelope: nil,
144
+ )
145
+ { kind: :leaves, value: result[:written], pruned: result[:pruned] }
146
+ end
147
+
148
+ def validate!
149
+ publish_tree = entry.publish_tree
150
+ raise UsageError.new("entry '#{entry.key}': publish.tree must be a string") unless publish_tree.is_a?(String)
151
+
152
+ used_vars = publish_tree.scan(/\{([a-z]+)\}/).flatten
153
+ return if used_vars.empty?
154
+
155
+ raise UsageError.new(
156
+ "entry '#{entry.key}': publish.tree names a single directory and takes no template variable(s) " \
157
+ "#{used_vars.map { |v| "{#{v}}" }.join(", ")} — it mirrors the whole subtree to one target dir.",
158
+ )
159
+ end
160
+ end
161
+
162
+ class SubtreeMirror
163
+ def initialize(entry, pctx, publisher: Textus::Infra::Port::Publisher.new)
164
+ @entry = entry
165
+ @pctx = pctx
166
+ @publisher = publisher
167
+ end
168
+
169
+ def mirror(walk_root:, target_dir:, key:, envelope:)
170
+ return { written: [], pruned: [] } unless File.directory?(walk_root)
171
+
172
+ written = publish_files(walk_root: walk_root, target_dir: target_dir, key: key, envelope: envelope)
173
+ { written: written, pruned: prune(target_dir, written) }
174
+ end
175
+
176
+ private
177
+
178
+ def publish_files(walk_root:, target_dir:, key:, envelope:)
179
+ Dir.glob(File.join(walk_root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
180
+ next nil unless File.file?(src)
181
+
182
+ dst = File.join(target_dir, relative(src, walk_root))
183
+ @publisher.publish(source: src, target: dst, store_root: @pctx.root)
184
+ @pctx.emit(:entry_published, key: key, envelope: envelope, source: src, target: dst)
185
+ { "key" => key, "source" => src, "target" => dst }
186
+ end
187
+ end
188
+
189
+ def prune(target_dir, written)
190
+ kept = written.map { |w| File.expand_path(w["target"]) }
191
+ store = Textus::Infra::Port::SentinelStore.new
192
+ store.targets_under(target_dir, @pctx.root).filter_map do |managed|
193
+ abs = File.expand_path(managed)
194
+ next nil if kept.include?(abs)
195
+
196
+ @publisher.unpublish(target: managed, store_root: @pctx.root)
197
+ managed
198
+ end
199
+ end
200
+
201
+ def relative(path, root)
202
+ path.sub(%r{\A#{Regexp.escape(root)}/}, "")
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ module Protocol
3
+ class Manifest
4
+ class Entry
5
+ # Populated by each Entry::* subclass at load time.
6
+ REGISTRY = {}
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,36 @@
1
+ module Textus
2
+ module Protocol
3
+ class Manifest
4
+ class Policy
5
+ # One publish destination (ADR 0094). Exactly one of:
6
+ # to-target { to:, template:?, inject_boot:? } — render data through a
7
+ # template, or copy verbatim when no template
8
+ # tree-target { tree: } — ADR 0052 subtree mirror
9
+ # Provenance is NOT a publish flag — it lives in the data's `_meta`.
10
+ class PublishTarget
11
+ attr_reader :to, :tree, :template, :inject_boot
12
+
13
+ def initialize(raw)
14
+ if raw.key?("provenance")
15
+ raise Textus::BadManifest.new("publish `provenance:` was removed (ADR 0094): provenance lives in the data's `_meta`")
16
+ end
17
+
18
+ @to = raw["to"]
19
+ @tree = raw["tree"]
20
+ raise Textus::BadManifest.new("a publish target needs exactly one of `to:` or `tree:`") unless @to.nil? ^ @tree.nil?
21
+
22
+ @template = raw["template"]
23
+ @inject_boot = raw["inject_boot"] == true
24
+ return unless tree_target? && (@template || @inject_boot)
25
+
26
+ raise Textus::BadManifest.new("a tree target takes no template/inject_boot (ADR 0094)")
27
+ end
28
+
29
+ def to_target? = !@to.nil?
30
+ def tree_target? = !@tree.nil?
31
+ def renders? = to_target? && !@template.nil?
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,69 @@
1
+ module Textus
2
+ module Protocol
3
+ class Manifest
4
+ class Policy
5
+ PROPOSALS_WRITERS = %w[human agent].freeze
6
+
7
+ def initialize(data)
8
+ @data = data
9
+ @entries = nil
10
+ end
11
+
12
+ attr_writer :entries
13
+
14
+ # ── Authorization ──
15
+
16
+ def allowed?(role:, lane:)
17
+ writers_for(lane).map(&:to_sym).include?(role.to_sym)
18
+ end
19
+
20
+ def allowed!(role:, lane:, key: nil)
21
+ return true if allowed?(role:, lane:)
22
+
23
+ raise Textus::WriteForbidden.new(key, lane, holders: writers_for(lane))
24
+ end
25
+
26
+ def writers_for(lane_name)
27
+ return PROPOSALS_WRITERS if lane_name == "proposals"
28
+
29
+ lane_map = Textus::Protocol::V4::LANES[lane_name]
30
+ lane_map ? Array(lane_map[:writers]) : []
31
+ end
32
+
33
+ def actor_for(lane_name)
34
+ writers_for(lane_name).first
35
+ end
36
+
37
+ def proposer_role
38
+ proposers = writers_for("proposals")
39
+ authors = writers_for("knowledge")
40
+ (proposers - authors).first || proposers.first
41
+ end
42
+
43
+ # ── Existing methods (unchanged) ──
44
+
45
+ def derived_entry?(key)
46
+ entry = Array(@entries).find { |e| e.key == key }
47
+ entry && entry.lane == "artifacts"
48
+ end
49
+
50
+ def queue_lane
51
+ "scratchpad.proposals"
52
+ end
53
+
54
+ def machine_lane
55
+ "artifacts"
56
+ end
57
+
58
+ def queue_lane?(lane_name)
59
+ lane_name == "scratchpad"
60
+ end
61
+
62
+ def propose_lane_for(role)
63
+ proposers = writers_for("proposals")
64
+ proposers.include?(role) ? "scratchpad.proposals" : nil
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,156 @@
1
+ module Textus
2
+ module Protocol
3
+ class Manifest
4
+ class Resolver
5
+ Resolution = ::Data.define(:entry, :path, :remaining)
6
+
7
+ MAX_DISTANCE_LEN = 200
8
+
9
+ def initialize(data)
10
+ @data = data
11
+ end
12
+
13
+ def resolve(key)
14
+ @data.validate_key!(key)
15
+ dr = Protocol::Key.resolve(key, @data.entries)
16
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless dr
17
+ raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless dr.entry
18
+
19
+ Resolution.new(entry: dr.entry, path: build_path(dr.entry, dr.remaining), remaining: dr.remaining)
20
+ end
21
+
22
+ def suggestions_for(key)
23
+ candidates = enumerate.map { |r| r[:key] }
24
+ candidates.concat(@data.entries.reject { |e| nested_entry?(e) }.map(&:key))
25
+ candidates.uniq!
26
+ suggest(key, candidates, limit: 5)
27
+ rescue StandardError
28
+ []
29
+ end
30
+
31
+ def enumerate(prefix: nil, include_keyless: false)
32
+ out = @data.entries.flat_map do |entry|
33
+ nested_entry?(entry) ? enumerate_nested(entry, include_keyless: include_keyless) : enumerate_leaf(entry)
34
+ end
35
+ out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
36
+ out.sort_by { |row| row[:key] }
37
+ end
38
+
39
+ private
40
+
41
+ def suggest(requested, candidates, limit: 5)
42
+ return [] if requested.nil? || requested.empty?
43
+
44
+ scored = candidates.first(200).map do |k|
45
+ prefix = self.class.shared_prefix_segments(requested, k)
46
+ dist = self.class.levenshtein(requested, k) || Float::INFINITY
47
+ [k, prefix, dist]
48
+ end
49
+ scored.sort_by { |(_, prefix, dist)| [-prefix, dist] }.first(limit).map(&:first)
50
+ end
51
+
52
+ def nested_entry?(entry)
53
+ entry.nested?
54
+ end
55
+
56
+ def build_path(entry, remaining)
57
+ if remaining.empty?
58
+ resolve_leaf_path(entry)
59
+ else
60
+ fmt = Textus::Protocol::Format.for(entry.format)
61
+ base = Textus::Protocol::Key::Path.normalize_relative_path(entry.path)
62
+ path = File.join(@data.root, base, *remaining)
63
+ path + fmt.nested_ext(remaining.last)
64
+ end
65
+ end
66
+
67
+ def enumerate_leaf(entry)
68
+ fp = resolve_leaf_path(entry)
69
+ File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
70
+ end
71
+
72
+ def enumerate_nested(entry, include_keyless: false)
73
+ # publish_tree mirrors opaque payload by path — its files are never
74
+ # enumerated as keys (ADR 0047). Ask the resolved mode, not the path.
75
+ # The `include_keyless:` override is used only by the projection lister
76
+ # so that `from: project` selects can read source data from keyless
77
+ # nested entries (e.g. knowledge.evidence) without exposing them as
78
+ # addressable store keys in the public `list` surface.
79
+ return [] if entry.publish_mode.keyless? && !include_keyless
80
+
81
+ base = File.join(@data.root, Textus::Protocol::Key::Path.normalize_relative_path(entry.path))
82
+ return [] unless File.directory?(base)
83
+
84
+ Dir.glob(File.join(base, nested_glob(entry.format)))
85
+ .filter_map { |path| nested_row_for(entry, base, path) }
86
+ end
87
+
88
+ def nested_row_for(entry, base, path)
89
+ rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
90
+
91
+ stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
92
+ segs = stripped.split("/").reject { |s| s.empty? || s == "." }
93
+ return nil if segs.empty?
94
+
95
+ illegal = segs.find { |s| !valid_segment?(s) }
96
+ if illegal
97
+ warn("textus: skipping illegal key segment '#{illegal}' at #{path} — " \
98
+ "rename to match [a-z0-9][a-z0-9-]* (run 'textus doctor' for the full list)")
99
+ return nil
100
+ end
101
+
102
+ { key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
103
+ end
104
+
105
+ def valid_segment?(seg)
106
+ return false if seg.nil? || seg.empty?
107
+ return false if seg.length > Protocol::Key::Grammar::MAX_SEGMENT_LEN
108
+
109
+ seg.match?(Protocol::Key::Grammar::SEGMENT)
110
+ end
111
+
112
+ def resolve_leaf_path(entry)
113
+ Textus::Protocol::Key::Path.resolve(@data, entry)
114
+ end
115
+
116
+ def nested_glob(format)
117
+ Textus::Protocol::Format.for(format).nested_glob
118
+ end
119
+ class << self
120
+ private
121
+
122
+ def shared_prefix_segments(left, right)
123
+ asegs = left.split(".")
124
+ bsegs = right.split(".")
125
+ n = [asegs.length, bsegs.length].min
126
+ i = 0
127
+ i += 1 while i < n && asegs[i] == bsegs[i]
128
+ i
129
+ end
130
+
131
+ def levenshtein(left, right)
132
+ return nil if left.length > MAX_DISTANCE_LEN || right.length > MAX_DISTANCE_LEN
133
+ return right.length if left.empty?
134
+ return left.length if right.empty?
135
+
136
+ prev = (0..right.length).to_a
137
+ curr = Array.new(right.length + 1, 0)
138
+ (1..left.length).each do |i|
139
+ curr[0] = i
140
+ (1..right.length).each do |j|
141
+ cost = left[i - 1] == right[j - 1] ? 0 : 1
142
+ curr[j] = [
143
+ curr[j - 1] + 1,
144
+ prev[j] + 1,
145
+ prev[j - 1] + cost,
146
+ ].min
147
+ end
148
+ prev, curr = curr, prev
149
+ end
150
+ prev[right.length]
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,53 @@
1
+ require "dry/schema"
2
+
3
+ module Textus
4
+ module Protocol
5
+ class Manifest
6
+ module Schema
7
+ # rubocop:disable Metrics/BlockLength
8
+ Contract = Dry::Schema.JSON do
9
+ optional(:lanes).value(:array).each do
10
+ hash do
11
+ required(:name).value(:string)
12
+ required(:kind).value(included_in?: LANE_KINDS)
13
+ optional(:owner).value(:string)
14
+ optional(:desc).value(:string)
15
+ end
16
+ end
17
+
18
+ optional(:roles).value(:array).each do
19
+ hash do
20
+ required(:name).value(:string)
21
+ optional(:can).value(:array).each(:string)
22
+ end
23
+ end
24
+
25
+ optional(:entries).value(:array).each do
26
+ hash do
27
+ required(:key).value(:string)
28
+ optional(:format).value(:string)
29
+ optional(:schema).maybe(:string)
30
+ optional(:tracked).value(:bool)
31
+ optional(:naming).value(:string)
32
+ end
33
+ end
34
+
35
+ optional(:data).value(:hash)
36
+
37
+ optional(:audit).hash do
38
+ optional(:max_size).value(:integer)
39
+ optional(:keep).value(:integer)
40
+ end
41
+ optional(:worker).hash do
42
+ optional(:pool).value(:integer)
43
+ optional(:poll).value(:integer)
44
+ optional(:lease_ttl).value(:integer)
45
+ optional(:max_attempts).value(:integer)
46
+ end
47
+ optional(:version).value(:string)
48
+ end
49
+ # rubocop:enable Metrics/BlockLength
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Protocol
5
+ class Manifest
6
+ module Schema
7
+ # Orchestrates structural validation (dry-schema Contract) then cross-field
8
+ # semantic checks (Semantics). Public interface unchanged: Validator.validate!(raw).
9
+ module Validator
10
+ module_function
11
+
12
+ def validate!(raw)
13
+ raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
14
+
15
+ # Root unknown-key check before Contract so it fires even when lanes: is empty.
16
+ Schema.walk(raw, ROOT_KEYS, "$")
17
+
18
+ result = Contract.call(raw)
19
+ raise BadManifest.new(format_first_error(result.errors.messages)) unless result.success?
20
+
21
+ if raw.key?("data")
22
+ raise BadManifest.new("manifest must declare data:") if raw["data"].nil?
23
+ elsif Array(raw["lanes"]).empty?
24
+ raise BadManifest.new("manifest must declare lanes:")
25
+ end
26
+ end
27
+
28
+ # Format the first dry-schema error to match the legacy path-prefixed style:
29
+ # "unknown key 'x' at '$.lanes[0]'" for extra-key errors;
30
+ # "manifest structure error at <path>: <msg>" for type/value errors.
31
+ def format_first_error(messages)
32
+ msg = messages.first
33
+ return "manifest structure error: unknown" unless msg
34
+
35
+ parent = format_path(msg.path[0..-2])
36
+ key = msg.path.last
37
+
38
+ if msg.text == "is not allowed"
39
+ "unknown key '#{key}' at '#{parent}'"
40
+ else
41
+ "manifest structure error at #{format_path(msg.path)}: #{msg.text}"
42
+ end
43
+ end
44
+
45
+ def format_path(parts)
46
+ "$" + Array(parts).map { |p| p.is_a?(Integer) ? "[#{p}]" : ".#{p}" }.join
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,40 @@
1
+ module Textus
2
+ module Protocol
3
+ class Manifest
4
+ module Schema
5
+ # The closed coordination vocabulary (ADR 0028; five in 0033; unified in
6
+ # 0034; the quarantine + derived ZONE-KINDS folded into one `machine` kind
7
+ # in ADR 0091). Each kind pairs with the capability that authorizes
8
+ # originating bytes in it.
9
+ LANES = {
10
+ "canon" => "author",
11
+ "workspace" => "keep",
12
+ "machine" => "converge",
13
+ "queue" => "propose",
14
+ "raw" => "ingest",
15
+ }.freeze
16
+
17
+ LANE_KINDS = LANES.keys.freeze
18
+ CAPABILITIES = LANES.values.uniq.freeze
19
+ KIND_REQUIRES_VERB = LANES
20
+
21
+ # Key whitelists and rule-field registry (ADR 0109).
22
+ ROOT_KEYS = %w[version roles owner lanes entries data audit worker].freeze
23
+ LANE_KEYS = %w[name kind owner desc].freeze
24
+ ENTRY_KEYS = %w[key schema format naming tracked].freeze
25
+ AUDIT_KEYS = %w[max_size keep].freeze
26
+
27
+ OWNER_SUBJECT_PATTERN = /\A[a-z][a-z0-9_-]*\z/
28
+
29
+ # Public entry point — structural validation then semantic checks.
30
+ def self.validate!(raw) = Validator.validate!(raw)
31
+
32
+ def self.walk(raw, allowed, path)
33
+ raw.each_key do |k|
34
+ raise BadManifest.new("unknown key '#{k}' at '#{path}'") unless allowed.include?(k)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Protocol
5
+ Manifest = Data.define(:data, :resolver, :policy)
6
+ end
7
+ end
8
+
9
+ require_relative "manifest/schema"
10
+ require_relative "manifest/data"
11
+ require_relative "manifest/policy"
12
+ require_relative "manifest/resolver"
13
+
14
+ module Textus
15
+ module Protocol
16
+ class Manifest
17
+ class << self
18
+ def parse(yaml_text, root: ".")
19
+ raw = YAML.safe_load(yaml_text, aliases: true)
20
+ check_version!(raw, "<string>")
21
+ build(raw, root)
22
+ end
23
+
24
+ def load(root)
25
+ manifest_path = File.join(root, "manifest.yaml")
26
+ raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
27
+
28
+ raw = YAML.safe_load_file(manifest_path, aliases: true)
29
+ check_version!(raw, manifest_path)
30
+ build(raw, root)
31
+ end
32
+
33
+ private
34
+
35
+ def build(raw, root)
36
+ # Phase 1: structural data + authority policy (no entry validation)
37
+ data = Manifest::Data.parse(raw, root: root)
38
+ policy = Manifest::Policy.new(data)
39
+
40
+ policy.entries = data.entries
41
+
42
+ new(
43
+ data:,
44
+ resolver: Manifest::Resolver.new(data),
45
+ policy:,
46
+ )
47
+ end
48
+
49
+ def check_version!(raw, source)
50
+ return if raw["version"] == PROTOCOL
51
+
52
+ raise BadFrontmatter.new(
53
+ source,
54
+ "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ Textus::Manifest = Textus::Protocol::Manifest