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
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Gate
5
+ class Auth
6
+ FLOOR = {
7
+ put: %w[lane_writable_by raw_lane_ingest_only],
8
+ key_delete: %w[lane_deletable_by],
9
+ key_mv: %w[lane_writable_by raw_lane_ingest_only],
10
+ accept: %w[author_held],
11
+ reject: %w[author_held],
12
+ propose: %w[lane_writable_by raw_lane_ingest_only],
13
+ key_mv_prefix: %w[lane_writable_by raw_lane_ingest_only],
14
+ key_delete_prefix: %w[lane_writable_by raw_lane_ingest_only],
15
+ ingest: %w[lane_writable_by raw_write_once],
16
+ }.freeze
17
+
18
+ AuthContext = Struct.new(
19
+ :actor, :actor_caps, :lane_verb,
20
+ :action, :target, :envelope,
21
+ :mentry, :manifest,
22
+ keyword_init: true
23
+ )
24
+
25
+ def initialize(container)
26
+ @manifest = container.manifest
27
+ @schemas = container.schemas
28
+ end
29
+
30
+ # Command-based check (new Gate path).
31
+ def check!(cmd)
32
+ key = extract_key(cmd)
33
+ return unless key
34
+
35
+ evaluate_predicates(
36
+ action: command_to_action(cmd),
37
+ actor: cmd.role.to_s,
38
+ key: key,
39
+ envelope: nil,
40
+ extra: {},
41
+ )
42
+ end
43
+
44
+ # Backward-compatible check for inline action auth (accept, put, etc.).
45
+ def check_action!(action:, actor:, key:, envelope: nil, extra: {})
46
+ evaluate_predicates(
47
+ action: action.to_sym,
48
+ actor: actor,
49
+ key: key,
50
+ envelope: envelope,
51
+ extra: extra,
52
+ )
53
+ end
54
+
55
+ def self.command_to_verb
56
+ @command_to_verb ||= Textus::Gate::VERB_COMMAND.invert.freeze
57
+ end
58
+
59
+ private
60
+
61
+ def command_to_action(cmd)
62
+ self.class.command_to_verb.fetch(cmd.class) do
63
+ raise Textus::UsageError.new("unmapped command: #{cmd.class}")
64
+ end
65
+ end
66
+
67
+ def evaluate_predicates(action:, actor:, key:, envelope:, extra:)
68
+ mentry = @manifest.resolver.resolve(key).entry
69
+ lane_verb = @manifest.policy.verb_for_lane(mentry.lane.to_s)
70
+ actor_caps = Set.new(@manifest.data.role_caps.fetch(actor, []))
71
+
72
+ ctx = AuthContext.new(
73
+ actor:, actor_caps:, lane_verb:,
74
+ action:, target: key, envelope:,
75
+ mentry:, manifest: @manifest
76
+ )
77
+
78
+ failures = []
79
+ floor_preds = FLOOR.fetch(action, [])
80
+ rule_preds = rule_declared_predicates(action, key)
81
+ (floor_preds + rule_preds).uniq.each do |pred|
82
+ result = evaluate(pred, ctx, extra)
83
+ next if result[:pass]
84
+ raise result[:error] if result[:error]
85
+
86
+ failures << [pred, result[:reason]]
87
+ end
88
+ raise Textus::GuardFailed.new(failures) unless failures.empty?
89
+ end
90
+
91
+ def extract_key(cmd)
92
+ if cmd.respond_to?(:pending_key)
93
+ cmd.pending_key
94
+ elsif cmd.respond_to?(:key)
95
+ cmd.key
96
+ end
97
+ end
98
+
99
+ def rule_declared_predicates(action, key)
100
+ guard_map = @manifest.rules.for(key).guard
101
+ return [] if guard_map.nil?
102
+
103
+ Array(guard_map[action.to_s])
104
+ end
105
+
106
+ def evaluate(pred_name, ctx, extra)
107
+ case pred_name
108
+ when "lane_writable_by" then evaluate_lane_writable_by(ctx)
109
+ when "author_held" then evaluate_author_held(ctx)
110
+ when "target_is_canon" then evaluate_target_is_canon(ctx)
111
+ when "etag_match" then evaluate_etag_match(ctx, extra)
112
+ when "schema_valid" then evaluate_schema_valid(ctx)
113
+ when "fresh_within" then { pass: true }
114
+ when "raw_lane_ingest_only" then evaluate_raw_lane_ingest_only(ctx)
115
+ when "raw_write_once" then evaluate_raw_write_once(ctx)
116
+ when "lane_deletable_by" then evaluate_lane_deletable_by(ctx)
117
+ else raise Textus::UsageError.new("unknown predicate '#{pred_name}'")
118
+ end
119
+ end
120
+
121
+ def evaluate_lane_writable_by(ctx)
122
+ pass = ctx.actor_caps.include?(ctx.lane_verb.to_s)
123
+ return { pass: true } if pass
124
+
125
+ holders = @manifest.policy.roles_with_capability(ctx.lane_verb.to_s)
126
+ { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane, verb: ctx.lane_verb, holders:) }
127
+ end
128
+
129
+ def evaluate_author_held(ctx)
130
+ holders = @manifest.policy.roles_with_capability("author")
131
+ pass = holders.include?(ctx.actor.to_s)
132
+ reason = if pass
133
+ nil
134
+ elsif holders.empty?
135
+ "no role holds the 'author' capability; #{ctx.action} is disabled"
136
+ else
137
+ "role '#{ctx.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
138
+ end
139
+ { pass:, reason: }
140
+ end
141
+
142
+ def evaluate_target_is_canon(ctx)
143
+ kind = @manifest.policy.declared_kind(ctx.mentry.lane.to_s)
144
+ pass = kind == :canon
145
+ { pass:, reason: pass ? nil : "target lane '#{ctx.mentry.lane}' is not canon (kind: #{kind})" }
146
+ end
147
+
148
+ def evaluate_etag_match(ctx, extra)
149
+ if_etag = extra[:if_etag]
150
+ return { pass: true } if if_etag.nil?
151
+
152
+ current = ctx.envelope&.etag
153
+ pass = current.nil? || current == if_etag
154
+ { pass:, error: pass ? nil : Textus::EtagMismatch.new(ctx.target, if_etag, current) }
155
+ end
156
+
157
+ def evaluate_schema_valid(ctx)
158
+ return { pass: true } unless ctx.envelope
159
+
160
+ schema_ref = ctx.mentry.schema
161
+ return { pass: true } unless schema_ref
162
+
163
+ schema = @schemas.fetch_or_nil(schema_ref)
164
+ return { pass: true } unless schema
165
+
166
+ frontmatter = ctx.envelope.meta&.dig("_meta") || ctx.envelope.meta || {}
167
+ begin
168
+ schema.validate!(frontmatter)
169
+ { pass: true }
170
+ rescue Textus::SchemaViolation => e
171
+ { pass: false, reason: schema_reason(e) }
172
+ end
173
+ end
174
+
175
+ def evaluate_raw_lane_ingest_only(ctx)
176
+ return { pass: true } unless @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
177
+ return { pass: true } if ctx.action == :ingest
178
+
179
+ { pass: false, error: Textus::Error.new(
180
+ :raw_lane_ingest_only,
181
+ "raw lane '#{ctx.mentry.lane}' only accepts `textus ingest` — " \
182
+ "use that verb instead of '#{ctx.action}'",
183
+ ) }
184
+ end
185
+
186
+ def evaluate_raw_write_once(ctx)
187
+ path = @manifest.resolver.resolve(ctx.target).path
188
+ return { pass: true } unless File.exist?(path)
189
+
190
+ { pass: false, error: Textus::Error.new(
191
+ :raw_write_once,
192
+ "raw entry '#{ctx.target}' already exists; " \
193
+ "delete it first (`textus key-delete #{ctx.target}`), then re-ingest",
194
+ ) }
195
+ end
196
+
197
+ # Deletion authority: the lane's write capability OR the author capability.
198
+ # On raw-kind lanes only the author capability grants deletion (correction
199
+ # escape hatch); the lane's own verb (ingest) is write-only. On all other
200
+ # lane kinds the behaviour matches lane_writable_by — the lane's writer
201
+ # can delete as before.
202
+ def evaluate_lane_deletable_by(ctx)
203
+ is_raw = @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
204
+ pass = if is_raw
205
+ ctx.actor_caps.include?("author")
206
+ else
207
+ ctx.actor_caps.include?(ctx.lane_verb.to_s) || ctx.actor_caps.include?("author")
208
+ end
209
+ return { pass: true } if pass
210
+
211
+ extra_holders = is_raw ? ["author"] : [ctx.lane_verb.to_s, "author"]
212
+ holders = extra_holders.flat_map { |v| @manifest.policy.roles_with_capability(v) }.uniq
213
+ { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane,
214
+ verb: ctx.lane_verb, holders:) }
215
+ end
216
+
217
+ def schema_reason(err)
218
+ d = err.details
219
+ return err.message.dup unless d.is_a?(Hash)
220
+ return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
221
+ return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
222
+
223
+ err.message.dup
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Gate
5
+ VERB_COMMAND = {
6
+ get: Textus::Command::Get,
7
+ put: Textus::Command::Put,
8
+ propose: Textus::Command::Propose,
9
+ key_delete: Textus::Command::KeyDelete,
10
+ key_mv: Textus::Command::KeyMv,
11
+ accept: Textus::Command::Accept,
12
+ reject: Textus::Command::Reject,
13
+ enqueue: Textus::Command::Enqueue,
14
+ list: Textus::Command::List,
15
+ where: Textus::Command::Where,
16
+ uid: Textus::Command::Uid,
17
+ blame: Textus::Command::Blame,
18
+ audit: Textus::Command::Audit,
19
+ deps: Textus::Command::Deps,
20
+ rdeps: Textus::Command::Rdeps,
21
+ pulse: Textus::Command::Pulse,
22
+ rule_explain: Textus::Command::RuleExplain,
23
+ rule_list: Textus::Command::RuleList,
24
+ rule_lint: Textus::Command::RuleLint,
25
+ published: Textus::Command::Published,
26
+ schema_show: Textus::Command::SchemaShow,
27
+ doctor: Textus::Command::Doctor,
28
+ boot: Textus::Command::Boot,
29
+ jobs: Textus::Command::Jobs,
30
+ data_mv: Textus::Command::DataMv,
31
+ key_mv_prefix: Textus::Command::KeyMvPrefix,
32
+ key_delete_prefix: Textus::Command::KeyDeletePrefix,
33
+ drain: Textus::Command::Drain,
34
+ ingest: Textus::Command::Ingest,
35
+ }.freeze
36
+
37
+ ROUTES = {
38
+ Command::Get => [Textus::Action::Get],
39
+ Command::Put => [Textus::Action::Put],
40
+ Command::Propose => [Textus::Action::Propose],
41
+ Command::KeyDelete => [Textus::Action::KeyDelete],
42
+ Command::KeyMv => [Textus::Action::KeyMv],
43
+ Command::Accept => [Textus::Action::Accept],
44
+ Command::Reject => [Textus::Action::Reject],
45
+ Command::Enqueue => [Textus::Action::Enqueue],
46
+ Command::List => [Textus::Action::List],
47
+ Command::Where => [Textus::Action::Where],
48
+ Command::Uid => [Textus::Action::Uid],
49
+ Command::Blame => [Textus::Action::Blame],
50
+ Command::Audit => [Textus::Action::Audit],
51
+ Command::Deps => [Textus::Action::Deps],
52
+ Command::Rdeps => [Textus::Action::Rdeps],
53
+ Command::Pulse => [Textus::Action::Pulse],
54
+ Command::RuleExplain => [Textus::Action::RuleExplain],
55
+ Command::RuleList => [Textus::Action::RuleList],
56
+ Command::RuleLint => [Textus::Action::RuleLint],
57
+ Command::Published => [Textus::Action::Published],
58
+ Command::SchemaShow => [Textus::Action::SchemaEnvelope],
59
+ Command::Doctor => [Textus::Action::Doctor],
60
+ Command::Boot => [Textus::Action::Boot],
61
+ Command::Jobs => [Textus::Action::Jobs],
62
+ Command::DataMv => [Textus::Action::DataMv],
63
+ Command::KeyMvPrefix => [Textus::Action::KeyMvPrefix],
64
+ Command::KeyDeletePrefix => [Textus::Action::KeyDeletePrefix],
65
+ Command::Drain => [Textus::Action::Drain],
66
+ Command::Ingest => [Textus::Action::Ingest],
67
+ }.freeze
68
+
69
+ def initialize(container)
70
+ @container = container
71
+ end
72
+
73
+ def dispatch(cmd, correlation_id: nil)
74
+ cmd = normalize_propose_key(cmd, @container) if cmd.is_a?(Command::Propose)
75
+ action_classes = ROUTES.fetch(cmd.class) do
76
+ raise Textus::UsageError.new("unknown command: #{cmd.class}")
77
+ end
78
+
79
+ Gate::Auth.new(@container).check!(cmd)
80
+ call_obj = build_call(cmd, correlation_id: correlation_id)
81
+ results = action_classes.map { |klass| run_action(klass, cmd, @container, call_obj) }
82
+ results.length == 1 ? results.first : results
83
+ end
84
+
85
+ private
86
+
87
+ def normalize_propose_key(cmd, container)
88
+ return cmd if cmd.pending_key
89
+
90
+ zone = container.manifest.policy.propose_lane_for(cmd.role.to_s)
91
+ cmd.with(pending_key: zone ? "#{zone}.#{cmd.key}" : nil)
92
+ end
93
+
94
+ def run_action(klass, cmd, container, call_obj)
95
+ action = klass.new(**extract_kwargs(klass, cmd))
96
+ action.call(container:, call: call_obj)
97
+ end
98
+
99
+ def extract_kwargs(klass, cmd)
100
+ params = klass.instance_method(:initialize).parameters
101
+ accepts_keyrest = params.any? { |t, _| t == :keyrest }
102
+ param_set = params.to_set { |_t, n| n }
103
+ cmd.members.each_with_object({}) do |m, h|
104
+ next unless accepts_keyrest || param_set.include?(m)
105
+
106
+ val = cmd.public_send(m)
107
+ h[m] = val unless val.nil?
108
+ end
109
+ end
110
+
111
+ def build_call(cmd, correlation_id: nil)
112
+ dry_run = cmd.respond_to?(:dry_run) ? !cmd.dry_run.nil? : false
113
+ Textus::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
114
+ end
115
+ end
116
+ end
data/lib/textus/init.rb CHANGED
@@ -3,7 +3,7 @@ require "pathname"
3
3
 
4
4
  module Textus
5
5
  module Init
6
- ZONES = %w[knowledge notebook proposals artifacts].freeze
6
+ ZONES = %w[knowledge notebook proposals artifacts raw].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
9
  version: textus/3
@@ -11,143 +11,97 @@ module Textus
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
13
13
  - { name: automation, can: [converge] }
14
- zones:
14
+ lanes:
15
15
  - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
16
16
  - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
17
17
  - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
18
18
  - { name: artifacts, kind: machine, desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
19
19
  entries:
20
- - { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
21
- - { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
- - { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
- - { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
24
- # A per-host snapshot, refreshed from its declared intake by `textus drain` (scheduled, or on demand).
25
- # Nested so it grows to a fleet — add artifacts.feeds.machines.<host> leaves over SSH
26
- # (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
20
+ - { key: knowledge.identity, path: data/knowledge/identity.md, lane: knowledge, schema: null, owner: human:self, kind: leaf }
21
+ - { key: knowledge.notes, path: data/knowledge/notes, lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
+ - { key: notebook.notes, path: data/notebook/notes, lane: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
+ - { key: proposals.notes, path: data/proposals/notes, lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
24
+ # A per-host snapshot, populated by a registered workflow. Nested so it
25
+ # grows to a fleet — add leaves over SSH without renaming. tracked:false →
27
26
  # gitignored (machine info can be sensitive/noisy) but still protocol-readable
28
27
  # via `textus get artifacts.feeds.machines.local`. Delete to opt out. (ADR 0043)
29
28
  - key: artifacts.feeds.machines
30
- path: artifacts/feeds/machines
31
- zone: artifacts
29
+ path: data/artifacts/feeds/machines
30
+ lane: artifacts
32
31
  format: yaml
33
32
  nested: true
34
33
  tracked: false
35
- kind: produced
36
- source:
37
- from: handler
38
- handler: machines
39
- ttl: 1h # cadence on a long-running server
40
- config:
41
- machines:
42
- local: { via: local }
34
+ kind: nested
43
35
  rules: []
44
36
  YAML
45
37
 
46
- HOOKS_README = <<~MD
47
- # Hooks
48
-
49
- Drop one Ruby file per hook. All hooks register through one DSL.
50
- Files anywhere under `.textus/hooks/` (including subdirectories) are loaded at
51
- startup in alphabetical order by full path. Subdirectory names are organizational
52
- only — the registered event and name come from the DSL call, not the file path.
53
-
54
- ## DSL
55
-
56
- ```ruby
57
- Textus.hook do |reg|
58
- reg.on(:resolve_handler, :my_source) do |config:, args:, **|
59
- { _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
60
- end
61
-
62
- reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
63
- reg.on(:validate, :my_check) { |caps:, **| [] }
64
- reg.on(:entry_written, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
65
-
66
- # Run a side-effect every time textus writes a file to your repo:
67
- reg.on(:entry_published, :notify) do |key:, target:, **|
68
- warn "wrote \#{target} (from \#{key})"
69
- end
70
- end
71
- ```
72
-
73
- The intake handler above is paired with a manifest entry whose
74
- `source:` block declares the handler and its refresh cadence
75
- (`ttl`). Age GC (drop/archive) lives in a top-level `retention:`
76
- rule, not on the entry:
77
-
78
- ```yaml
79
- entries:
80
- - key: artifacts.feeds.foo
81
- kind: produced
82
- path: artifacts/feeds/foo.md
83
- zone: artifacts
84
- source:
85
- from: handler
86
- handler: my_source
87
- ttl: 10m # refresh cadence for this intake
88
-
89
- rules:
90
- - match: artifacts.feeds.foo
91
- retention:
92
- ttl: 30d
93
- action: archive # drop | archive (age GC of stored rows)
94
- ```
95
-
96
- Events: :resolve_handler, :transform_rows, :validate (rpc — return value used)
97
- :entry_written, :entry_deleted, :entry_fetched, :entry_renamed,
98
- :entry_produced, :produce_failed,
99
- :proposal_accepted, :proposal_rejected,
100
- :entry_published, :store_loaded, :session_opened,
101
- :entry_fetch_started, :entry_fetch_failed (pub-sub — return discarded)
102
-
103
- See SPEC.md §5.10 for the full table.
104
- MD
105
-
106
38
  AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
107
- # --with-agent profile: project facts + runbooks feed the orientation
108
- # projection below, which `textus drain` renders to CLAUDE.md/AGENTS.md.
109
- - { key: knowledge.project, path: knowledge/project.md, zone: knowledge, schema: project, owner: human:self, kind: leaf }
110
- - { key: knowledge.runbooks, path: knowledge/runbooks, zone: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
39
+ - { key: knowledge.project, path: data/knowledge/project.md, lane: knowledge, schema: project, owner: human:self, kind: leaf }
40
+ - { key: knowledge.runbooks, path: data/knowledge/runbooks, lane: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
111
41
  - key: artifacts.derived.orientation
112
- path: artifacts/derived/orientation.json
113
- zone: artifacts
42
+ path: data/artifacts/derived/orientation.json
43
+ lane: artifacts
114
44
  publish:
115
45
  - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
116
46
  - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
117
- source:
118
- from: project
119
- select:
120
- - knowledge.project
121
- - knowledge.runbooks
122
- transform: orientation_reducer
123
47
  kind: produced
124
48
  YAML
125
49
 
126
50
  def self.run(target_root, with_agent: false)
51
+ check_target!(target_root)
52
+ scaffold_dir = File.expand_path("init/templates", __dir__)
53
+ create_directories(target_root)
54
+ write_manifest(target_root, with_agent:)
55
+ mcp_status = scaffold_agent(target_root, scaffold_dir, with_agent:)
56
+ setup_state_dirs(target_root)
57
+ write_gitignore(target_root)
58
+ build_result(target_root, with_agent:, mcp_status:)
59
+ end
60
+
61
+ def self.check_target!(target_root)
127
62
  raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
63
+ end
64
+
65
+ ASSET_ZONES = %w[raw].freeze
128
66
 
67
+ def self.create_directories(target_root)
129
68
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
130
69
  FileUtils.mkdir_p(File.join(target_root, "templates"))
131
- FileUtils.mkdir_p(File.join(target_root, "hooks"))
70
+ FileUtils.mkdir_p(File.join(target_root, "workflows"))
132
71
  ZONES.each do |z|
133
- dir = File.join(target_root, "zones", z)
72
+ dir = File.join(target_root, "data", z)
134
73
  FileUtils.mkdir_p(dir)
135
74
  File.write(File.join(dir, ".gitkeep"), "")
136
75
  end
137
- File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
138
- scaffold_dir = File.expand_path("init/templates", __dir__)
139
- File.write(File.join(target_root, "hooks", "machine_intake.rb"),
140
- File.read(File.join(scaffold_dir, "machine_intake.rb")))
141
- File.write(File.join(target_root, "manifest.yaml"), manifest_yaml(with_agent: with_agent))
142
- mcp_status = nil
143
- if with_agent
144
- scaffold_agent_profile(target_root, scaffold_dir)
145
- mcp_status = write_mcp_config(target_root, scaffold_dir)
76
+ ASSET_ZONES.each do |z|
77
+ dir = File.join(target_root, "assets", z)
78
+ FileUtils.mkdir_p(dir)
79
+ File.write(File.join(dir, ".gitkeep"), "")
146
80
  end
81
+ end
82
+
83
+ def self.write_manifest(target_root, with_agent:)
84
+ File.write(File.join(target_root, "manifest.yaml"), manifest_yaml(with_agent: with_agent))
85
+ end
86
+
87
+ def self.scaffold_agent(target_root, scaffold_dir, with_agent:)
88
+ return nil unless with_agent
89
+
90
+ scaffold_agent_profile(target_root, scaffold_dir)
91
+ write_mcp_config(target_root, scaffold_dir)
92
+ end
93
+
94
+ def self.setup_state_dirs(target_root)
147
95
  FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
148
96
  FileUtils.mkdir_p(Textus::Layout.state(target_root))
149
97
  FileUtils.mkdir_p(Textus::Layout.locks(target_root))
98
+ end
99
+
100
+ def self.write_gitignore(target_root)
150
101
  File.write(File.join(target_root, ".gitignore"), derived_gitignore(target_root))
102
+ end
103
+
104
+ def self.build_result(target_root, with_agent:, mcp_status:)
151
105
  result = { "protocol" => PROTOCOL, "initialized" => target_root, "profile" => with_agent ? "agent" : "default" }
152
106
  result["mcp_config"] = mcp_status if with_agent
153
107
  result
@@ -168,7 +122,6 @@ module Textus
168
122
  "project.schema.yaml" => File.join("schemas", "project.yaml"),
169
123
  "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
170
124
  "orientation.mustache" => File.join("templates", "orientation.mustache"),
171
- "orientation_reducer.rb" => File.join("hooks", "orientation_reducer.rb"),
172
125
  }.each do |src, dest|
173
126
  File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
174
127
  end
@@ -193,8 +146,9 @@ module Textus
193
146
  manifest = Textus::Manifest.load(target_root)
194
147
  root = Pathname.new(target_root)
195
148
  untracked = manifest.data.entries.reject(&:tracked?).map do |e|
196
- if e.nested? # a whole subtree of leaf files (artifacts.feeds.machines.* → zones/artifacts/feeds/machines/)
197
- "#{File.join("zones", e.path)}/"
149
+ if e.nested? # a whole subtree of leaf files (artifacts.feeds.machines.* → data/artifacts/feeds/machines/)
150
+ rel = e.path.start_with?("data/") ? e.path : File.join("data", e.path)
151
+ "#{rel}/"
198
152
  else
199
153
  Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
200
154
  end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ module Jobs
3
+ class Base
4
+ def self.inherited(subclass)
5
+ super
6
+ return unless subclass.name
7
+
8
+ TracePoint.new(:end) do |tp|
9
+ if tp.self == subclass
10
+ Textus::Jobs.register(subclass)
11
+ tp.disable
12
+ end
13
+ end.enable
14
+ end
15
+
16
+ def call(**)
17
+ raise NotImplementedError.new("#{self.class}#call")
18
+ end
19
+
20
+ def args = {}
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Jobs
5
+ class Materialize < Base
6
+ TYPE = "materialize"
7
+
8
+ def initialize(key:)
9
+ super()
10
+ @key = key
11
+ end
12
+
13
+ def args = { key: @key }
14
+
15
+ def call(container:, call:)
16
+ Textus::Produce::Engine.converge(container: container, call: call, keys: [@key])
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ module Jobs
3
+ Plan = Data.define(:steps, :warnings) do
4
+ def to_h
5
+ { "steps" => steps, "warnings" => warnings }
6
+ end
7
+ end
8
+ end
9
+ end