textus 0.51.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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  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 +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  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/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Read
3
- # Store-wide schema + role-authority validation: walks every entry and runs
4
- # the Validator over it. Consumed internally by `doctor`'s schema_violations
5
- # check and exposed as a Ruby store method (`store.validate_all`).
6
- #
7
- # Ruby-only, like `Read::Freshness`: it declares a contract (so it round-trips
8
- # through the routing<->contract bijection, ADR 0105) but omits `surfaces`, so
9
- # it gets no CLI or MCP projection. The public `validate-all` CLI verb was
10
- # removed in v0.5 (`doctor --check=schema_violations` replaces it).
11
- class ValidateAll
12
- extend Textus::Contract::DSL
13
-
14
- verb :validate_all
15
- summary "Internal store-wide schema + role-authority validation; backs " \
16
- "doctor's schema_violations check. No public surface (ADR 0105)."
17
-
18
- def initialize(container:, call:)
19
- @container = container
20
- @call = call
21
- @manifest = container.manifest
22
- @schemas = container.schemas
23
- @audit_log = container.audit_log
24
- end
25
-
26
- def call
27
- Validator.new(
28
- reader: Get.new(container: @container, call: @call),
29
- manifest: @manifest,
30
- audit_log: @audit_log,
31
- schema_for: ->(name) { @schemas.fetch_or_nil(name) },
32
- ).call
33
- end
34
- end
35
- end
36
- end
@@ -1,24 +0,0 @@
1
- module Textus
2
- module Read
3
- class Where
4
- extend Textus::Contract::DSL
5
-
6
- verb :where
7
- summary "Resolve a key to its zone, owner, and path without reading the body."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted key to locate (returns zone, owner, path; does not read content)"
11
-
12
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
13
- @manifest = container.manifest
14
- end
15
-
16
- def call(key)
17
- res = @manifest.resolver.resolve(key)
18
- mentry = res.entry
19
- path = res.path
20
- { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
21
- end
22
- end
23
- end
24
- end
@@ -1,78 +0,0 @@
1
- module Textus
2
- # Thin role-scoped facade over a Container. Closes over a role default
3
- # and a dry_run flag, then forwards every verb in Dispatcher::VERBS to
4
- # the corresponding use case.
5
- #
6
- # Replaces the per-call Session under the 0.27.0 architecture: a Store
7
- # exposes #as(role) to get a RoleScope, and Store#put / Store#get / etc
8
- # delegate to RoleScope under the default role.
9
- class RoleScope
10
- attr_reader :container, :role, :correlation_id
11
-
12
- def dry_run?
13
- @dry_run
14
- end
15
-
16
- def initialize(container:, role:, dry_run: false, correlation_id: nil)
17
- @container = container
18
- @role = role.to_s
19
- @dry_run = dry_run
20
- @correlation_id = correlation_id
21
- end
22
-
23
- def with_role(role)
24
- self.class.new(container: @container, role: role, dry_run: @dry_run, correlation_id: @correlation_id)
25
- end
26
-
27
- def with_correlation_id(cid)
28
- self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
29
- end
30
-
31
- def hook_context
32
- @hook_context ||= Textus::Hooks::Context.new(scope: self)
33
- end
34
-
35
- def with_dry_run
36
- self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
37
- end
38
-
39
- # Single bind + invoke site for every surface. `inputs` is the uniform
40
- # by-name hash (the binder's currency). MCP/CLI build it from their raw
41
- # transport shape and call this directly; the per-verb Ruby methods below
42
- # normalize positional+keyword Ruby args into `inputs` and delegate here.
43
- def dispatch_bound(verb, inputs, session: nil)
44
- klass = Textus::Dispatcher::VERBS[verb]
45
- spec = (klass.contract if klass.respond_to?(:contract?) && klass.contract?)
46
-
47
- invoke = lambda do |effective_inputs|
48
- args, kwargs =
49
- if spec
50
- Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
51
- else
52
- [[], effective_inputs]
53
- end
54
- call_value = Textus::Call.build(role: @role, correlation_id: @correlation_id, dry_run: @dry_run)
55
- Textus::Dispatcher.invoke(verb, container: @container, call: call_value, args: args, kwargs: kwargs)
56
- end
57
-
58
- if spec&.around
59
- Textus::Contract::Around.with(spec.around, scope: self, inputs: inputs, session: session, &invoke)
60
- else
61
- invoke.call(inputs)
62
- end
63
- end
64
-
65
- Textus::Dispatcher::VERBS.each_key do |verb|
66
- define_method(verb) do |*args, **kwargs|
67
- klass = Textus::Dispatcher::VERBS[verb]
68
- inputs =
69
- if klass.respond_to?(:contract?) && klass.contract?
70
- Textus::Contract::Binder.inputs_from_ordered(klass.contract, args, kwargs)
71
- else
72
- kwargs
73
- end
74
- dispatch_bound(verb, inputs)
75
- end
76
- end
77
- end
78
- end
@@ -1,58 +0,0 @@
1
- module Textus
2
- module Write
3
- class Accept
4
- extend Textus::Contract::DSL
5
-
6
- verb :accept
7
- summary "apply a queued proposal to its target zone; requires the author capability"
8
- surfaces :cli, :mcp
9
- cli "accept"
10
- arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
11
-
12
- def initialize(container:, call:)
13
- @container = container
14
- @call = call
15
- @manifest = container.manifest
16
- @schemas = container.schemas
17
- @events = container.events
18
- end
19
-
20
- def call(pending_key)
21
- env = Textus::Read::Get.new(container: @container, call: @call).call(pending_key)
22
- proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
23
- target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
24
- action = proposal["action"] || "put"
25
-
26
- guard.for(:accept, target).check!(
27
- Textus::Domain::Policy::Evaluation.new(
28
- actor: @call.role, transition: :accept, origin: pending_key,
29
- target: target, envelope: env, manifest: @manifest
30
- ),
31
- )
32
-
33
- case action
34
- when "put"
35
- put_op.call(target, meta: env.meta["frontmatter"] || {}, body: env.body)
36
- when "delete"
37
- delete_op.call(target)
38
- else
39
- raise ProposalError.new("unknown action: #{action}")
40
- end
41
-
42
- delete_op.call(pending_key)
43
- @events.publish(:proposal_accepted, ctx: hook_context, key: pending_key, target_key: target)
44
- { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
45
- end
46
-
47
- private
48
-
49
- def guard
50
- @guard ||= Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
51
- end
52
-
53
- def hook_context = @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
54
- def put_op = @put_op ||= Textus::Write::Put.new(container: @container, call: @call)
55
- def delete_op = @delete_op ||= Textus::Write::KeyDelete.new(container: @container, call: @call)
56
- end
57
- end
58
- end
@@ -1,65 +0,0 @@
1
- module Textus
2
- module Write
3
- class KeyDelete
4
- extend Textus::Contract::DSL
5
-
6
- verb :key_delete
7
- summary "Delete one entry by key. Single-key, lower blast radius than " \
8
- "key_delete_prefix; guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
9
- surfaces :cli, :mcp
10
- cli "key delete"
11
- arg :key, String, required: true, positional: true,
12
- description: "dotted entry key to delete"
13
- arg :if_etag, String,
14
- description: "optimistic-concurrency guard: the etag you last read; the delete is rejected if the entry changed since"
15
- # `call` already returns a wire hash {protocol, ok, key, deleted}; identity response.
16
-
17
- def initialize(container:, call:)
18
- @container = container
19
- @call = call
20
- @manifest = container.manifest
21
- @events = container.events
22
- end
23
-
24
- def call(key, if_etag: nil, suppress_events: false)
25
- Textus::Manifest::Data.validate_key!(key)
26
- mentry = @manifest.resolver.resolve(key).entry
27
-
28
- guard_for(:key_delete, key, if_etag: if_etag).check!(eval_for(:key_delete, target_key: key))
29
-
30
- writer.delete(key, mentry: mentry, if_etag: if_etag)
31
-
32
- unless suppress_events
33
- @events.publish(:entry_deleted,
34
- ctx: hook_context,
35
- key: key)
36
- end
37
-
38
- { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
39
- end
40
-
41
- private
42
-
43
- def guard_for(transition, key, if_etag: nil)
44
- Textus::Domain::Policy::GuardFactory.new(
45
- manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
46
- ).for(transition, key)
47
- end
48
-
49
- def eval_for(transition, target_key:, envelope: nil)
50
- Textus::Domain::Policy::Evaluation.new(
51
- actor: @call.role, transition: transition, origin: nil,
52
- target: target_key, envelope: envelope, manifest: @manifest
53
- )
54
- end
55
-
56
- def hook_context
57
- @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
58
- end
59
-
60
- def writer
61
- @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
62
- end
63
- end
64
- end
65
- end
@@ -1,141 +0,0 @@
1
- module Textus
2
- module Write
3
- class KeyMv
4
- extend Textus::Contract::DSL
5
-
6
- verb :key_mv
7
- summary "Rename one entry (same zone + format). Refuses if the target exists. Single-key, lower blast radius than key_mv_prefix."
8
- surfaces :cli, :mcp
9
- cli "key mv"
10
- arg :old_key, String, required: true, positional: true,
11
- description: "current dotted key"
12
- arg :new_key, String, required: true, positional: true,
13
- description: "new dotted key (must be the same zone and format as old_key)"
14
- arg :dry_run, :boolean,
15
- description: "when true, returns the planned move (from/to paths, uid) without applying it; " \
16
- "defaults to false, so omitting it applies the move immediately " \
17
- "(unlike the bulk key_mv_prefix, which defaults to a dry-run plan)"
18
- # `call` already returns a wire hash; identity response.
19
-
20
- def initialize(container:, call:)
21
- @container = container
22
- @call = call
23
- @manifest = container.manifest
24
- @events = container.events
25
- end
26
-
27
- def call(old_key, new_key, dry_run: false)
28
- old_res, new_res = prepare(old_key, new_key)
29
- return dry_run_result(old_key, new_key, old_res, new_res) if dry_run
30
-
31
- ensure_uid!(old_key, old_res.entry)
32
- envelope = writer.move(
33
- from_key: old_key, to_key: new_key,
34
- new_mentry: new_res.entry
35
- )
36
- publish_renamed(old_key, new_key, envelope)
37
- success_result(old_key, new_key, old_res, new_res, envelope)
38
- end
39
-
40
- private
41
-
42
- def hook_context
43
- @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
44
- end
45
-
46
- def prepare(old_key, new_key)
47
- Textus::Manifest::Data.validate_key!(old_key)
48
- Textus::Manifest::Data.validate_key!(new_key)
49
- raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
50
-
51
- old_res = @manifest.resolver.resolve(old_key)
52
- new_res = @manifest.resolver.resolve(new_key)
53
- raise UnknownKey.new(old_key) unless reader.exists?(old_key)
54
-
55
- validate_zone_and_format!(old_res.entry, new_res.entry)
56
- guard_for(:key_mv, old_key).check!(eval_for(:key_mv, target_key: old_key))
57
- guard_for(:key_mv, new_key).check!(eval_for(:key_mv, target_key: new_key))
58
- raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if reader.exists?(new_key)
59
-
60
- [old_res, new_res]
61
- end
62
-
63
- def validate_zone_and_format!(old_mentry, new_mentry)
64
- if old_mentry.zone != new_mentry.zone
65
- raise UsageError.new(
66
- "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
67
- "Use put+delete for cross-zone moves.",
68
- )
69
- end
70
- return if old_mentry.format == new_mentry.format
71
-
72
- raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
73
- end
74
-
75
- # If the source file lacks a UID, rewrite it in-place via the writer
76
- # so a UID gets injected before the move. This produces one "put"
77
- # audit row, then the "mv" row from Writer#move.
78
- def ensure_uid!(old_key, old_mentry)
79
- pre_env = reader.read(old_key)
80
- return if pre_env.uid
81
-
82
- writer.put(
83
- old_key, mentry: old_mentry,
84
- payload: Textus::Envelope::IO::Writer::Payload.new(
85
- meta: pre_env.meta, body: pre_env.body, content: pre_env.content,
86
- )
87
- )
88
- end
89
-
90
- def publish_renamed(old_key, new_key, envelope)
91
- @events.publish(:entry_renamed,
92
- ctx: hook_context,
93
- key: new_key,
94
- from_key: old_key,
95
- to_key: new_key,
96
- envelope: envelope)
97
- end
98
-
99
- def dry_run_result(old_key, new_key, old_res, new_res)
100
- pre_env = reader.read(old_key)
101
- {
102
- "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
103
- "from_key" => old_key, "to_key" => new_key,
104
- "from_path" => old_res.path, "to_path" => new_res.path,
105
- "uid" => pre_env.uid
106
- }
107
- end
108
-
109
- def success_result(old_key, new_key, old_res, new_res, envelope)
110
- {
111
- "protocol" => PROTOCOL, "ok" => true,
112
- "from_key" => old_key, "to_key" => new_key,
113
- "from_path" => old_res.path, "to_path" => new_res.path,
114
- "uid" => envelope.uid,
115
- "envelope" => envelope.to_h_for_wire
116
- }
117
- end
118
-
119
- def guard_for(transition, key, if_etag: nil)
120
- Textus::Domain::Policy::GuardFactory.new(
121
- manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
122
- ).for(transition, key)
123
- end
124
-
125
- def eval_for(transition, target_key:, envelope: nil)
126
- Textus::Domain::Policy::Evaluation.new(
127
- actor: @call.role, transition: transition, origin: nil,
128
- target: target_key, envelope: envelope, manifest: @manifest
129
- )
130
- end
131
-
132
- def writer
133
- @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
134
- end
135
-
136
- def reader
137
- @reader ||= Textus::Envelope::IO::Reader.from(container: @container)
138
- end
139
- end
140
- end
141
- end
@@ -1,54 +0,0 @@
1
- module Textus
2
- module Write
3
- # Queue a proposal: resolve the acting role's propose_zone, prefix the key,
4
- # and write there via the Put verb. Was inlined in the MCP `propose` tool
5
- # and the CLI propose verb; promoted to a first-class verb so all three
6
- # transports share one implementation (ADR 0036, ADR 0039).
7
- class Propose
8
- extend Textus::Contract::DSL
9
-
10
- verb :propose
11
- summary "Write a proposal to the role's propose_zone. Auto-prefixes the key."
12
- surfaces :cli, :mcp
13
- cli_stdin :json
14
- arg :key, String, required: true, positional: true,
15
- description: "key relative to propose_zone, e.g. 'decisions.feature-x'"
16
- arg :meta, Hash, required: false, wire_name: :_meta,
17
- description: "frontmatter; reads back as `_meta` from `get`. Include a 'proposal:' block naming the target_key"
18
- arg :body, String,
19
- description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
20
- arg :content, Hash,
21
- description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
22
- # ADR 0069: every surface receives the raw Envelope and self-shapes — no
23
- # surface pre-wires the result. Emitting the full wire envelope on every
24
- # surface is a superset of the old `{uid, etag, key}` (the accepted
25
- # breaking change; MCP/Ruby now get the full envelope too).
26
- view { |env, _i| env.to_h_for_wire }
27
-
28
- def initialize(container:, call:)
29
- @container = container
30
- @call = call
31
- @manifest = container.manifest
32
- end
33
-
34
- # if_etag is intentionally absent: a proposal is always a fresh queue write.
35
- def call(key, meta: nil, body: nil, content: nil)
36
- zone = @manifest.policy.propose_zone_for(@call.role)
37
- unless zone
38
- raise Textus::Error.new(
39
- "propose_forbidden",
40
- "role '#{@call.role}' has no writable propose_zone",
41
- details: { "role" => @call.role },
42
- hint: "the manifest must define a queue zone and '#{@call.role}' must hold the 'propose' capability",
43
- )
44
- end
45
-
46
- Textus::Dispatcher.invoke(
47
- :put, container: @container, call: @call,
48
- args: ["#{zone}.#{key}"],
49
- kwargs: { meta: meta || {}, body: body, content: content }
50
- )
51
- end
52
- end
53
- end
54
- end
@@ -1,74 +0,0 @@
1
- module Textus
2
- module Write
3
- class Put
4
- extend Textus::Contract::DSL
5
-
6
- verb :put
7
- summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"
11
- arg :meta, Hash, required: false, wire_name: :_meta,
12
- description: "frontmatter; reads back as `_meta` from `get`. Schema-validated — call `schema KEY` first"
13
- arg :body, String,
14
- description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
15
- arg :content, Hash,
16
- description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
17
- arg :if_etag, String,
18
- description: "optimistic-concurrency guard: the etag you last read; the write is rejected if the entry changed since"
19
- view { |env| { "uid" => env.uid, "etag" => env.etag } }
20
-
21
- def initialize(container:, call:)
22
- @container = container
23
- @call = call
24
- @manifest = container.manifest
25
- @events = container.events
26
- end
27
-
28
- def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
29
- Textus::Manifest::Data.validate_key!(key)
30
- mentry = @manifest.resolver.resolve(key).entry
31
- guard_for(:put, key, if_etag: if_etag).check!(eval_for(:put, target_key: key))
32
-
33
- envelope = writer.put(
34
- key,
35
- mentry: mentry,
36
- payload: Textus::Envelope::IO::Writer::Payload.new(
37
- meta: meta, body: body, content: content,
38
- ),
39
- if_etag: if_etag,
40
- )
41
-
42
- @events.publish(:entry_written,
43
- ctx: hook_context,
44
- key: key,
45
- envelope: envelope)
46
-
47
- envelope
48
- end
49
-
50
- private
51
-
52
- def guard_for(transition, key, if_etag: nil)
53
- Textus::Domain::Policy::GuardFactory.new(
54
- manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
55
- ).for(transition, key)
56
- end
57
-
58
- def eval_for(transition, target_key:, envelope: nil)
59
- Textus::Domain::Policy::Evaluation.new(
60
- actor: @call.role, transition: transition, origin: nil,
61
- target: target_key, envelope: envelope, manifest: @manifest
62
- )
63
- end
64
-
65
- def hook_context
66
- @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
67
- end
68
-
69
- def writer
70
- @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
71
- end
72
- end
73
- end
74
- end
@@ -1,68 +0,0 @@
1
- module Textus
2
- module Write
3
- class Reject
4
- extend Textus::Contract::DSL
5
-
6
- verb :reject
7
- summary "discard a queued proposal without applying it"
8
- surfaces :cli, :mcp
9
- cli "reject"
10
- arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
11
-
12
- def initialize(container:, call:)
13
- @container = container
14
- @call = call
15
- @manifest = container.manifest
16
- @schemas = container.schemas
17
- @events = container.events
18
- end
19
-
20
- def call(pending_key)
21
- guard.for(:reject, pending_key).check!(
22
- Textus::Domain::Policy::Evaluation.new(
23
- actor: @call.role, transition: :reject, origin: pending_key,
24
- target: pending_key, envelope: nil, manifest: @manifest
25
- ),
26
- )
27
-
28
- mentry = @manifest.resolver.resolve(pending_key).entry
29
- unless mentry.in_proposal_zone?(@manifest.policy)
30
- raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
31
- end
32
-
33
- env = Textus::Read::Get.new(
34
- container: @container, call: @call,
35
- ).call(pending_key)
36
- proposal = env.meta&.dig("proposal") or
37
- raise ProposalError.new("entry has no proposal block: #{pending_key}")
38
- target_key = proposal["target_key"] or
39
- raise ProposalError.new("proposal missing target_key")
40
-
41
- delete_op.call(pending_key, suppress_events: true)
42
-
43
- @events.publish(:proposal_rejected,
44
- ctx: hook_context,
45
- key: pending_key,
46
- target_key: target_key)
47
-
48
- { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
49
- end
50
-
51
- private
52
-
53
- def guard
54
- @guard ||= Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
55
- end
56
-
57
- def hook_context
58
- @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
59
- end
60
-
61
- def delete_op
62
- @delete_op ||= Textus::Write::KeyDelete.new(
63
- container: @container, call: @call,
64
- )
65
- end
66
- end
67
- end
68
- end