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,32 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Manifest fingerprint changed mid-session. Client should re-boot.
4
- class ContractDrift < Textus::Error
5
- JSONRPC_CODE = -32_001
6
-
7
- def initialize(message, details: {})
8
- super("contract_drift", message, details: details)
9
- end
10
- end
11
-
12
- # Audit cursor fell off the keep window. Client should re-boot and
13
- # resume from the new latest_seq.
14
- class CursorExpired < Textus::Error
15
- JSONRPC_CODE = -32_002
16
-
17
- def initialize(message, details: {})
18
- super("cursor_expired", message, details: details)
19
- end
20
- end
21
-
22
- # Tool execution failed (validation, authorization, IO). Wraps an
23
- # underlying Textus::Error or generic StandardError.
24
- class ToolError < Textus::Error
25
- JSONRPC_CODE = -32_000
26
-
27
- def initialize(message, details: {})
28
- super("tool_error", message, details: details)
29
- end
30
- end
31
- end
32
- end
@@ -1,138 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module MCP
5
- # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
6
- # message (NDJSON). Holds a single Session for the lifetime of stdin.
7
- class Server
8
- PROTOCOL_VERSION = "2024-11-05"
9
- SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
10
-
11
- def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
12
- @store = store
13
- @stdin = stdin
14
- @stdout = stdout
15
- @role = role
16
- @session = nil
17
- end
18
-
19
- def run
20
- @stdin.each_line do |line|
21
- line = line.strip
22
- next if line.empty?
23
-
24
- handle_line(line)
25
- end
26
- end
27
-
28
- private
29
-
30
- def handle_line(line)
31
- msg = JSON.parse(line)
32
- rescue JSON::ParserError => e
33
- emit_error(nil, -32_700, "parse error: #{e.message}")
34
- else
35
- dispatch(msg)
36
- end
37
-
38
- def dispatch(msg)
39
- rid = msg["id"]
40
- case msg["method"]
41
- when "initialize" then handle_initialize(rid, msg["params"] || {})
42
- when "tools/list" then handle_tools_list(rid)
43
- when "tools/call" then handle_tools_call(rid, msg["params"] || {})
44
- when "ping" then emit_result(rid, {})
45
- when "shutdown" then emit_result(rid, nil)
46
- when "notifications/initialized" then nil
47
- else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
48
- end
49
- end
50
-
51
- def handle_initialize(rid, _params)
52
- # The acting role IS the resolved connection role (ADR 0040): the MCP
53
- # transport defaults to `agent`, which can write the queue, so its
54
- # propose_zone resolves directly. If a connection's role cannot propose,
55
- # propose_zone is nil and the `propose` tool reports that honestly.
56
- propose_zone = @store.manifest.policy.propose_zone_for(@role)
57
-
58
- @session = Session.new(
59
- role: @role,
60
- cursor: @store.audit_log.latest_seq,
61
- propose_zone: propose_zone,
62
- contract_etag: contract_etag,
63
- )
64
-
65
- # ADR 0075: announce the connection to connect-time hooks with the
66
- # resolved role. Distinct from :store_loaded (fired at Store.new under
67
- # the default role, before any connection's role is known).
68
- @store.events.publish(
69
- :session_opened,
70
- ctx: Hooks::Context.new(scope: @store.as(@role)),
71
- role: @role,
72
- cursor: @session.cursor,
73
- )
74
-
75
- emit_result(rid, {
76
- "protocolVersion" => PROTOCOL_VERSION,
77
- "serverInfo" => SERVER_INFO,
78
- "capabilities" => { "tools" => {} },
79
- })
80
- end
81
-
82
- def handle_tools_list(rid)
83
- emit_result(rid, { "tools" => Catalog.tool_schemas })
84
- end
85
-
86
- def handle_tools_call(rid, params)
87
- unless @session
88
- emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
89
- return
90
- end
91
-
92
- name = params["name"]
93
- args = params["arguments"] || {}
94
-
95
- # ADR 0083: the contract-drift guard gates mutating verbs — every MCP
96
- # verb that is NOT a pure read (Write:: + the destructive Maintenance::
97
- # verbs reconcile/zone_mv/key_*_prefix). Reads and boot bypass it (a stale
98
- # read returns on-disk truth; boot re-orients). Keying on read_verbs
99
- # (not write_verbs) keeps the destructive Maintenance:: verbs gated.
100
- @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
101
-
102
- result = Catalog.call(name, session: @session, store: @store, args: args)
103
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
104
- @session = @session.with(contract_etag: contract_etag) if name == "boot"
105
-
106
- emit_result(rid, {
107
- "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
108
- "isError" => false,
109
- })
110
- rescue ContractDrift => e
111
- emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
112
- rescue CursorExpired => e
113
- emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
114
- rescue ToolError => e
115
- emit_error(rid, ToolError::JSONRPC_CODE, e.message)
116
- rescue StandardError => e
117
- emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
118
- end
119
-
120
- def contract_etag
121
- Textus::Etag.for_contract(@store.root)
122
- end
123
-
124
- def emit_result(rid, result)
125
- write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
126
- end
127
-
128
- def emit_error(rid, code, message)
129
- write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
130
- end
131
-
132
- def write(obj)
133
- @stdout.puts(JSON.dump(obj))
134
- @stdout.flush
135
- end
136
- end
137
- end
138
- end
@@ -1,7 +0,0 @@
1
- module Textus
2
- module MCP
3
- # The session value now lives in core (ADR 0036); retained here as an
4
- # alias so existing MCP references keep resolving.
5
- Session = Textus::Session
6
- end
7
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
4
- # per-verb contracts; this delegates to MCP::Catalog. The hand-written
5
- # array is gone — a kwarg rename now updates the schema automatically (and
6
- # the signature guard fails if the contract lags the use-case).
7
- module ToolSchemas
8
- module_function
9
-
10
- def all
11
- Catalog.tool_schemas
12
- end
13
- end
14
- end
15
- end
data/lib/textus/mcp.rb DELETED
@@ -1,6 +0,0 @@
1
- module Textus
2
- # The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
3
- # Wraps Textus::RoleScope as auto-derived tools. See ADR 0015.
4
- module MCP
5
- end
6
- end
@@ -1,117 +0,0 @@
1
- module Textus
2
- module Mustache
3
- MAX_DEPTH = 8
4
- TAG = %r{\{\{(?<sigil>[#^/!&]?)\s*(?<name>[\w.-]+)\s*\}\}}
5
-
6
- def self.render(template, context, strict: false, depth: 0) # rubocop:disable Metrics/AbcSize
7
- raise TemplateError.new("template recursion depth #{depth} exceeded #{MAX_DEPTH}") if depth > MAX_DEPTH
8
-
9
- out = +""
10
- pos = 0
11
- while (m = template.match(TAG, pos))
12
- out << template[pos...m.begin(0)]
13
- case m[:sigil]
14
- when "!"
15
- # comment, skip
16
- when "#"
17
- section, new_pos = parse_section(template, m, m[:name])
18
- value = lookup(context, m[:name])
19
- out << render_section(section, value, context, strict, depth)
20
- pos = new_pos
21
- next
22
- when "^"
23
- section, new_pos = parse_section(template, m, m[:name])
24
- value = lookup(context, m[:name])
25
- if falsy?(value)
26
- raise TemplateError.new("template recursion depth #{depth + 1} exceeded #{MAX_DEPTH}") if depth + 1 > MAX_DEPTH
27
-
28
- out << render(section, context, strict: strict, depth: depth + 1)
29
- end
30
- pos = new_pos
31
- next
32
- when "/"
33
- raise TemplateError.new("unexpected closing tag #{m[:name]}")
34
- else
35
- val = lookup(context, m[:name])
36
- if val.nil?
37
- raise TemplateError.new("missing variable: #{m[:name]}") if strict
38
- else
39
- out << val.to_s
40
- end
41
- end
42
- pos = m.end(0)
43
- end
44
- out << template[pos..]
45
- out
46
- end
47
-
48
- def self.parse_section(template, open_match, name)
49
- open_re = /\{\{#\s*#{Regexp.escape(name)}\s*\}\}|\{\{\^\s*#{Regexp.escape(name)}\s*\}\}/
50
- close_re = %r{\{\{/\s*#{Regexp.escape(name)}\s*\}\}}
51
- both = Regexp.union(open_re, close_re)
52
- depth = 1
53
- cursor = open_match.end(0)
54
- while (m = template.match(both, cursor))
55
- if m[0].start_with?("{{/")
56
- depth -= 1
57
- return [template[open_match.end(0)...m.begin(0)], m.end(0)] if depth.zero?
58
- else
59
- depth += 1
60
- end
61
- cursor = m.end(0)
62
- end
63
- raise TemplateError.new("unclosed section: #{name}")
64
- end
65
-
66
- def self.render_section(section, value, context, strict, depth)
67
- raise TemplateError.new("template recursion depth #{depth + 1} exceeded #{MAX_DEPTH}") if depth + 1 > MAX_DEPTH
68
-
69
- case value
70
- when Array
71
- value.map { |v| render(section, scope_for(context, v), strict: strict, depth: depth + 1) }.join
72
- when Hash
73
- render(section, merge(context, value), strict: strict, depth: depth + 1)
74
- when true
75
- render(section, context, strict: strict, depth: depth + 1)
76
- when false, nil
77
- # falsy in regular section: render nothing.
78
- # render_section is only called for inverted sections when falsy? is true at the call site,
79
- # so this branch is only hit for normal sections with falsy values.
80
- ""
81
- else
82
- render(section, context, strict: strict, depth: depth + 1)
83
- end || ""
84
- end
85
-
86
- def self.lookup(context, name)
87
- # Implicit iterator: {{.}} refers to the current scope itself (used when
88
- # iterating arrays of primitive values).
89
- return context["."] if name == "." && context.is_a?(Hash) && context.key?(".")
90
- return context[name] if context.is_a?(Hash) && context.key?(name)
91
-
92
- name.split(".").reduce(context) do |acc, seg|
93
- return nil unless acc.is_a?(Hash)
94
-
95
- acc[seg]
96
- end
97
- end
98
-
99
- # Build the rendering scope for one iteration of a section. Hash items
100
- # merge into the outer context; primitive items (strings, numbers) bind
101
- # to the implicit iterator under key ".".
102
- def self.scope_for(context, item)
103
- return merge(context, item) if item.is_a?(Hash)
104
-
105
- base = context.is_a?(Hash) ? context : {}
106
- base.merge("." => item)
107
- end
108
-
109
- def self.merge(base, override)
110
- return base unless override.is_a?(Hash)
111
-
112
- base.merge(override)
113
- end
114
-
115
- def self.falsy?(v) = v.nil? || v == false || v == [] || v == ""
116
- end
117
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Ports
5
- # ADR 0093: on a canon write, converge the derived entries that depend on the
6
- # written key (rdeps ∩ derived) by running Produce — scoped + non-destructive.
7
- # This IS reconcile narrowed to a write's blast radius; there is no separate
8
- # "reactive materialize" subsystem. Per-entry source.on_write (sync|async)
9
- # picks inline-under-lock vs deferred. A write INTO a derived entry does not
10
- # fan out (recursion guard). Failures never reach the writer (Produce.converge
11
- # isolates them). Attached at Store boot, alongside AuditSubscriber.
12
- class ProduceOnWriteSubscriber
13
- def initialize(container)
14
- @container = container
15
- end
16
-
17
- def attach(bus)
18
- bus.on(:entry_written, :produce_on_write) do |ctx:, key:, **|
19
- call = Textus::Call.build(role: ctx.role, correlation_id: ctx.correlation_id)
20
- on_write(key: key, call: call)
21
- end
22
- self
23
- end
24
-
25
- def on_write(key:, call:)
26
- return if derived_write?(key) # recursion guard: produce output is not a source change
27
-
28
- affected = Textus::Read::Rdeps.new(container: @container).call(key)["rdeps"]
29
- producible = affected.select { |k| producible?(k) }
30
- return if producible.empty?
31
-
32
- if any_sync?(producible)
33
- Textus::Produce::Engine.converge(container: @container, call: call, keys: producible)
34
- else
35
- Textus::Produce::Engine::AsyncRunner.enqueue(container: @container, call: call, keys: producible)
36
- end
37
- end
38
-
39
- private
40
-
41
- def derived_write?(key)
42
- @container.manifest.resolver.resolve(key).entry.derived?
43
- rescue Textus::Error
44
- false
45
- end
46
-
47
- # The producible scope mirrors Produce::Engine#produce_one: derived
48
- # entries render+publish, and nested publish_tree entries mirror their
49
- # source subtree (ADR 0047). Including the latter restores reactive
50
- # re-mirroring on a write into a tree's source — dropped when the scope
51
- # narrowed to `derived?` only.
52
- def producible?(key)
53
- entry = @container.manifest.resolver.resolve(key).entry
54
- entry.derived? || !entry.publish_tree.nil?
55
- rescue Textus::Error
56
- false
57
- end
58
-
59
- # Only derived entries carry a source with on_write semantics; a nested
60
- # publish_tree entry has no source and defaults to async.
61
- def any_sync?(keys)
62
- keys.any? do |k|
63
- entry = @container.manifest.resolver.resolve(k).entry
64
- entry.derived? && entry.source.sync?
65
- end
66
- end
67
- end
68
- end
69
- end
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Produce
3
- # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5).
4
- # Produce::Acquire::Intake (the ingest executor driven by reconcile + hook) emits through
5
- # this seam so the event names and payload shapes live in one place with one
6
- # derived hook context.
7
- class Events
8
- def self.from(container:, call:)
9
- new(
10
- events: container.events,
11
- hook_context: Textus::Hooks::Context.for(container: container, call: call),
12
- )
13
- end
14
-
15
- def initialize(events:, hook_context:)
16
- @events = events
17
- @hook_context = hook_context
18
- end
19
-
20
- def started(key, mode: :sync)
21
- @events.publish(:entry_fetch_started, ctx: @hook_context, key: key, mode: mode)
22
- end
23
-
24
- def failed(key, error)
25
- @events.publish(:entry_fetch_failed, ctx: @hook_context, key: key,
26
- error_class: error.class.name, error_message: error.message)
27
- end
28
-
29
- def fetched(key, envelope, change)
30
- return if change == :unchanged
31
-
32
- @events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
33
- end
34
- end
35
- end
36
- end
@@ -1,130 +0,0 @@
1
- require "json"
2
- require "time"
3
-
4
- module Textus
5
- module Read
6
- # Queries .textus/.run/audit/audit.log. Filters: key, zone, role, verb, since,
7
- # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
8
- # rows produce nil and are skipped).
9
- class Audit
10
- # Value object that carries all filter parameters for an audit query.
11
- # `matches?` checks the manifest-independent predicates so the loop body
12
- # only needs to handle the zone check (which requires manifest access).
13
- Query = Data.define(:key, :zone, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
14
- # rubocop:disable Metrics/ParameterLists
15
- def self.build(key: nil, zone: nil, role: nil, verb: nil,
16
- since: nil, seq_since: nil, correlation_id: nil, limit: nil)
17
- new(key:, zone:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
18
- end
19
- # rubocop:enable Metrics/ParameterLists
20
-
21
- def matches?(row)
22
- return false if key && row["key"] != key
23
- return false if role && row["role"] != role
24
- return false if verb && row["verb"] != verb
25
- return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
26
- return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
27
- return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
28
-
29
- true
30
- end
31
- end
32
-
33
- extend Textus::Contract::DSL
34
-
35
- verb :audit
36
- summary "Query the audit log with optional filters."
37
- surfaces :cli
38
- cli "audit"
39
- # #call(**filters) — args map to Query.build keyword params (ADR 0063)
40
- arg :key, String, required: false, description: "filter to rows for this key"
41
- arg :zone, String, required: false, description: "filter to keys in this zone"
42
- arg :role, String, required: false, description: "filter to rows written under this role"
43
- arg :verb, String, required: false, description: "filter to rows for this verb"
44
- arg :since, String, required: false,
45
- coerce: ->(s) { Textus::Read::Audit.parse_since(s, now: Time.now) },
46
- description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
47
- arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
48
- arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
49
- arg :limit, Integer, required: false, description: "maximum number of rows to return"
50
- view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
51
-
52
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
53
- @manifest = container.manifest
54
- @root = container.root
55
- @log_path = Textus::Layout.audit_log(container.root)
56
- @audit_log = container.audit_log
57
- end
58
-
59
- def call(**filters)
60
- query = Query.build(**filters)
61
- check_cursor_expiry!(query.seq_since)
62
-
63
- files = all_log_files
64
- return [] if files.empty?
65
-
66
- rows = []
67
- files.each do |file|
68
- File.foreach(file) do |line|
69
- parsed = parse_row(line.chomp)
70
- next unless parsed
71
- next unless query.matches?(parsed)
72
- next if query.zone && !key_in_zone?(parsed["key"], query.zone)
73
-
74
- rows << parsed
75
- break if limit_reached?(rows, query)
76
- end
77
- break if limit_reached?(rows, query)
78
- end
79
- rows
80
- end
81
-
82
- # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
83
- # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
84
- def self.parse_since(str, now: Time.now.utc)
85
- return nil if str.nil? || str.empty?
86
- return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
87
-
88
- m = str.match(/\A(\d+)([smhd])\z/) or return nil
89
- mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
90
- now - (m[1].to_i * mult)
91
- end
92
-
93
- private
94
-
95
- def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
96
-
97
- def check_cursor_expiry!(seq_since)
98
- return unless seq_since
99
-
100
- log = @audit_log || Textus::Ports::AuditLog.new(@root)
101
- min = log.min_available_seq
102
- raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
103
- end
104
-
105
- def all_log_files
106
- rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
107
- .reject { |p| p.end_with?(".meta.json") }
108
- .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
109
- active = File.exist?(@log_path) ? [@log_path] : []
110
- rotated + active
111
- end
112
-
113
- def parse_row(line)
114
- return nil if line.empty?
115
- return nil unless line.start_with?("{")
116
-
117
- JSON.parse(line)
118
- rescue JSON::ParserError
119
- nil
120
- end
121
-
122
- def key_in_zone?(key, zone)
123
- mentry = @manifest.resolver.resolve(key).entry
124
- mentry && mentry.zone == zone
125
- rescue Textus::Error
126
- false
127
- end
128
- end
129
- end
130
- end
@@ -1,26 +0,0 @@
1
- module Textus
2
- module Read
3
- # Dispatched use case for the `boot` verb. The orientation envelope is
4
- # built by the Textus::Boot library module; this class is the uniform
5
- # (container:, call:) entry point that Dispatcher::VERBS resolves to.
6
- # Boot is role-independent, so `call` is not consulted.
7
- class Boot
8
- extend Textus::Contract::DSL
9
-
10
- verb :boot
11
- summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
12
- surfaces :cli, :mcp
13
- arg :lean, :boolean,
14
- description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
15
-
16
- def initialize(container:, call:)
17
- @container = container
18
- @call = call
19
- end
20
-
21
- def call(lean: false)
22
- Textus::Boot.build(container: @container, lean: lean)
23
- end
24
- end
25
- end
26
- end
@@ -1,70 +0,0 @@
1
- module Textus
2
- module Read
3
- # A machine-readable projection of the contract surface: every verb, the
4
- # transports it reaches, and its full argument schema — sourced from the
5
- # same Contract DSL the CLI/MCP/boot already project from (ADR 0039/0063).
6
- #
7
- # Integrators assert their docs against this in CI so they can't drift
8
- # (#161 F4 — patrick-nexus docs claimed "MCP exposes 3 verbs" while ~20 are
9
- # surfaced). It also makes the per-surface `dry_run` default asymmetry
10
- # (#161 F6) self-documenting: each arg carries both `default` (agent wire)
11
- # and `cli_default` (CLI), so the divergence is visible, not folklore.
12
- #
13
- # Pure contract introspection — it reads no store data; `container` is
14
- # accepted only for the uniform use-case constructor.
15
- class Capabilities
16
- extend Textus::Contract::DSL
17
-
18
- verb :capabilities
19
- summary "Machine-readable contract surface: every verb, its transports, and arg schema."
20
- surfaces :cli, :mcp
21
- arg :verb, String, required: false, description: "filter to a single verb by name"
22
- view { |result, _i| result }
23
-
24
- def initialize(container: nil, call: nil); end
25
-
26
- def call(verb: nil)
27
- klasses = Textus::Dispatcher::VERBS.values.select { |k| contract?(k) }
28
- rows = klasses.map { |k| project(k.contract) }
29
- rows.select! { |r| r["verb"] == verb } if verb
30
- { "verbs" => rows.sort_by { |r| r["verb"] } }
31
- end
32
-
33
- private
34
-
35
- def contract?(klass)
36
- klass.respond_to?(:contract?) && klass.contract?
37
- end
38
-
39
- def project(spec)
40
- {
41
- "verb" => spec.verb.to_s,
42
- "summary" => spec.summary,
43
- "surfaces" => spec.surfaces.map(&:to_s) + ["ruby"],
44
- "cli" => spec.cli? ? spec.cli_path : nil,
45
- "args" => spec.args.map { |a| project_arg(a) },
46
- }
47
- end
48
-
49
- def project_arg(arg)
50
- out = {
51
- "name" => arg.wire.to_s,
52
- "type" => json_type(arg.type),
53
- "required" => arg.required,
54
- "positional" => arg.positional,
55
- }
56
- out["description"] = arg.description if arg.description
57
- out["default"] = arg.default unless arg.default.nil?
58
- out["cli_default"] = arg.cli_default unless arg.cli_default == :__unset
59
- out["session_default"] = arg.session_default.to_s if arg.session_default
60
- out
61
- end
62
-
63
- def json_type(type)
64
- Textus::Contract.json_type(type)
65
- rescue ArgumentError
66
- "string"
67
- end
68
- end
69
- end
70
- end