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
@@ -0,0 +1,55 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Handlers
5
+ module Write
6
+ class DataMv
7
+ def initialize(container:)
8
+ @container = container
9
+ end
10
+
11
+ def call(command, _call)
12
+ manifest = @container.manifest
13
+ geom = @container.layout
14
+
15
+ return Value::Result.failure(:usage_error, "from and to required") if command.from.nil? || command.to.nil?
16
+ unless manifest.data.declared_lane_kinds.key?(command.from)
17
+ return Value::Result.failure(:usage_error,
18
+ "data lane '#{command.from}' not declared")
19
+ end
20
+
21
+ dest_dir = geom.lane_path(command.to)
22
+ return Value::Result.failure(:usage_error, "destination 'data/#{command.to}' already exists") if File.exist?(dest_dir)
23
+
24
+ affected_keys = manifest.data.entries.select { |entry| entry.lane == command.from }.map(&:key)
25
+
26
+ steps = [{ "op" => "rename_zone", "from" => command.from, "to" => command.to }]
27
+ steps += affected_keys.map do |key|
28
+ { "op" => "mv", "from" => key, "to" => "#{command.to}#{key[command.from.length..]}" }
29
+ end
30
+
31
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: [])
32
+ return Value::Result.success(plan) if command.dry_run
33
+
34
+ rewrite_manifest!(geom, from: command.from, to: command.to)
35
+ FileUtils.mv(geom.lane_path(command.from), dest_dir)
36
+ Value::Result.success(plan)
37
+ end
38
+
39
+ private
40
+
41
+ def rewrite_manifest!(geom, from:, to:)
42
+ path = geom.manifest_path
43
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
44
+ raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
45
+ raw["entries"].each do |entry|
46
+ entry["lane"] = to if entry["lane"] == from
47
+ entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
48
+ entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
49
+ end
50
+ File.write(path, YAML.dump(raw))
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class DeleteKey
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ writer = Store::Entry::Writer.from(container: @container, call: call)
11
+ writer.delete(command.key, if_etag: command.if_etag)
12
+ Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true, "key" => command.key, "deleted" => true)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class EnqueueJob
5
+ def initialize(job_store:)
6
+ @job_store = job_store
7
+ end
8
+
9
+ def call(command, call)
10
+ action_class = Textus::Jobs.fetch(command.type.to_s)
11
+
12
+ if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
13
+ return Value::Result.failure(:forbidden,
14
+ "role '#{call.role}' is not authorized to enqueue this job type",
15
+ details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE })
16
+ end
17
+
18
+ job = Textus::Store::Jobs::Queue::Job.new(type: command.type, args: command.args, role: call.role, max_attempts: 3)
19
+ Textus::Store::Jobs::Queue.new(store: @job_store).enqueue(job)
20
+ Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id)
21
+ rescue Textus::UsageError
22
+ Value::Result.failure(:usage_error, "unregistered job type '#{command.type}'")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class KeyDeletePrefix
5
+ def initialize(orchestration:)
6
+ @orchestration = orchestration
7
+ end
8
+
9
+ def call(command, call)
10
+ return Value::Result.failure(:usage_error, "prefix required") if command.prefix.nil? || command.prefix.empty?
11
+
12
+ list = @orchestration.list_keys(prefix: command.prefix, lane: nil, call: call)
13
+ return list if list.failure?
14
+
15
+ leaves = list.value.fetch("rows")
16
+
17
+ warnings = leaves.empty? ? ["no keys under #{command.prefix}"] : []
18
+ steps = leaves.map { |row| { "op" => "delete", "key" => row["key"] } }
19
+
20
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
21
+ return Value::Result.success(plan) if command.dry_run
22
+
23
+ steps.each do |step|
24
+ delete = @orchestration.delete_key(key: step["key"], call: call)
25
+ return delete if delete.failure?
26
+ end
27
+ Value::Result.success(plan)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class KeyMvPrefix
5
+ def initialize(orchestration:)
6
+ @orchestration = orchestration
7
+ end
8
+
9
+ def call(command, call)
10
+ if command.from_prefix.nil? || command.to_prefix.nil?
11
+ return Value::Result.failure(:usage_error,
12
+ "from_prefix and to_prefix required")
13
+ end
14
+
15
+ list = @orchestration.list_keys(prefix: command.from_prefix, lane: nil, call: call)
16
+ return list if list.failure?
17
+
18
+ leaves = list.value.fetch("rows")
19
+
20
+ if leaves.any? { |r| r["key"] == command.from_prefix }
21
+ return Value::Result.failure(:usage_error,
22
+ "from_prefix '#{command.from_prefix}' is itself a leaf — use `mv` to rename a single key")
23
+ end
24
+
25
+ warnings = leaves.empty? ? ["no keys under #{command.from_prefix}"] : []
26
+ steps = leaves.map do |row|
27
+ old_key = row["key"]
28
+ tail = old_key.delete_prefix("#{command.from_prefix}.")
29
+ new_key = "#{command.to_prefix}.#{tail}"
30
+ { "op" => "mv", "from" => old_key, "to" => new_key }
31
+ end
32
+
33
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
34
+ return Value::Result.success(plan) if command.dry_run
35
+
36
+ steps.each do |step|
37
+ move = @orchestration.move_key(old_key: step["from"], new_key: step["to"], call: call)
38
+ return move if move.failure?
39
+ end
40
+ Value::Result.success(plan)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,80 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class MoveKey
5
+ def initialize(container:, manifest:)
6
+ @container = container
7
+ @manifest = manifest
8
+ end
9
+
10
+ def call(command, call)
11
+ Textus::Manifest::Data.validate_key!(command.old_key)
12
+ Textus::Manifest::Data.validate_key!(command.new_key)
13
+
14
+ return Value::Result.failure(:usage_error, "mv: old and new keys are identical") if command.old_key == command.new_key
15
+
16
+ old_res = @manifest.resolver.resolve(command.old_key)
17
+ new_res = @manifest.resolver.resolve(command.new_key)
18
+
19
+ reader = Store::Entry::Reader.from(container: @container)
20
+
21
+ unless reader.exists?(command.old_key)
22
+ return Value::Result.failure(:not_found,
23
+ "source key '#{command.old_key}' not found")
24
+ end
25
+
26
+ zone_check = validate_zone(old_res.entry, new_res.entry)
27
+ return zone_check if zone_check
28
+
29
+ if reader.exists?(command.new_key)
30
+ return Value::Result.failure(:usage_error, "mv: target '#{command.new_key}' already exists at #{new_res.path}")
31
+ end
32
+
33
+ pre_env = reader.read(command.old_key)
34
+ writer = Store::Entry::Writer.from(container: @container, call: call)
35
+ unless pre_env.uid
36
+ writer.put(
37
+ command.old_key, mentry: old_res.entry,
38
+ payload: Textus::Value::Payload.new(meta: pre_env.meta, body: pre_env.body, content: pre_env.content)
39
+ )
40
+ end
41
+
42
+ if command.dry_run
43
+ return Value::Result.success({
44
+ "protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
45
+ "from_key" => command.old_key, "to_key" => command.new_key,
46
+ "from_path" => old_res.path, "to_path" => new_res.path,
47
+ "uid" => pre_env.uid
48
+ })
49
+ end
50
+
51
+ envelope = writer.move(
52
+ from_key: command.old_key, to_key: command.new_key,
53
+ new_mentry: new_res.entry
54
+ )
55
+
56
+ Value::Result.success({
57
+ "protocol" => Textus::PROTOCOL, "ok" => true,
58
+ "from_key" => command.old_key, "to_key" => command.new_key,
59
+ "from_path" => old_res.path, "to_path" => new_res.path,
60
+ "uid" => envelope.uid, "envelope" => envelope.to_h_for_wire
61
+ })
62
+ end
63
+
64
+ private
65
+
66
+ def validate_zone(old_mentry, new_mentry)
67
+ if old_mentry.lane != new_mentry.lane
68
+ return Value::Result.failure(:usage_error,
69
+ "mv: cross-zone refused (#{old_mentry.lane} -> #{new_mentry.lane}). Use put+delete.")
70
+ end
71
+ if old_mentry.format != new_mentry.format
72
+ return Value::Result.failure(:usage_error,
73
+ "mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
74
+ end
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class ProposeEntry
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ zone = @container.manifest.policy.propose_lane_for(call.role)
11
+ unless zone
12
+ return Value::Result.failure(:propose_forbidden,
13
+ "role '#{call.role}' has no writable propose_lane",
14
+ details: { "role" => call.role })
15
+ end
16
+
17
+ key = "#{zone}.#{command.key}"
18
+ mentry = @container.manifest.resolver.resolve(key).entry
19
+ writer = Store::Entry::Writer.from(container: @container, call: call)
20
+ envelope = writer.put(
21
+ key, mentry: mentry,
22
+ payload: Textus::Value::Payload.new(meta: command.meta || {}, body: command.body, content: command.content)
23
+ )
24
+ Value::Result.success(envelope)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class PutEntry
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ Textus::Manifest::Data.validate_key!(command.key)
11
+ mentry = @container.manifest.resolver.resolve(command.key).entry
12
+
13
+ writer = Store::Entry::Writer.from(container: @container, call: call)
14
+ envelope = writer.put(
15
+ command.key,
16
+ mentry: mentry,
17
+ payload: Textus::Value::Payload.new(
18
+ meta: command.meta,
19
+ body: command.body,
20
+ content: command.content,
21
+ ),
22
+ if_etag: command.if_etag,
23
+ )
24
+ Value::Result.success(envelope)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class RejectProposal
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ mentry = @container.manifest.resolver.resolve(command.pending_key).entry
11
+ unless mentry.in_proposal_lane?(@container.manifest.policy)
12
+ return Value::Result.failure(:proposal_error,
13
+ "reject: '#{command.pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
14
+ end
15
+
16
+ reader = Store::Entry::Reader.from(container: @container)
17
+ env = reader.read(command.pending_key)
18
+ proposal = env&.meta&.dig("proposal") or
19
+ return Value::Result.failure(:proposal_error, "entry has no proposal block: #{command.pending_key}")
20
+ target_key = proposal["target_key"]
21
+
22
+ writer = Store::Entry::Writer.from(container: @container, call: call)
23
+ writer.delete(command.pending_key, mentry: mentry)
24
+ Value::Result.success("protocol" => Textus::PROTOCOL, "rejected" => command.pending_key, "target_key" => target_key)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/textus/init.rb CHANGED
@@ -3,7 +3,7 @@ require "pathname"
3
3
 
4
4
  module Textus
5
5
  module Init
6
- ZONES = %w[knowledge notebook proposals artifacts raw].freeze
6
+ ZONES = %w[knowledge scratchpad proposals artifacts raw].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
9
  version: textus/4
@@ -13,13 +13,13 @@ module Textus
13
13
  - { name: automation, can: [converge] }
14
14
  lanes:
15
15
  - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
16
- - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
16
+ - { name: scratchpad, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
17
17
  - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
18
18
  - { name: artifacts, kind: machine, desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
19
19
  entries:
20
20
  - { key: knowledge.identity, path: data/knowledge/identity.md, lane: knowledge, schema: null, owner: human:self, kind: leaf }
21
21
  - { key: knowledge.notes, path: data/knowledge/notes, lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
- - { key: notebook.notes, path: data/notebook/notes, lane: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
22
+ - { key: scratchpad.notes, path: data/scratchpad/notes, lane: scratchpad, schema: null, owner: agent:self, nested: true, kind: nested }
23
23
  - { key: proposals.notes, path: data/proposals/notes, lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
24
24
  # A per-host snapshot, populated by a registered workflow. Nested so it
25
25
  # grows to a fleet — add leaves over SSH without renaming. tracked:false →
@@ -92,9 +92,7 @@ module Textus
92
92
  end
93
93
 
94
94
  def self.setup_state_dirs(target_root)
95
- FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
96
- FileUtils.mkdir_p(Textus::Layout.cursors(target_root))
97
- FileUtils.mkdir_p(Textus::Layout.locks(target_root))
95
+ FileUtils.mkdir_p(Textus::Store::Layout.new(target_root).audit_dir_path)
98
96
  end
99
97
 
100
98
  def self.write_gitignore(target_root)
@@ -153,7 +151,7 @@ module Textus
153
151
  Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
154
152
  end
155
153
  end
156
- Textus::Layout.gitignore_body(untracked_paths: untracked)
154
+ Textus::Store::Layout.new(target_root).gitignore_body(untracked_entries: untracked)
157
155
  end
158
156
  end
159
157
  end
data/lib/textus/jobs.rb CHANGED
@@ -1,15 +1,9 @@
1
1
  module Textus
2
2
  module Jobs
3
- @registry = {}
4
-
5
- def self.registry = @registry
6
-
7
- def self.register(klass)
8
- @registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
9
- end
10
-
11
3
  def self.fetch(type)
12
- @registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
4
+ Store::Jobs::Registry.fetch(type)
5
+ rescue Store::Jobs::Registry::UnknownJob
6
+ raise Textus::UsageError.new("unknown job type: #{type}")
13
7
  end
14
8
  end
15
9
  end
@@ -7,13 +7,13 @@ module Textus
7
7
  # Fallback role set for a manifest that omits `roles:` entirely. Agent
8
8
  # is intentionally minimal here (`propose` only) — narrower than the
9
9
  # `textus init` scaffold, which declares `agent: [propose, keep]` so the
10
- # default `notebook` workspace is writable. A roles-less manifest that
10
+ # default `scratchpad` workspace is writable. A roles-less manifest that
11
11
  # declares a `kind: workspace` zone is therefore rejected at load (no
12
12
  # `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
13
13
  DEFAULT_MAPPING = {
14
- Textus::Role::HUMAN => %w[author propose].freeze,
15
- Textus::Role::AGENT => %w[propose].freeze,
16
- Textus::Role::AUTOMATION => %w[converge].freeze,
14
+ Textus::Value::Role::HUMAN => %w[author propose].freeze,
15
+ Textus::Value::Role::AGENT => %w[propose].freeze,
16
+ Textus::Value::Role::AUTOMATION => %w[converge].freeze,
17
17
  }.freeze
18
18
 
19
19
  # Returns { role_name => [verbs] }. When `roles:` is declared we use
@@ -4,7 +4,7 @@ module Textus
4
4
  class Base < Entry
5
5
  attr_reader :raw, :key, :path, :lane, :schema, :owner, :format, :publish_targets
6
6
 
7
- # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
7
+ # rubocop:disable Lint/MissingSuper
8
8
  def initialize(raw:, key:, path:, lane:, schema:, owner:, format:, publish_targets: [])
9
9
  @raw = raw
10
10
  @key = key
@@ -15,7 +15,7 @@ module Textus
15
15
  @format = format
16
16
  @publish_targets = Array(publish_targets)
17
17
  end
18
- # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
18
+ # rubocop:enable Lint/MissingSuper
19
19
 
20
20
  def lane_writers(policy)
21
21
  verb = policy.verb_for_lane(@lane)
@@ -75,7 +75,7 @@ module Textus
75
75
  # Read a named template from the store's templates/ directory.
76
76
  # Raises TemplateError when the file doesn't exist.
77
77
  def read_template(name)
78
- path = File.join(container.root.to_s, "templates", name)
78
+ path = container.layout.template_path(name)
79
79
  unless File.exist?(path)
80
80
  raise Textus::TemplateError.new(
81
81
  "template '#{name}' not found",
@@ -9,7 +9,7 @@ module Textus
9
9
  # shared shape — Tree always walks at `base` and honors `ignore` in the
10
10
  # prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
11
11
  class SubtreeMirror
12
- def initialize(entry, pctx, publisher: Textus::Ports::Publisher.new)
12
+ def initialize(entry, pctx, publisher: Textus::Port::Publisher.new)
13
13
  @entry = entry
14
14
  @pctx = pctx
15
15
  @publisher = publisher
@@ -52,7 +52,7 @@ module Textus
52
52
  # targets_under can't reach another leaf's sentinels.
53
53
  def prune(target_dir, written, honor_ignore)
54
54
  kept = written.map { |w| File.expand_path(w["target"]) }
55
- store = Textus::Ports::SentinelStore.new
55
+ store = Textus::Port::SentinelStore.new
56
56
  store.targets_under(target_dir, @pctx.root).filter_map do |managed|
57
57
  abs = File.expand_path(managed)
58
58
  next nil if kept.include?(abs)
@@ -9,12 +9,12 @@ module Textus
9
9
  # ADR 0094: iterates publish_targets (to-targets), rendering through a
10
10
  # template when the target declares one, or copying verbatim otherwise.
11
11
  class ToPaths < Mode
12
- def initialize(entry, publisher: Textus::Ports::Publisher.new)
12
+ def initialize(entry, publisher: Textus::Port::Publisher.new)
13
13
  super(entry)
14
14
  @publisher = publisher
15
15
  end
16
16
 
17
- def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
17
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
18
18
  targets = entry.publish_targets.select(&:to_target?)
19
19
 
20
20
  return nil if targets.empty?
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class AuthorHeld
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ holders = manifest.policy.roles_with_capability("author")
8
+ pass = holders.include?(actor.to_s)
9
+ reason = if pass
10
+ nil
11
+ elsif holders.empty?
12
+ "no role holds the 'author' capability; #{action} is disabled"
13
+ else
14
+ "role '#{actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
15
+ end
16
+ { pass:, reason: }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class EtagMatch
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ if_etag = extra[:if_etag]
8
+ return { pass: true } if if_etag.nil?
9
+
10
+ current = envelope&.etag
11
+ pass = current.nil? || current == if_etag
12
+ { pass:, error: pass ? nil : Textus::EtagMismatch.new(key, if_etag, current) }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class FreshWithin
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ { pass: true }
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class LaneDeletableBy
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ mentry = manifest.resolver.resolve(key).entry
10
+ is_raw = manifest.policy.declared_kind(mentry.lane.to_s) == :raw
11
+ lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
12
+ caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
13
+
14
+ pass = if is_raw
15
+ caps.include?("author")
16
+ else
17
+ caps.include?(lane_verb.to_s) || caps.include?("author")
18
+ end
19
+ return { pass: true } if pass
20
+
21
+ extra_holders = is_raw ? ["author"] : [lane_verb.to_s, "author"]
22
+ holders = extra_holders.flat_map { |v| manifest.policy.roles_with_capability(v) }.uniq
23
+ { pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
24
+ rescue Textus::UnknownKey
25
+ { pass: true }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class LaneWritableBy
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ mentry = manifest.resolver.resolve(key).entry
10
+ lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
11
+ caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
12
+ return { pass: true } if caps.include?(lane_verb.to_s)
13
+
14
+ holders = manifest.policy.roles_with_capability(lane_verb.to_s)
15
+ { pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
16
+ rescue Textus::UnknownKey
17
+ { pass: true }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class RawLaneIngestOnly
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ mentry = manifest.resolver.resolve(key).entry
10
+ return { pass: true } unless manifest.policy.declared_kind(mentry.lane.to_s) == :raw
11
+ return { pass: true } if action == :ingest
12
+
13
+ { pass: false, error: Textus::Error.new(
14
+ :raw_lane_ingest_only,
15
+ "raw lane '#{mentry.lane}' only accepts `textus ingest` — " \
16
+ "use that verb instead of '#{action}'",
17
+ ) }
18
+ rescue Textus::UnknownKey
19
+ { pass: true }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class RawWriteOnce
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ return { pass: true } if key.nil?
8
+
9
+ path = manifest.resolver.resolve(key).path
10
+ return { pass: true } unless File.exist?(path)
11
+
12
+ { pass: false, error: Textus::Error.new(
13
+ :raw_write_once,
14
+ "raw entry '#{key}' already exists; " \
15
+ "delete it first (`textus key-delete #{key}`), then re-ingest",
16
+ ) }
17
+ rescue Textus::UnknownKey
18
+ { pass: true }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end