textus 0.54.2 → 0.55.2

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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
data/lib/textus/boot.rb CHANGED
@@ -57,12 +57,8 @@ module Textus
57
57
  { "name" => "pulse" },
58
58
  ].freeze
59
59
 
60
- # verb token => contract.summary, for every Dispatcher verb that carries a
61
- # contract. The single source for a verb's one-line summary (ADR 0039).
62
60
  def self.contract_summaries
63
- Textus::Action::VERBS.values
64
- .select { |k| k.respond_to?(:contract?) && k.contract? }
65
- .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
61
+ Textus::VerbRegistry.registered.to_h { |s| [s.verb.to_s, s.summary] }
66
62
  end
67
63
 
68
64
  # Build the CLI verb catalog: each summary is derived from its contract when
@@ -76,69 +72,70 @@ module Textus
76
72
  end
77
73
  end
78
74
 
79
- def self.agent_quickstart(manifest, audit_log)
80
- agent_role = manifest.policy.proposer_role
75
+ def self.build(container:)
76
+ etag = Textus::Value::Etag.for_contract(container.root)
77
+ latest_seq = container.audit_log.latest_seq
78
+ artifact = read_artifact_content(container, "artifacts.boot")
79
+ context = read_boot_context(container)
80
+
81
+ # Prefer pre-computed artifact (drain computes, boot reads).
82
+ # Fall back to inline manifest projection for stores that have not yet
83
+ # run drain (test fixtures, fresh inits).
84
+ stable = artifact || inline_boot_content(container.manifest, latest_seq)
85
+
86
+ if stable["agent_quickstart"]
87
+ stable = stable.merge(
88
+ "agent_quickstart" => stable["agent_quickstart"].merge("latest_seq" => latest_seq),
89
+ )
90
+ end
91
+
92
+ payload = {
93
+ "protocol" => PROTOCOL_ID,
94
+ "store_root" => container.root,
95
+ "contract_etag" => etag,
96
+ }.merge(stable)
97
+ payload["context"] = context if context
98
+ payload
99
+ end
81
100
 
82
- writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |lane_name, acc|
101
+ def self.inline_boot_content(manifest, _latest_seq)
102
+ agent_role = manifest.policy.proposer_role
103
+ writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |ln, acc|
83
104
  next unless agent_role
84
105
 
85
- verb = manifest.policy.verb_for_lane(lane_name)
106
+ verb = manifest.policy.verb_for_lane(ln)
86
107
  writers = manifest.policy.roles_with_capability(verb)
87
- acc << lane_name if writers.include?(agent_role)
108
+ acc << ln if writers.include?(agent_role)
88
109
  end
89
110
 
90
- propose_lane = manifest.policy.propose_lane_for(agent_role)
91
-
92
- {
93
- "read_verbs" => Textus::Surfaces::MCP::Catalog.read_verbs,
94
- "write_verbs" => agent_role ? Textus::Surfaces::MCP::Catalog.write_verbs : [],
95
- "writable_lanes" => writable_lanes,
96
- "propose_lane" => propose_lane,
97
- "latest_seq" => audit_log.latest_seq,
98
- }
99
- end
100
-
101
- def self.recipes(manifest)
102
- queue = manifest.policy.queue_lane
103
- feeds = lane_label(manifest, :machine, "the machine lane")
104
111
  {
105
- "read" => {
106
- "purpose" => "find and read an entry",
107
- "steps" => [
108
- "list (lane:, prefix:) discover keys without reading bodies",
109
- "get KEY — returns the entry envelope",
110
- ],
111
- },
112
- "write" => {
113
- "purpose" => "create or update an entry",
114
- "steps" => [
115
- "schema KEY — learn the _meta field shape (required, optional, field types) before writing",
116
- "assemble an envelope: { _meta: {…}, body: \"…\" }",
117
- "put KEY — persist it (role-gated); pass if_etag to guard a concurrent edit",
118
- ],
119
- },
120
- "propose" => {
121
- "purpose" => "agent suggests a change for human review",
122
- "agent_steps" => [
123
- "propose KEY — writes the change into the #{queue} lane for review",
124
- ],
125
- "human_steps" => [
126
- "accept #{queue}.KEY — promotes the proposal into its target lane",
127
- ],
128
- },
129
- "drain" => {
130
- "purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
131
- "steps" => [
132
- "pulse — its `stale` list names entries past their ttl",
133
- "drain (lane: #{feeds}) — re-pull the stale entries",
134
- ],
112
+ "lanes" => lanes_for(manifest),
113
+ "agent_quickstart" => {
114
+ "read_verbs" => Textus::Surface::MCP::Catalog.read_verbs,
115
+ "write_verbs" => agent_role ? Textus::Surface::MCP::Catalog.write_verbs : [],
116
+ "writable_lanes" => writable_lanes,
117
+ "propose_lane" => manifest.policy.propose_lane_for(agent_role),
135
118
  },
119
+ "agent_protocol" => agent_protocol(manifest),
136
120
  }
137
121
  end
138
122
 
139
123
  def self.agent_protocol(manifest)
124
+ queue = manifest.policy.queue_lane
125
+ feeds = lane_label(manifest, :machine, "the machine lane")
140
126
  AGENT_PROTOCOL_TEMPLATE.merge(
141
- "recipes" => recipes(manifest),
127
+ "recipes" => {
128
+ "read" => { "purpose" => "find and read an entry",
129
+ "steps" => ["list (lane:, prefix:) — discover keys", "get KEY — returns the entry envelope"] },
130
+ "write" => { "purpose" => "create or update an entry",
131
+ "steps" => ["schema KEY — learn field shape", "put KEY — persist it (role-gated)"] },
132
+ "propose" => { "purpose" => "agent suggests a change for human review",
133
+ "agent_steps" => ["propose KEY — writes to #{queue} lane"],
134
+ "human_steps" => ["accept #{queue}.KEY — promotes to target lane"] },
135
+ "drain" => { "purpose" => "keep machine lanes fresh",
136
+ "steps" => ["pulse — stale list names overdue entries",
137
+ "drain (lane: #{feeds}) — re-pull stale entries"] },
138
+ },
142
139
  "role_resolution" => {
143
140
  "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
144
141
  "then a transport default ('human' for CLI, 'agent' for MCP)",
@@ -148,29 +145,23 @@ module Textus
148
145
  )
149
146
  end
150
147
 
151
- def self.build(container:)
152
- manifest = container.manifest
153
- etag = Textus::Etag.for_contract(container.root)
154
-
155
- {
156
- "protocol" => PROTOCOL_ID,
157
- "store_root" => container.root,
158
- "contract_etag" => etag,
159
- "lanes" => lanes_for(manifest),
160
- "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
161
- "orientation" => read_artifact_content(container, "artifacts.config.orientation"),
162
- "context" => read_boot_context(container),
163
- "index_key" => index_key_if_present(container),
164
- "agent_protocol" => agent_protocol(manifest),
165
- }.compact
148
+ def self.lanes_for(manifest)
149
+ manifest.data.declared_lane_kinds.keys.map do |name|
150
+ verb = manifest.policy.verb_for_lane(name)
151
+ row = { "name" => name, "writers" => manifest.policy.roles_with_capability(verb) }
152
+ kind = manifest.policy.declared_kind(name)
153
+ row["kind"] = kind.to_s if kind
154
+ purpose = manifest.data.lane_descs[name]
155
+ row["purpose"] = purpose if purpose && !purpose.empty?
156
+ row
157
+ end
166
158
  end
167
159
 
168
160
  def self.read_artifact_content(container, key)
169
161
  res = container.manifest.resolver.resolve(key)
170
162
  return nil unless res.path && File.exist?(res.path)
171
163
 
172
- call = Textus::Call.build(role: Textus::Role::DEFAULT)
173
- env = Textus::Action::Get.new(key: key).call(container: container, call: call)
164
+ env = Textus::Store::Entry::Reader.from(container: container).read(key)
174
165
  env&.content
175
166
  rescue Textus::Error
176
167
  nil
@@ -180,31 +171,11 @@ module Textus
180
171
  res = container.manifest.resolver.resolve("knowledge.boot")
181
172
  return nil unless res.path && File.exist?(res.path)
182
173
 
183
- call = Textus::Call.build(role: Textus::Role::DEFAULT)
184
- env = Textus::Action::Get.new(key: "knowledge.boot").call(container: container, call: call)
174
+ env = Textus::Store::Entry::Reader.from(container: container).read("knowledge.boot")
185
175
  body = env&.body&.strip
186
176
  body.nil? || body.empty? ? nil : body
187
177
  rescue Textus::Error
188
178
  nil
189
179
  end
190
-
191
- def self.index_key_if_present(container)
192
- res = container.manifest.resolver.resolve("artifacts.system.index")
193
- res.path && File.exist?(res.path) ? "artifacts.system.index" : nil
194
- rescue Textus::Error
195
- nil
196
- end
197
-
198
- def self.lanes_for(manifest)
199
- manifest.data.declared_lane_kinds.keys.map do |name|
200
- verb = manifest.policy.verb_for_lane(name)
201
- row = { "name" => name, "writers" => manifest.policy.roles_with_capability(verb) }
202
- kind = manifest.policy.declared_kind(name)
203
- row["kind"] = kind.to_s if kind
204
- purpose = manifest.data.lane_descs[name]
205
- row["purpose"] = purpose if purpose && !purpose.empty?
206
- row
207
- end
208
- end
209
180
  end
210
181
  end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ module Dispatch
3
+ class MissingArgs < Textus::Error
4
+ attr_reader :spec, :missing
5
+
6
+ def initialize(spec, missing)
7
+ @spec = spec
8
+ @missing = missing
9
+ super("missing_args", "#{spec.verb}: missing #{missing.map(&:wire).join(", ")}")
10
+ end
11
+ end
12
+
13
+ module Binder
14
+ Pending = Data.define(:spec, :inputs)
15
+
16
+ module_function
17
+
18
+ def command(spec, inputs)
19
+ Pending.new(spec: spec, inputs: inputs)
20
+ end
21
+
22
+ def bind(spec, inputs)
23
+ missing = spec.required_args.reject { |a| inputs.key?(a.name) }
24
+ raise MissingArgs.new(spec, missing) unless missing.empty?
25
+
26
+ spec.args.each_with_object({}) do |a, h|
27
+ if inputs.key?(a.name)
28
+ h[a.name] = inputs[a.name]
29
+ elsif !a.default.nil?
30
+ h[a.name] = a.default
31
+ end
32
+ end
33
+ end
34
+
35
+ def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
36
+ names = spec.args.select(&:positional).map(&:name)
37
+ names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
38
+ end
39
+
40
+ def inputs_from_wire(spec, raw)
41
+ raw ||= {}
42
+ spec.args.each_with_object({}) do |a, h|
43
+ h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,63 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Contracts
4
+ GetEntry = Data.define(:key)
5
+
6
+ PutEntry = Data.define(:key, :meta, :body, :content, :if_etag)
7
+
8
+ ListKeys = Data.define(:prefix, :lane, :q, :schema)
9
+
10
+ DeleteKey = Data.define(:key, :if_etag)
11
+
12
+ MoveKey = Data.define(:old_key, :new_key, :if_etag, :dry_run)
13
+
14
+ ProposeEntry = Data.define(:key, :meta, :body, :content)
15
+
16
+ AcceptProposal = Data.define(:pending_key)
17
+
18
+ RejectProposal = Data.define(:pending_key)
19
+
20
+ EnqueueJob = Data.define(:type, :args)
21
+
22
+ AuditEntries = Data.define(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit)
23
+
24
+ PulseEntries = Data.define(:since)
25
+
26
+ BlameEntry = Data.define(:key, :limit)
27
+
28
+ WhereEntry = Data.define(:key)
29
+
30
+ UidEntry = Data.define(:key)
31
+
32
+ DepsEntry = Data.define(:key)
33
+
34
+ RdepsEntry = Data.define(:key)
35
+
36
+ BootStore = Data.define
37
+
38
+ DoctorStore = Data.define(:checks)
39
+
40
+ PublishedEntries = Data.define
41
+
42
+ RuleExplain = Data.define(:key, :detail)
43
+
44
+ RuleList = Data.define
45
+
46
+ SchemaEnvelope = Data.define(:key)
47
+
48
+ DrainStore = Data.define(:prefix, :lane)
49
+
50
+ IngestEntry = Data.define(:kind, :slug, :url, :path, :lane, :label)
51
+
52
+ JobsAction = Data.define(:state, :action, :job_id)
53
+
54
+ RuleLint = Data.define(:candidate_yaml)
55
+
56
+ DataMv = Data.define(:from, :to, :dry_run)
57
+
58
+ KeyMvPrefix = Data.define(:from_prefix, :to_prefix, :dry_run)
59
+
60
+ KeyDeletePrefix = Data.define(:prefix, :dry_run)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Dispatch
3
+ class HandlerRegistry
4
+ def initialize
5
+ @handlers = {}
6
+ end
7
+
8
+ def register(command_class, handler)
9
+ @handlers[command_class] = handler
10
+ end
11
+
12
+ def for(command_class)
13
+ @handlers[command_class] || raise("no handler registered for #{command_class}")
14
+ end
15
+
16
+ def registered?(command_class)
17
+ @handlers.key?(command_class)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Dispatch
5
+ module Middleware
6
+ # Shadows successful write operations into the SQLite audit_events table
7
+ # synchronously after dispatch, without the command knowing. Read verbs
8
+ # and failed writes pass through unchanged.
9
+ class AuditIndex < Base
10
+ middleware_name :audit_index
11
+
12
+ INDEXED_CONTRACTS = [
13
+ Contracts::PutEntry,
14
+ Contracts::DeleteKey,
15
+ Contracts::MoveKey,
16
+ ].freeze
17
+
18
+ def initialize(job_store:, audit_log:)
19
+ super()
20
+ @job_store = job_store
21
+ @audit_log = audit_log
22
+ end
23
+
24
+ def call(container:, command:, call:, next_handler:) # rubocop:disable Lint/UnusedMethodArgument
25
+ result = next_handler.call(command, call)
26
+ return result unless result.success? && INDEXED_CONTRACTS.include?(command.class)
27
+
28
+ key = command.respond_to?(:key) ? command.key : nil
29
+ return result unless key
30
+
31
+ seq = @audit_log.latest_seq
32
+ verb = command.class.name.split("::").last
33
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
34
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
35
+ .downcase
36
+
37
+ @job_store.insert_audit_event(
38
+ seq: seq,
39
+ ts: Time.now.utc.iso8601,
40
+ role: call.role,
41
+ verb: verb,
42
+ key: key,
43
+ etag_before: nil,
44
+ etag_after: result.value.is_a?(Hash) ? result.value["etag"] : nil,
45
+ )
46
+ result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Auth < Base
5
+ middleware_name :auth
6
+
7
+ def call(container:, command:, call:, next_handler:)
8
+ verb = VerbRegistry.contract_to_verb!(command.class).to_sym
9
+ key = key_for(command)
10
+
11
+ rule_preds = key ? rule_declared_predicates(verb, container.manifest, key) : []
12
+
13
+ Manifest::Policy::Predicates.evaluate(
14
+ manifest: container.manifest, schemas: container.schemas,
15
+ action: verb, actor: call.role, key: key,
16
+ rule_predicates: rule_preds
17
+ )
18
+
19
+ next_handler.call(command, call)
20
+ end
21
+
22
+ private
23
+
24
+ def rule_declared_predicates(verb, manifest, key)
25
+ guard_map = manifest.rules.for(key).guard
26
+ return [] if guard_map.nil?
27
+
28
+ Array(guard_map[verb.to_s])
29
+ end
30
+
31
+ def key_for(command)
32
+ if command.respond_to?(:key) then command.key
33
+ elsif command.respond_to?(:old_key) then command.old_key
34
+ elsif command.respond_to?(:pending_key) then command.pending_key
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Base
5
+ def self.inherited(subclass)
6
+ super
7
+ subclass.instance_variable_set(:@middleware_name, nil)
8
+ end
9
+
10
+ class << self
11
+ def middleware_name(name = nil)
12
+ if name
13
+ @middleware_name = name.to_s
14
+ else
15
+ @middleware_name || name.split("::").last.downcase
16
+ end
17
+ end
18
+ end
19
+
20
+ def call(container:, command:, call:, next_handler:)
21
+ raise NotImplementedError
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Binder < Base
5
+ middleware_name :binder
6
+
7
+ def call(container:, command:, call:, next_handler:) # rubocop:disable Lint/UnusedMethodArgument
8
+ return next_handler.call(command, call) unless command.is_a?(Dispatch::Binder::Pending)
9
+
10
+ spec = command.spec
11
+ contract_class = VerbRegistry.contract_class_for(spec.verb) or
12
+ raise Textus::UsageError.new("unknown command verb: #{spec.verb}")
13
+ resolved = Dispatch::Binder.bind(spec, command.inputs)
14
+ built = Dispatch::Pipeline.build_command(contract_class, resolved)
15
+ next_handler.call(built, call)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,53 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Cascade < Base
5
+ middleware_name :cascade
6
+
7
+ CASCADE_VERBS = %i[put propose accept reject key_mv key_delete].freeze
8
+
9
+ TRIGGER_TYPE_MAP = {
10
+ Contracts::PutEntry => "entry.written",
11
+ Contracts::ProposeEntry => "entry.written",
12
+ Contracts::DeleteKey => "entry.deleted",
13
+ Contracts::MoveKey => "entry.moved",
14
+ Contracts::AcceptProposal => "proposal.accepted",
15
+ Contracts::RejectProposal => "proposal.rejected",
16
+ }.freeze
17
+
18
+ def call(container:, command:, call:, next_handler:)
19
+ result = next_handler.call(command, call)
20
+ return result unless result.success? && cascadable?(command)
21
+
22
+ key = cascade_key(command)
23
+ return result unless key
24
+
25
+ trigger_type = TRIGGER_TYPE_MAP[command.class]
26
+ jobs = Textus::Store::Jobs::Planner.new(container: container).plan(
27
+ trigger: { "type" => trigger_type, "target" => key },
28
+ role: call.role,
29
+ )
30
+ queue = Textus::Store::Jobs::Queue.new(store: container.job_store)
31
+ jobs.each { |j| queue.enqueue(j) }
32
+ result
33
+ end
34
+
35
+ private
36
+
37
+ def cascadable?(command)
38
+ CASCADE_VERBS.include?(VerbRegistry.contract_to_verb!(command.class).to_sym)
39
+ end
40
+
41
+ def cascade_key(command)
42
+ case command
43
+ when Contracts::PutEntry, Contracts::DeleteKey then command.key
44
+ when Contracts::MoveKey then command.new_key
45
+ when Contracts::AcceptProposal,
46
+ Contracts::RejectProposal then command.pending_key
47
+ when Contracts::ProposeEntry then command.key
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ module Textus
2
+ module Dispatch
3
+ class Pipeline
4
+ attr_reader :container
5
+
6
+ def initialize(registry:, container:, middleware: [])
7
+ @registry = registry
8
+ @middleware = middleware
9
+ @container = container
10
+ end
11
+
12
+ def dispatch(command, call:)
13
+ stack = @middleware.reverse.reduce(->(cmd, c) { execute(cmd, c) }) do |next_mw, mw|
14
+ ->(cmd, c) { mw.call(container: @container, command: cmd, call: c, next_handler: next_mw) }
15
+ end
16
+ stack.call(command, call)
17
+ end
18
+
19
+ def self.build_command(contract_class, inputs)
20
+ members = contract_class.members
21
+ kwargs = members.to_h do |member|
22
+ [member, inputs[member]]
23
+ end
24
+ contract_class.new(**kwargs)
25
+ end
26
+
27
+ private
28
+
29
+ def execute(command, call)
30
+ handler = @registry.for(command.class)
31
+ handler.call(command, call)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,8 +3,8 @@ module Textus
3
3
  class Check
4
4
  class AuditLog < Check
5
5
  def call
6
- path = Textus::Layout.audit_log(root)
7
- Textus::Ports::AuditLog.new(root).verify_integrity.map do |v|
6
+ path = Textus::Store::Layout.new(root).audit_log_path
7
+ Textus::Port::AuditLog.new(root).verify_integrity.map do |v|
8
8
  {
9
9
  "code" => "audit.parse_error",
10
10
  "level" => "warning",
@@ -8,12 +8,12 @@ module Textus
8
8
  # verb reported.
9
9
  class GeneratorDrift < Check
10
10
  def call
11
- gen = Textus::Core::Freshness::Evaluator.new(
11
+ detector = Textus::Store::Freshness::DriftDetector.new(
12
12
  manifest: manifest,
13
- file_stat: Textus::Ports::Storage::FileStat.new,
14
- clock: Textus::Ports::Clock.new,
13
+ file_stat: Textus::Port::Storage::FileStat.new,
14
+ clock: Textus::Port::Clock.new,
15
15
  )
16
- manifest.data.entries.flat_map { |m| gen.drift_rows(m) }.map do |row|
16
+ manifest.data.entries.flat_map { |m| detector.drift_rows(m) }.map do |row|
17
17
  {
18
18
  "code" => "generator_drift",
19
19
  "level" => "warning",
@@ -8,12 +8,12 @@ module Textus
8
8
  # that drift without making `build` scan globally.
9
9
  class OrphanedPublishTargets < Check
10
10
  def call
11
- sdir = Textus::Layout.sentinels(root)
11
+ sdir = Textus::Store::Layout.new(root).sentinels_root
12
12
  return [] unless File.directory?(sdir)
13
13
 
14
14
  repo_root = File.dirname(root)
15
- store = Textus::Ports::SentinelStore.new
16
- glob = File.join(sdir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")
15
+ store = Textus::Port::SentinelStore.new
16
+ glob = File.join(sdir, "**", "*#{Textus::Port::SentinelStore::SUFFIX}")
17
17
  Dir.glob(glob).filter_map do |spath|
18
18
  sentinel = store.load(spath, repo_root)
19
19
  next nil if sentinel.nil? || sentinel.source.nil?
@@ -8,7 +8,7 @@ module Textus
8
8
  class ProtocolVersion < Check
9
9
  # Standalone interface: root is the project root (parent of .textus/).
10
10
  def self.run(root:)
11
- path = File.join(root, ".textus/manifest.yaml")
11
+ path = File.join(root, ".textus", "manifest.yaml")
12
12
  return [] unless File.exist?(path)
13
13
 
14
14
  doc = YAML.safe_load_file(path, aliases: false) || {}
@@ -26,7 +26,7 @@ module Textus
26
26
  # Doctor check interface: root is the .textus/ directory itself,
27
27
  # so manifest.yaml lives directly inside it.
28
28
  def call
29
- path = File.join(root, "manifest.yaml")
29
+ path = geometry.manifest_path
30
30
  return [] unless File.exist?(path)
31
31
 
32
32
  doc = YAML.safe_load_file(path, aliases: false) || {}
@@ -42,7 +42,7 @@ module Textus
42
42
  end
43
43
 
44
44
  def find_asset_path(asset_rel)
45
- File.join(root, "assets", asset_rel)
45
+ File.join(geometry.root, "assets", asset_rel)
46
46
  end
47
47
  end
48
48
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  # leaving the operator with no signal that a schema is broken.
8
8
  class SchemaParseError < Check
9
9
  def call
10
- dir = File.join(root, "schemas")
10
+ dir = geometry.schemas_dir
11
11
  return [] unless File.directory?(dir)
12
12
 
13
13
  Dir.glob(File.join(dir, "*.yaml")).each_with_object([]) do |path, out|
@@ -4,11 +4,11 @@ module Textus
4
4
  class SchemaViolations < Check
5
5
  def call
6
6
  result = Textus::Doctor::Validator.new(
7
- reader: ->(key, ctnr, c) { Textus::Action::Get.new(key: key).call(container: ctnr, call: c) },
7
+ reader: ->(key, ctnr, _c) { Textus::Store::Entry::Reader.from(container: ctnr).read(key) },
8
8
  manifest: @container.manifest,
9
9
  audit_log: @container.audit_log,
10
10
  schema_for: ->(name) { @container.schemas.fetch_or_nil(name) },
11
- ).call(container: @container, call: Textus::Call.build(role: Textus::Role::DEFAULT))
11
+ ).call(container: @container, call: Textus::Value::Call.build(role: Textus::Value::Role::DEFAULT))
12
12
 
13
13
  result["violations"].map do |v|
14
14
  fix = v["expected"] &&