textus 0.52.0 → 0.54.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 (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -1,59 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module Textus
6
- module Domain
7
- module Policy
8
- module Predicates
9
- # Parameterized predicate: the entry must have been written within
10
- # `duration` of now. Duration strings ("1h", "30m", "7d") parse via
11
- # Domain::Duration.seconds. Passes when no envelope exists yet.
12
- class FreshWithin
13
- attr_reader :reason
14
-
15
- def initialize(duration:, now: nil)
16
- @seconds = Textus::Domain::Duration.seconds(duration)
17
- @now = now
18
- end
19
-
20
- def name = "fresh_within"
21
-
22
- def call(eval)
23
- return true if eval.envelope.nil? || @seconds.nil?
24
-
25
- written = written_at(eval.envelope)
26
- return true if written.nil?
27
-
28
- now = @now || Textus::Ports::Clock.new.now
29
- return true if now - written <= @seconds
30
-
31
- @reason = "entry older than #{@seconds}s (written #{written.iso8601})"
32
- false
33
- end
34
-
35
- private
36
-
37
- # Domain-pure: reads the stored write timestamp from the envelope's
38
- # freshness (checked_at) or meta (last_fetched_at) and parses the
39
- # stored ISO-8601 string. Parsing a stored string is not I/O (allowed
40
- # in domain, ADR 0024). `generated_at` is intentionally NOT consulted:
41
- # build-generation time is no longer carried in the artifact (ADR
42
- # 0070), and fetch-freshness is a fetch concept, not a build one.
43
- def written_at(envelope)
44
- raw = envelope.freshness&.checked_at ||
45
- envelope.meta&.dig("last_fetched_at")
46
- return raw if raw.is_a?(Time)
47
- return nil if raw.nil?
48
-
49
- begin
50
- Time.parse(raw.to_s)
51
- rescue StandardError
52
- nil
53
- end
54
- end
55
- end
56
- end
57
- end
58
- end
59
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- module Predicates
7
- # The single source of truth for the predicate vocabulary
8
- # (ADR 0031 §3). Replaces both Promote::KNOWN and Promotion::REGISTRY.
9
- # Each entry is name => ->(params:, schemas:) { predicate }.
10
- module Registry
11
- ENTRIES = {
12
- "zone_writable_by" => ->(**) { ZoneWritableBy.new },
13
- "author_held" => ->(**) { AuthorHeld.new },
14
- "target_is_canon" => ->(**) { TargetIsCanon.new },
15
- "schema_valid" => ->(schemas:, **) { SchemaValid.new(schemas: schemas) },
16
- "etag_match" => ->(params:, **) { EtagMatch.new(if_etag: params) },
17
- "fresh_within" => ->(params:, **) { FreshWithin.new(duration: params) },
18
- }.freeze
19
-
20
- # Accepts either "name" or { "name" => params }.
21
- def self.build(spec, schemas:)
22
- name, params =
23
- if spec.is_a?(Hash)
24
- spec.first
25
- else
26
- [spec.to_s, nil]
27
- end
28
- ctor = ENTRIES[name.to_s] or raise Textus::UsageError.new(
29
- "unknown guard predicate: '#{name}' (known: #{ENTRIES.keys.join(", ")})",
30
- )
31
- ctor.call(params: params, schemas: schemas)
32
- end
33
-
34
- def self.known = ENTRIES.keys
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- module Predicates
7
- # Predicate: the entry's effective frontmatter satisfies the schema
8
- # bound to the target key. For accept, the frontmatter lives under
9
- # envelope.meta["frontmatter"]; for a direct put it is envelope.meta.
10
- class SchemaValid
11
- attr_reader :reason
12
-
13
- def initialize(schemas:)
14
- @schemas = schemas
15
- end
16
-
17
- def name = "schema_valid"
18
-
19
- def call(eval)
20
- manifest = eval.manifest
21
- return true if eval.envelope.nil? || manifest.nil? || @schemas.nil?
22
-
23
- target_key = eval.target
24
- return true unless target_key
25
-
26
- mentry = manifest.resolver.resolve(target_key).entry
27
- schema_ref = mentry&.schema
28
- return true unless schema_ref
29
-
30
- schema = @schemas.fetch_or_nil(schema_ref)
31
- return true unless schema
32
-
33
- frontmatter =
34
- eval.envelope.meta&.dig("frontmatter") || eval.envelope.meta || {}
35
- begin
36
- schema.validate!(frontmatter)
37
- true
38
- rescue Textus::SchemaViolation => e
39
- @reason = humanize(e)
40
- false
41
- end
42
- rescue StandardError => e
43
- @reason = "schema validation error: #{e.message}"
44
- false
45
- end
46
-
47
- private
48
-
49
- def humanize(err)
50
- d = err.details
51
- return err.message.dup unless d.is_a?(Hash)
52
- return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
53
- return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
54
-
55
- err.message.dup
56
- end
57
- end
58
- end
59
- end
60
- end
61
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- module Predicates
7
- # Predicate: a proposal may only target a `canon` zone (ADR 0035). Runs
8
- # on the `accept` floor, where Evaluation#target is the proposal's
9
- # resolved target_key. Refuses promotion into workspace/derived/
10
- # quarantine/queue — the queue→canon path is the only coherent one.
11
- # No bespoke #error; failures accumulate into GuardFailed (ADR 0031).
12
- class TargetIsCanon
13
- attr_reader :reason
14
-
15
- def name = "target_is_canon"
16
-
17
- def call(eval)
18
- zone = eval.manifest.resolver.resolve(eval.target).entry.zone
19
- kind = eval.manifest.policy.declared_kind(zone.to_s)
20
- return true if kind == :canon
21
-
22
- @reason = "proposal target '#{eval.target}' is in zone '#{zone}' " \
23
- "(kind: #{kind || "none"}); proposals may only target a canon zone"
24
- false
25
- rescue Textus::UnknownKey
26
- @reason = "proposal target '#{eval.target}' resolves to no declared entry"
27
- false
28
- end
29
- end
30
- end
31
- end
32
- end
33
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- module Predicates
7
- # Predicate #0 of every write guard. Wraps the post-0.31.0 capability
8
- # topology gate (role.can ⊇ verb_for(zone.kind)). On failure, #error
9
- # raises the capability-shaped WriteForbidden so the topology refusal
10
- # — textus's signature product feature — is unchanged.
11
- class ZoneWritableBy
12
- attr_reader :reason
13
-
14
- def name = "zone_writable_by"
15
-
16
- def call(eval)
17
- manifest = eval.manifest
18
- @mentry = manifest.resolver.resolve(eval.target).entry
19
- return true if manifest.policy.permission_for(@mentry.zone.to_s).allows_write?(eval.actor)
20
-
21
- @verb = manifest.policy.verb_for_zone(@mentry.zone) # capability the kind requires
22
- @holders = manifest.policy.roles_with_capability(@verb)
23
- @reason = "zone '#{@mentry.zone}' needs capability '#{@verb}'; '#{eval.actor}' lacks it"
24
- false
25
- end
26
-
27
- # Matches the capability-shaped WriteForbidden landed by ADR 0030
28
- # Task 3:
29
- # WriteForbidden.new(key, zone, verb:, holders:)
30
- # → "writing '<k>' (zone '<z>') needs capability '<verb>'",
31
- # hint: "held by: <holders>; pass --as=<role>".
32
- def error(_eval)
33
- Textus::WriteForbidden.new(@mentry.key, @mentry.zone, verb: @verb, holders: @holders)
34
- end
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,73 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- # An entry's data-acquisition declaration (ADR 0094). `source:` says HOW the
5
- # entry's data is acquired; rendering is a publish concern, so there are no
6
- # template/render fields here. `from` is the acquire + staleness axis:
7
- # from: project -> derived (internal projection; observable -> rdeps staleness)
8
- # from: handler -> intake (external fetch; unobservable -> ttl staleness)
9
- # from: command -> external (out-of-band runner; staleness only, textus never runs it)
10
- # Materialization is async-only (job-queue model): a write enqueues a
11
- # `materialize` job, converged by a worker. There is no per-entry write
12
- # trigger knob.
13
- class Source
14
- FROMS = %w[project handler command].freeze
15
-
16
- attr_reader :from, :handler, :config, :command, :sources
17
-
18
- def initialize(raw)
19
- @from = raw["from"].to_s
20
- unless FROMS.include?(@from)
21
- raise Textus::BadManifest.new("source.from must be one of #{FROMS.join("|")}, got #{raw["from"].inspect}")
22
- end
23
-
24
- @ttl = raw["ttl"]
25
- @projection = {}
26
-
27
- case @from
28
- when "project" then init_project(raw)
29
- when "handler" then init_handler(raw)
30
- when "command" then init_command(raw)
31
- end
32
- end
33
-
34
- def kind = @from == "handler" ? :intake : :derived
35
- def external? = @from == "command"
36
- def projection? = @from == "project"
37
- def ttl_seconds = @ttl.nil? ? nil : Textus::Domain::Duration.seconds(@ttl)
38
-
39
- # Flattened projection accessors (ADR 0094) — read directly off the source
40
- # block; nil when absent or not a projection source.
41
- def select = @projection["select"]
42
- def pluck = @projection["pluck"]
43
- def sort_by = @projection["sort_by"]
44
- def transform = @projection["transform"]
45
-
46
- # The projection spec hash fed to Textus::Projection (string keys, only the
47
- # present fields). {} when not a projection.
48
- def projection_spec = @projection.dup
49
-
50
- private
51
-
52
- def init_project(raw)
53
- %w[select pluck sort_by transform].each { |f| @projection[f] = raw[f] if raw.key?(f) }
54
- return unless @projection["select"].nil? && @projection["transform"].nil?
55
-
56
- raise Textus::BadManifest.new("source (from: project) requires `select:` and/or `transform:`")
57
- end
58
-
59
- def init_handler(raw)
60
- @handler = raw["handler"] or
61
- raise Textus::BadManifest.new("source (from: handler) requires a `handler:` field")
62
- @config = raw["config"] || {}
63
- end
64
-
65
- def init_command(raw)
66
- @command = raw["command"] or
67
- raise Textus::BadManifest.new("source (from: command) requires a `command:` field")
68
- @sources = raw["sources"] || []
69
- end
70
- end
71
- end
72
- end
73
- end
@@ -1,48 +0,0 @@
1
- module Textus
2
- class Envelope
3
- module IO
4
- # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
- # bytes, parses them via the format strategy, and hands back an
6
- # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
7
- # (existing-uid lookup for the uid-preservation step in #put).
8
- #
9
- # No audit, no events, no permission checks — those live one layer up.
10
- class Reader
11
- def self.from(container:)
12
- new(file_store: container.file_store, manifest: container.manifest)
13
- end
14
-
15
- def initialize(file_store:, manifest:)
16
- @file_store = file_store
17
- @manifest = manifest
18
- end
19
-
20
- def read(key)
21
- res = @manifest.resolver.resolve(key)
22
- path = res.path
23
- return nil unless @file_store.exists?(path)
24
-
25
- mentry = res.entry
26
- raw = @file_store.read(path)
27
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
28
- Textus::Envelope.build(
29
- key: key, mentry: mentry, path: path,
30
- meta: parsed["_meta"], body: parsed["body"],
31
- etag: Etag.for_bytes(raw), content: parsed["content"]
32
- )
33
- end
34
-
35
- def existing_uid(key)
36
- env = read(key)
37
- env&.uid
38
- rescue StandardError
39
- nil
40
- end
41
-
42
- def exists?(key)
43
- @file_store.exists?(@manifest.resolver.resolve(key).path)
44
- end
45
- end
46
- end
47
- end
48
- end
@@ -1,182 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- class Envelope
5
- module IO
6
- # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
- # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
- # Reader for the existing-uid lookup.
9
- #
10
- # Invariant: every public method's final action is @audit_log.append(...).
11
- #
12
- # No permission check, no event firing — those belong to the caller
13
- # (Write::Put / ::Delete / ::Mv).
14
- class Writer
15
- Payload = Data.define(:meta, :body, :content)
16
-
17
- def self.from(container:, call:)
18
- new(
19
- file_store: container.file_store, manifest: container.manifest,
20
- schemas: container.schemas, audit_log: container.audit_log,
21
- call: call, reader: Reader.from(container: container)
22
- )
23
- end
24
-
25
- def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
26
- @file_store = file_store
27
- @manifest = manifest
28
- @schemas = schemas
29
- @audit_log = audit_log
30
- @call = call
31
- @reader = reader
32
- end
33
-
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
-
47
- 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
- )
71
- envelope
72
- end
73
-
74
- def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
75
- # `mentry:` is accepted for symmetry with `put` / `move` and to
76
- # leave room for future format-specific delete hooks; no field
77
- # on it is needed today.
78
- path = @manifest.resolver.resolve(key).path
79
- raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
80
-
81
- etag_before = @file_store.etag(path)
82
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
83
-
84
- @file_store.delete(path)
85
- prune_empty_parents(path)
86
- @audit_log.append(
87
- role: @call.role, verb: "key_delete", key: key,
88
- etag_before: etag_before, etag_after: nil,
89
- extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
90
- )
91
- end
92
-
93
- def move(from_key:, to_key:, new_mentry:, if_etag: nil)
94
- from_path = @manifest.resolver.resolve(from_key).path
95
- to_path = @manifest.resolver.resolve(to_key).path
96
- raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
97
-
98
- etag_before = @file_store.etag(from_path)
99
- raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
100
-
101
- FileUtils.mkdir_p(File.dirname(to_path))
102
- FileUtils.mv(from_path, to_path)
103
- prune_empty_parents(from_path)
104
- basename = to_key.split(".").last
105
- Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
106
- etag_after = Etag.for_file(to_path)
107
-
108
- raw = @file_store.read(to_path)
109
- parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
110
- envelope = Textus::Envelope.build(
111
- key: to_key, mentry: new_mentry, path: to_path,
112
- meta: parsed["_meta"], body: parsed["body"],
113
- etag: etag_after, content: parsed["content"]
114
- )
115
-
116
- extras = {
117
- "from_key" => from_key, "to_key" => to_key,
118
- "from_path" => from_path, "to_path" => to_path,
119
- "uid" => envelope.uid
120
- }
121
- extras["correlation_id"] = @call.correlation_id if @call.correlation_id
122
-
123
- @audit_log.append(
124
- role: @call.role, verb: "key_mv", key: to_key,
125
- etag_before: etag_before, etag_after: etag_after,
126
- extras: extras
127
- )
128
-
129
- envelope
130
- end
131
-
132
- private
133
-
134
- # After a file leaves a directory (delete or move-source), remove any
135
- # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
136
- # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
137
- # declared, first-class container, so its own dir is preserved even when
138
- # momentarily empty; only the sub-dirs the bulk op carved out are
139
- # pruned. Stops at the first non-empty ancestor, so a dir holding a
140
- # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
141
- # non-empty dir is silently fine, never fatal to the write.
142
- def prune_empty_parents(path)
143
- floor = zone_floor(path)
144
- return unless floor
145
-
146
- dir = File.dirname(path)
147
- while dir.start_with?("#{floor}/") && Dir.empty?(dir)
148
- Dir.rmdir(dir)
149
- dir = File.dirname(dir)
150
- end
151
- rescue SystemCallError
152
- nil
153
- end
154
-
155
- # The zone directory under which `path` lives (`<root>/zones/<zone>`),
156
- # or nil if `path` is not under the store's zones tree.
157
- def zone_floor(path)
158
- zones_root = File.join(@manifest.data.root, "zones")
159
- prefix = "#{zones_root}/"
160
- return nil unless path.start_with?(prefix)
161
-
162
- zone_seg = path.delete_prefix(prefix).split("/").first
163
- zone_seg && File.join(zones_root, zone_seg)
164
- end
165
-
166
- def ensure_uid(format, meta, content, existing_uid)
167
- Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
168
- end
169
-
170
- def enforce_name_match!(path, meta, format)
171
- Textus::Entry.for_format(format).enforce_name_match!(path, meta)
172
- end
173
-
174
- def serialize_for_put(mentry:, path:, meta:, body:, content:)
175
- Textus::Entry.for_format(mentry.format).serialize_for_put(
176
- meta: meta, body: body, content: content, path: path,
177
- )
178
- end
179
- end
180
- end
181
- end
182
- end
@@ -1,70 +0,0 @@
1
- require "json"
2
- require "csv"
3
- require "yaml"
4
- require "rexml/document"
5
-
6
- module Textus
7
- module Hooks
8
- module Builtin
9
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
- def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
11
- rpc.register(:resolve_handler, :json) do |caps:, config:, args:|
12
- _ = caps
13
- _ = args
14
- data = JSON.parse(config["bytes"].to_s)
15
- { _meta: {}, body: YAML.dump(data) }
16
- end
17
-
18
- rpc.register(:resolve_handler, :csv) do |caps:, config:, args:|
19
- _ = caps
20
- _ = args
21
- rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
- { _meta: {}, body: YAML.dump(rows) }
23
- end
24
-
25
- rpc.register(:resolve_handler, :"markdown-links") do |caps:, config:, args:|
26
- _ = caps
27
- _ = args
28
- links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
29
- { "text" => text, "href" => href }
30
- end
31
- { _meta: {}, body: YAML.dump(links) }
32
- end
33
-
34
- rpc.register(:resolve_handler, :"ical-events") do |caps:, config:, args:|
35
- _ = caps
36
- _ = args
37
- events_list = []
38
- current = nil
39
- config["bytes"].to_s.each_line do |line|
40
- line = line.strip
41
- case line
42
- when "BEGIN:VEVENT" then current = {}
43
- when "END:VEVENT"
44
- events_list << current if current
45
- current = nil
46
- when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
47
- current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
48
- end
49
- end
50
- { _meta: {}, body: YAML.dump(events_list) }
51
- end
52
-
53
- rpc.register(:resolve_handler, :rss) do |caps:, config:, args:|
54
- _ = caps
55
- _ = args
56
- doc = REXML::Document.new(config["bytes"].to_s)
57
- items = doc.elements.to_a("//item").map do |item|
58
- {
59
- "title" => item.elements["title"]&.text,
60
- "link" => item.elements["link"]&.text,
61
- "pubDate" => item.elements["pubDate"]&.text,
62
- }
63
- end
64
- { _meta: {}, body: YAML.dump(items) }
65
- end
66
- end
67
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
68
- end
69
- end
70
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Hooks
5
- # The single source of truth for hook event names and their required
6
- # kwargs. EventBus, RpcRegistry, and the Loader DSL router all read these
7
- # tables directly — the registries do not keep their own copies. Catalog
8
- # references no other constant, so it has no load-order cycle, which is
9
- # what removed the previous drift hazard (EventBus held a hard-coded
10
- # `RPC_EVENTS` list that could fall out of sync with RpcRegistry's table).
11
- module Catalog
12
- # Pub-sub events: 0..N handlers, fire-and-forget, receive `ctx:`.
13
- PUBSUB = {
14
- entry_written: %i[ctx key envelope],
15
- entry_deleted: %i[ctx key],
16
- entry_fetched: %i[ctx key envelope change],
17
- entry_renamed: %i[ctx key from_key to_key envelope],
18
- entry_produced: %i[ctx key envelope sources],
19
- produce_failed: %i[ctx keys error],
20
- proposal_accepted: %i[ctx key target_key],
21
- proposal_rejected: %i[ctx key target_key],
22
- entry_published: %i[ctx key envelope source target],
23
- store_loaded: %i[ctx],
24
- session_opened: %i[ctx role cursor],
25
- entry_fetch_started: %i[ctx key mode],
26
- entry_fetch_failed: %i[ctx key error_class error_message],
27
- }.freeze
28
-
29
- # RPC events: single handler, return value matters, receive `caps:`.
30
- RPC = {
31
- resolve_handler: %i[caps config args],
32
- transform_rows: %i[caps rows config],
33
- validate: %i[caps],
34
- }.freeze
35
- end
36
- end
37
- end