textus 0.52.0 → 0.53.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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. data/lib/textus/write/reject.rb +0 -68
@@ -14,8 +14,9 @@ module Textus
14
14
  .downcase
15
15
  end
16
16
 
17
- def initialize(container)
17
+ def initialize(container, role: Textus::Role::DEFAULT)
18
18
  @container = container
19
+ @role = role
19
20
  end
20
21
 
21
22
  def call
@@ -26,16 +27,18 @@ module Textus
26
27
 
27
28
  def root = @container.root
28
29
  def manifest = @container.manifest
29
- def rpc = @container.rpc
30
+ def steps = @container.steps
30
31
 
31
- # Dispatch a verb through the single use-case invocation seam (ADR 0026).
32
+ # Dispatch a verb through Gate.
32
33
  def dispatch(verb, *args, **kwargs)
33
- Textus::Dispatcher.invoke(
34
- verb,
35
- container: @container,
36
- call: Textus::Call.build(role: Textus::Role::DEFAULT),
37
- args: args, kwargs: kwargs
38
- )
34
+ klass = Textus::Action::VERBS[verb]
35
+ spec = klass.contract if klass.respond_to?(:contract?) && klass.contract?
36
+ inputs = spec ? Textus::Contract::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
37
+ cmd_class = Textus::Gate::VERB_COMMAND.fetch(verb)
38
+ merged = inputs.merge(role: @role)
39
+ filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
40
+ cmd = cmd_class.new(**filled)
41
+ @container.gate.dispatch(cmd)
39
42
  end
40
43
  end
41
44
  end
@@ -1,27 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Textus
2
- module Read
4
+ module Doctor
3
5
  class Validator
4
6
  def initialize(reader:, manifest:, audit_log:, schema_for:)
5
- @reader = reader
6
- @manifest = manifest
7
+ @reader = reader
8
+ @manifest = manifest
7
9
  @audit_log = audit_log
8
10
  @schema_for = schema_for
9
11
  end
10
12
 
11
- def call
13
+ def call(container:, call:)
14
+ @container = container
15
+ @call = call
12
16
  violations = []
13
17
  check_content_violations(violations)
14
18
  check_role_authority_violations(violations)
15
- { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
19
+ { "protocol" => Textus::PROTOCOL, "ok" => violations.empty?, "violations" => violations }
16
20
  end
17
21
 
18
22
  private
19
23
 
20
24
  def check_content_violations(violations)
21
25
  @manifest.resolver.enumerate.each do |row|
22
- key = row[:key]
26
+ key = row[:key]
23
27
  mentry = row[:manifest_entry]
24
- env = fetch_envelope(key, violations) or next
28
+ env = fetch_envelope(key, violations) or next
25
29
  schema = mentry.schema && @schema_for.call(mentry.schema)
26
30
  next unless schema
27
31
 
@@ -42,7 +46,7 @@ module Textus
42
46
  next unless schema
43
47
 
44
48
  env = begin
45
- @reader.get(row[:key])
49
+ @reader.call(row[:key], @container, @call)
46
50
  rescue StandardError
47
51
  next
48
52
  end
@@ -54,19 +58,24 @@ module Textus
54
58
  last_writer = @audit_log.last_writer_for(key)
55
59
  return if last_writer.nil?
56
60
 
57
- last_writer_is_authority = @manifest.policy.roles_with_capability("author").include?(last_writer)
58
-
61
+ last_writer_is_authority =
62
+ @manifest.policy.roles_with_capability("author").include?(last_writer)
59
63
  env.meta.each_key do |field|
60
64
  owner = schema.maintained_by(field)
61
65
  next if owner.nil? || last_writer == owner || last_writer_is_authority
62
66
 
63
- violations << { "key" => key, "code" => "role_authority",
64
- "field" => field, "expected" => owner, "last_writer" => last_writer }
67
+ violations << {
68
+ "key" => key,
69
+ "code" => "role_authority",
70
+ "field" => field,
71
+ "expected" => owner,
72
+ "last_writer" => last_writer,
73
+ }
65
74
  end
66
75
  end
67
76
 
68
77
  def fetch_envelope(key, violations)
69
- @reader.get(key)
78
+ @reader.call(key, @container, @call)
70
79
  rescue Textus::Error => e
71
80
  violations << { "key" => key, "code" => e.code, "message" => e.message }
72
81
  nil
data/lib/textus/doctor.rb CHANGED
@@ -22,7 +22,7 @@ module Textus
22
22
  Check::UnownedSchemaFields,
23
23
  Check::SchemaViolations,
24
24
  Check::RuleAmbiguity,
25
- Check::HandlerAllowlist,
25
+ Check::HandlerPermit,
26
26
  Check::OrphanedPublishTargets,
27
27
  Check::PublishTreeIndexOverlap,
28
28
  Check::ProposalTargets,
@@ -33,7 +33,7 @@ module Textus
33
33
 
34
34
  module_function
35
35
 
36
- def build(container:, checks: nil)
36
+ def build(container:, checks: nil, role: Textus::Role::DEFAULT)
37
37
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
38
38
  unknown = selected_keys - ALL_CHECKS
39
39
  unless unknown.empty?
@@ -43,7 +43,7 @@ module Textus
43
43
  end
44
44
 
45
45
  selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
46
- issues = selected.flat_map { |c| c.new(container).call }
46
+ issues = selected.flat_map { |c| c.new(container, role:).call }
47
47
  issues.concat(run_registered_checks(container))
48
48
 
49
49
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
@@ -56,12 +56,12 @@ module Textus
56
56
  end
57
57
 
58
58
  def run_registered_checks(container)
59
- container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
59
+ container.steps.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
60
60
  end
61
61
 
62
62
  def invoke_registered_check(container, name)
63
63
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
64
- container.rpc.invoke(:validate, name, caps: container)
64
+ container.steps.invoke(:validate, name, caps: container)
65
65
  end
66
66
  return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
67
67
 
@@ -75,7 +75,7 @@ module Textus
75
75
  rescue StandardError => e
76
76
  [fail_issue(name, code: "doctor_check.failed",
77
77
  message: "#{e.class}: #{e.message}",
78
- fix: "fix the :validate hook in .textus/hooks/")]
78
+ fix: "fix the :validate step in .textus/steps/validate/")]
79
79
  end
80
80
 
81
81
  def fail_issue(name, code:, message:, fix:)
@@ -32,42 +32,15 @@ module Textus
32
32
  end
33
33
 
34
34
  def put(key, mentry:, payload:, if_etag: nil)
35
- path = @manifest.resolver.resolve(key).path
36
-
37
- meta = payload.meta || {}
38
-
39
- existing_uid = @reader.existing_uid(key)
40
- meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
41
-
42
- bytes, eff_meta, eff_body, eff_content = serialize_for_put(
43
- mentry: mentry, path: path,
44
- meta: meta, body: payload.body, content: content
45
- )
46
-
35
+ path = resolve_path(key)
36
+ meta, content = prepare_uid(mentry, payload, key)
37
+ bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
47
38
  enforce_name_match!(path, eff_meta, mentry.format)
48
-
49
- schema = @schemas.fetch_or_nil(mentry.schema)
50
- if schema
51
- Entry.for_format(mentry.format).validate_against(
52
- schema,
53
- { "_meta" => eff_meta, "content" => eff_content },
54
- )
55
- end
56
-
57
- etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
58
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
59
-
60
- @file_store.write(path, bytes)
61
- etag_after = Etag.for_bytes(bytes)
62
- envelope = Textus::Envelope.build(
63
- key: key, mentry: mentry, path: path,
64
- meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
65
- )
66
- @audit_log.append(
67
- role: @call.role, verb: "put", key: key,
68
- etag_before: etag_before, etag_after: etag_after,
69
- extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
70
- )
39
+ validate_schema(mentry, eff_meta, eff_content)
40
+ etag_before = check_etag!(path, key, if_etag)
41
+ write_bytes(path, bytes)
42
+ envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
43
+ audit_put(key, etag_before, envelope.etag)
71
44
  envelope
72
45
  end
73
46
 
@@ -155,7 +128,7 @@ module Textus
155
128
  # The zone directory under which `path` lives (`<root>/zones/<zone>`),
156
129
  # or nil if `path` is not under the store's zones tree.
157
130
  def zone_floor(path)
158
- zones_root = File.join(@manifest.data.root, "zones")
131
+ zones_root = File.join(@manifest.data.root, "data")
159
132
  prefix = "#{zones_root}/"
160
133
  return nil unless path.start_with?(prefix)
161
134
 
@@ -176,6 +149,62 @@ module Textus
176
149
  meta: meta, body: body, content: content, path: path,
177
150
  )
178
151
  end
152
+
153
+ def resolve_path(key)
154
+ @manifest.resolver.resolve(key).path
155
+ end
156
+
157
+ def prepare_uid(mentry, payload, key)
158
+ meta = payload.meta || {}
159
+ existing_uid = @reader.existing_uid(key)
160
+ ensure_uid(mentry.format, meta, payload.content, existing_uid)
161
+ end
162
+
163
+ def serialize_entry(mentry, path, meta, payload, content)
164
+ serialize_for_put(
165
+ mentry: mentry, path: path,
166
+ meta: meta, body: payload.body, content: content
167
+ )
168
+ end
169
+
170
+ def validate_schema(mentry, eff_meta, eff_content)
171
+ schema = @schemas.fetch_or_nil(mentry.schema)
172
+ return unless schema
173
+
174
+ Entry.for_format(mentry.format).validate_against(
175
+ schema,
176
+ { "_meta" => eff_meta, "content" => eff_content },
177
+ )
178
+ end
179
+
180
+ def check_etag!(path, key, if_etag)
181
+ etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
182
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
183
+
184
+ etag_before
185
+ end
186
+
187
+ def write_bytes(path, bytes)
188
+ @file_store.write(path, bytes)
189
+ end
190
+
191
+ def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
192
+ Textus::Envelope.build(
193
+ key: key, mentry: mentry, path: path,
194
+ meta: eff_meta, body: eff_body,
195
+ etag: Etag.for_bytes(@file_store.read(path)),
196
+ content: eff_content
197
+ )
198
+ end
199
+
200
+ def audit_put(key, etag_before, etag_after)
201
+ extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
202
+ @audit_log.append(
203
+ role: @call.role, verb: "put", key: key,
204
+ etag_before: etag_before, etag_after: etag_after,
205
+ extras: extras
206
+ )
207
+ end
179
208
  end
180
209
  end
181
210
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Textus
4
4
  Envelope = Data.define(
5
- :protocol, :key, :zone, :owner, :path, :format,
5
+ :protocol, :key, :lane, :owner, :path, :format,
6
6
  :uid, :etag, :schema_ref, :meta, :body, :content, :freshness
7
7
  ) do
8
8
  # rubocop:disable Metrics/ParameterLists
@@ -11,7 +11,7 @@ module Textus
11
11
  new(
12
12
  protocol: Textus::PROTOCOL,
13
13
  key: key,
14
- zone: mentry.zone,
14
+ lane: mentry.lane,
15
15
  owner: mentry.owner,
16
16
  path: path,
17
17
  format: mentry.format,
@@ -34,7 +34,7 @@ module Textus
34
34
  h = {
35
35
  "protocol" => protocol,
36
36
  "key" => key,
37
- "zone" => zone,
37
+ "lane" => lane,
38
38
  "owner" => owner,
39
39
  "path" => path,
40
40
  "format" => format,
@@ -49,6 +49,8 @@ module Textus
49
49
  h
50
50
  end
51
51
 
52
+ alias_method :zone, :lane
53
+
52
54
  def stale?
53
55
  return false if freshness.nil?
54
56
 
data/lib/textus/errors.rb CHANGED
@@ -1,24 +1,32 @@
1
1
  module Textus
2
+ ErrorInfo = Data.define(:details, :exit_code, :hint) do
3
+ def self.for(details: {}, exit_code: 1, hint: nil)
4
+ new(details: details, exit_code: exit_code, hint: hint)
5
+ end
6
+ end
7
+
2
8
  class Error < StandardError
3
- attr_reader :code, :details, :exit_code, :hint
9
+ attr_reader :code
4
10
 
5
- def initialize(code, message, details: {}, exit_code: 1, hint: nil)
11
+ def initialize(code, message, info: nil, details: {}, exit_code: 1, hint: nil)
6
12
  super(message)
7
13
  @code = code
8
- @details = details
9
- @exit_code = exit_code
10
- @hint = hint
14
+ @info = info || ErrorInfo.for(details:, exit_code:, hint:)
11
15
  end
12
16
 
17
+ def details = @info.details
18
+ def exit_code = @info.exit_code
19
+ def hint = @info.hint
20
+
13
21
  def to_envelope
14
22
  env = {
15
23
  "protocol" => Textus::PROTOCOL,
16
24
  "ok" => false,
17
25
  "code" => @code,
18
26
  "message" => message,
19
- "details" => @details,
27
+ "details" => details,
20
28
  }
21
- env["hint"] = @hint if @hint
29
+ env["hint"] = hint if hint
22
30
  env
23
31
  end
24
32
  end
@@ -97,7 +105,7 @@ module Textus
97
105
  else
98
106
  "no declared role"
99
107
  end
100
- details = { "key" => k, "zone" => z }
108
+ details = { "key" => k, "lane" => z }
101
109
  details["verb"] = verb if verb
102
110
  details["holders"] = holders if holders
103
111
  super(
@@ -177,7 +185,7 @@ module Textus
177
185
  class PublishError < Error
178
186
  def initialize(m, target: nil)
179
187
  hint =
180
- ("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/zones/" if target)
188
+ ("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/data/" if target)
181
189
  super("publish_error", m, details: target ? { "target" => target } : {}, hint: hint)
182
190
  end
183
191
  end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Events
3
+ ENTRY_WRITTEN = "entry.written"
4
+ ENTRY_DELETED = "entry.deleted"
5
+ ENTRY_RENAMED = "entry.renamed"
6
+ ENTRY_FETCHED = "entry.fetched"
7
+ ENTRY_DERIVED = "entry.derived"
8
+ ENTRY_VALIDATED = "entry.validated"
9
+ ENTRY_PUBLISHED = "entry.published"
10
+ PIPELINE_FAILED = "pipeline.failed"
11
+
12
+ STEP_FETCH_COMPLETE = "step.fetch.complete"
13
+ STEP_TRANSFORM_COMPLETE = "step.transform.complete"
14
+ STEP_VALIDATE_PASSED = "step.validate.passed"
15
+ STEP_VALIDATE_FAILED = "step.validate.failed"
16
+
17
+ STORE_LOADED = "store.loaded"
18
+ SESSION_OPENED = "session.opened"
19
+ SESSION_CLOSED = "session.closed"
20
+ end
21
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Gate
5
+ class Auth
6
+ FLOOR = {
7
+ put: %w[lane_writable_by],
8
+ key_delete: %w[lane_writable_by],
9
+ key_mv: %w[lane_writable_by],
10
+ accept: %w[author_held],
11
+ reject: %w[author_held],
12
+ propose: %w[lane_writable_by],
13
+ key_mv_prefix: %w[lane_writable_by],
14
+ key_delete_prefix: %w[lane_writable_by],
15
+ }.freeze
16
+
17
+ AuthContext = Struct.new(
18
+ :actor, :actor_caps, :lane_verb,
19
+ :action, :target, :envelope,
20
+ :mentry, :manifest,
21
+ keyword_init: true
22
+ )
23
+
24
+ def initialize(container)
25
+ @manifest = container.manifest
26
+ @schemas = container.schemas
27
+ end
28
+
29
+ # Command-based check (new Gate path).
30
+ def check!(cmd)
31
+ key = extract_key(cmd)
32
+ return unless key
33
+
34
+ evaluate_predicates(
35
+ action: command_to_action(cmd),
36
+ actor: cmd.role.to_s,
37
+ key: key,
38
+ envelope: nil,
39
+ extra: {},
40
+ )
41
+ end
42
+
43
+ # Backward-compatible check for inline action auth (accept, put, etc.).
44
+ def check_action!(action:, actor:, key:, envelope: nil, extra: {})
45
+ evaluate_predicates(
46
+ action: action.to_sym,
47
+ actor: actor,
48
+ key: key,
49
+ envelope: envelope,
50
+ extra: extra,
51
+ )
52
+ end
53
+
54
+ def self.command_to_verb
55
+ @command_to_verb ||= Textus::Gate::VERB_COMMAND.invert.freeze
56
+ end
57
+
58
+ private
59
+
60
+ def command_to_action(cmd)
61
+ self.class.command_to_verb.fetch(cmd.class) do
62
+ raise Textus::UsageError.new("unmapped command: #{cmd.class}")
63
+ end
64
+ end
65
+
66
+ def evaluate_predicates(action:, actor:, key:, envelope:, extra:)
67
+ mentry = @manifest.resolver.resolve(key).entry
68
+ lane_verb = @manifest.policy.verb_for_lane(mentry.lane.to_s)
69
+ actor_caps = Set.new(@manifest.data.role_caps.fetch(actor, []))
70
+
71
+ ctx = AuthContext.new(
72
+ actor:, actor_caps:, lane_verb:,
73
+ action:, target: key, envelope:,
74
+ mentry:, manifest: @manifest
75
+ )
76
+
77
+ failures = []
78
+ floor_preds = FLOOR.fetch(action, [])
79
+ rule_preds = rule_declared_predicates(action, key)
80
+ (floor_preds + rule_preds).uniq.each do |pred|
81
+ result = evaluate(pred, ctx, extra)
82
+ next if result[:pass]
83
+ raise result[:error] if result[:error]
84
+
85
+ failures << [pred, result[:reason]]
86
+ end
87
+ raise Textus::GuardFailed.new(failures) unless failures.empty?
88
+ end
89
+
90
+ def extract_key(cmd)
91
+ if cmd.respond_to?(:pending_key)
92
+ cmd.pending_key
93
+ elsif cmd.respond_to?(:key)
94
+ cmd.key
95
+ end
96
+ end
97
+
98
+ def rule_declared_predicates(action, key)
99
+ guard_map = @manifest.rules.for(key).guard
100
+ return [] if guard_map.nil?
101
+
102
+ Array(guard_map[action.to_s])
103
+ end
104
+
105
+ def evaluate(pred_name, ctx, extra)
106
+ case pred_name
107
+ when "lane_writable_by" then evaluate_lane_writable_by(ctx)
108
+ when "author_held" then evaluate_author_held(ctx)
109
+ when "target_is_canon" then evaluate_target_is_canon(ctx)
110
+ when "etag_match" then evaluate_etag_match(ctx, extra)
111
+ when "schema_valid" then evaluate_schema_valid(ctx)
112
+ when "fresh_within" then { pass: true }
113
+ else raise Textus::UsageError.new("unknown predicate '#{pred_name}'")
114
+ end
115
+ end
116
+
117
+ def evaluate_lane_writable_by(ctx)
118
+ pass = ctx.actor_caps.include?(ctx.lane_verb.to_s)
119
+ return { pass: true } if pass
120
+
121
+ holders = @manifest.policy.roles_with_capability(ctx.lane_verb.to_s)
122
+ { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane, verb: ctx.lane_verb, holders:) }
123
+ end
124
+
125
+ def evaluate_author_held(ctx)
126
+ holders = @manifest.policy.roles_with_capability("author")
127
+ pass = holders.include?(ctx.actor.to_s)
128
+ reason = if pass
129
+ nil
130
+ elsif holders.empty?
131
+ "no role holds the 'author' capability; #{ctx.action} is disabled"
132
+ else
133
+ "role '#{ctx.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
134
+ end
135
+ { pass:, reason: }
136
+ end
137
+
138
+ def evaluate_target_is_canon(ctx)
139
+ kind = @manifest.policy.declared_kind(ctx.mentry.lane.to_s)
140
+ pass = kind == :canon
141
+ { pass:, reason: pass ? nil : "target lane '#{ctx.mentry.lane}' is not canon (kind: #{kind})" }
142
+ end
143
+
144
+ def evaluate_etag_match(ctx, extra)
145
+ if_etag = extra[:if_etag]
146
+ return { pass: true } if if_etag.nil?
147
+
148
+ current = ctx.envelope&.etag
149
+ pass = current.nil? || current == if_etag
150
+ { pass:, error: pass ? nil : Textus::EtagMismatch.new(ctx.target, if_etag, current) }
151
+ end
152
+
153
+ def evaluate_schema_valid(ctx)
154
+ return { pass: true } unless ctx.envelope
155
+
156
+ schema_ref = ctx.mentry.schema
157
+ return { pass: true } unless schema_ref
158
+
159
+ schema = @schemas.fetch_or_nil(schema_ref)
160
+ return { pass: true } unless schema
161
+
162
+ frontmatter = ctx.envelope.meta&.dig("_meta") || ctx.envelope.meta || {}
163
+ begin
164
+ schema.validate!(frontmatter)
165
+ { pass: true }
166
+ rescue Textus::SchemaViolation => e
167
+ { pass: false, reason: schema_reason(e) }
168
+ end
169
+ end
170
+
171
+ def schema_reason(err)
172
+ d = err.details
173
+ return err.message.dup unless d.is_a?(Hash)
174
+ return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
175
+ return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
176
+
177
+ err.message.dup
178
+ end
179
+ end
180
+ end
181
+ end