textus 0.55.1 → 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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
@@ -1,57 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Core
5
- module Retention
6
- # Retention sweep reporter (ADR 0093/0099). Which entries are past their
7
- # `retention:` ttl and the destructive action that applies. Age basis: file
8
- # mtime. Only drop/archive. Renamed off the Core::Retention vs
9
- # Manifest::Policy::Retention collision (ADR 0099).
10
- class Sweep
11
- def self.expired?(ttl_seconds:, mtime:, now:)
12
- return false if ttl_seconds.nil? || mtime.nil?
13
-
14
- (now - mtime).to_i > ttl_seconds
15
- end
16
-
17
- def initialize(manifest:, file_stat:, clock:)
18
- @manifest = manifest
19
- @file_stat = file_stat
20
- @clock = clock
21
- end
22
-
23
- def call(prefix: nil, lane: nil)
24
- @manifest.data.entries
25
- .select { |m| matches?(m, prefix: prefix, lane: lane) }
26
- .flat_map { |m| rows_for(m) }
27
- end
28
-
29
- private
30
-
31
- def matches?(mentry, prefix:, lane:)
32
- return false if lane && mentry.lane != lane
33
- return false if prefix && !Textus::Key::Matching.matches_prefix?(
34
- mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
35
- )
36
-
37
- true
38
- end
39
-
40
- def rows_for(mentry)
41
- policy = @manifest.rules.for(mentry.key).retention
42
- return [] if policy.nil?
43
-
44
- @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
45
- path = row[:path]
46
- next unless @file_stat.exists?(path)
47
- next unless self.class.expired?(
48
- ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
49
- )
50
-
51
- { "key" => row[:key], "path" => path, "action" => policy.action.to_s }
52
- end
53
- end
54
- end
55
- end
56
- end
57
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Core
5
- # Retention — "is the entry old enough to retire?" (Q2, ADR 0093/0099).
6
- # GC dueness, orthogonal to Freshness (content currency). The reporter is
7
- # Core::Retention::Sweep; the manifest rule policy is Manifest::Policy::Retention.
8
- module Retention
9
- end
10
- end
11
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Format
3
- module Shared
4
- ENFORCE_NAME_RE = /\.(md|json|yaml|yml|txt)\z/i
5
-
6
- def self.enforce_name_match!(path, meta, extensions)
7
- return unless meta.is_a?(Hash) && meta["name"]
8
-
9
- ext = extensions.first
10
- basename = File.basename(path, ext)
11
- return if meta["name"] == basename
12
-
13
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
14
- end
15
- end
16
- end
17
- end
@@ -1,212 +0,0 @@
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
- def check!(cmd)
31
- key = extract_key(cmd)
32
- return unless key
33
-
34
- evaluate_predicates(
35
- action: cmd.verb,
36
- actor: cmd.role.to_s,
37
- key: key,
38
- envelope: nil,
39
- extra: {},
40
- )
41
- end
42
-
43
- # Backward-compatible check for inline action auth (accept, put, etc.).
44
- def check_action!(action:, actor:, key:, envelope: nil, extra: {})
45
- evaluate_predicates(
46
- action: action.to_sym,
47
- actor: actor,
48
- key: key,
49
- envelope: envelope,
50
- extra: extra,
51
- )
52
- end
53
-
54
- private
55
-
56
- def evaluate_predicates(action:, actor:, key:, envelope:, extra:)
57
- mentry = @manifest.resolver.resolve(key).entry
58
- lane_verb = @manifest.policy.verb_for_lane(mentry.lane.to_s)
59
- actor_caps = Set.new(@manifest.data.role_caps.fetch(actor, []))
60
-
61
- ctx = AuthContext.new(
62
- actor:, actor_caps:, lane_verb:,
63
- action:, target: key, envelope:,
64
- mentry:, manifest: @manifest
65
- )
66
-
67
- failures = []
68
- floor_preds = FLOOR.fetch(action, [])
69
- rule_preds = rule_declared_predicates(action, key)
70
- (floor_preds + rule_preds).uniq.each do |pred|
71
- result = evaluate(pred, ctx, extra)
72
- next if result[:pass]
73
- raise result[:error] if result[:error]
74
-
75
- failures << [pred, result[:reason]]
76
- end
77
- raise Textus::GuardFailed.new(failures) unless failures.empty?
78
- end
79
-
80
- def extract_key(cmd)
81
- cmd.params.key?(:pending_key) ? cmd.pending_key : cmd.key
82
- end
83
-
84
- def rule_declared_predicates(action, key)
85
- guard_map = @manifest.rules.for(key).guard
86
- return [] if guard_map.nil?
87
-
88
- Array(guard_map[action.to_s])
89
- end
90
-
91
- def evaluate(pred_name, ctx, extra)
92
- case pred_name
93
- when "lane_writable_by" then evaluate_lane_writable_by(ctx)
94
- when "author_held" then evaluate_author_held(ctx)
95
- when "target_is_canon" then evaluate_target_is_canon(ctx)
96
- when "etag_match" then evaluate_etag_match(ctx, extra)
97
- when "schema_valid" then evaluate_schema_valid(ctx)
98
- when "fresh_within" then { pass: true }
99
- when "raw_lane_ingest_only" then evaluate_raw_lane_ingest_only(ctx)
100
- when "raw_write_once" then evaluate_raw_write_once(ctx)
101
- when "lane_deletable_by" then evaluate_lane_deletable_by(ctx)
102
- else raise Textus::UsageError.new("unknown predicate '#{pred_name}'")
103
- end
104
- end
105
-
106
- def evaluate_lane_writable_by(ctx)
107
- pass = ctx.actor_caps.include?(ctx.lane_verb.to_s)
108
- return { pass: true } if pass
109
-
110
- holders = @manifest.policy.roles_with_capability(ctx.lane_verb.to_s)
111
- { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane, verb: ctx.lane_verb, holders:) }
112
- end
113
-
114
- def evaluate_author_held(ctx)
115
- holders = @manifest.policy.roles_with_capability("author")
116
- pass = holders.include?(ctx.actor.to_s)
117
- reason = if pass
118
- nil
119
- elsif holders.empty?
120
- "no role holds the 'author' capability; #{ctx.action} is disabled"
121
- else
122
- "role '#{ctx.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
123
- end
124
- { pass:, reason: }
125
- end
126
-
127
- def evaluate_target_is_canon(ctx)
128
- kind = @manifest.policy.declared_kind(ctx.mentry.lane.to_s)
129
- pass = kind == :canon
130
- { pass:, reason: pass ? nil : "target lane '#{ctx.mentry.lane}' is not canon (kind: #{kind})" }
131
- end
132
-
133
- def evaluate_etag_match(ctx, extra)
134
- if_etag = extra[:if_etag]
135
- return { pass: true } if if_etag.nil?
136
-
137
- current = ctx.envelope&.etag
138
- pass = current.nil? || current == if_etag
139
- { pass:, error: pass ? nil : Textus::EtagMismatch.new(ctx.target, if_etag, current) }
140
- end
141
-
142
- def evaluate_schema_valid(ctx)
143
- return { pass: true } unless ctx.envelope
144
-
145
- schema_ref = ctx.mentry.schema
146
- return { pass: true } unless schema_ref
147
-
148
- schema = @schemas.fetch_or_nil(schema_ref)
149
- return { pass: true } unless schema
150
-
151
- frontmatter = ctx.envelope.meta&.dig("_meta") || ctx.envelope.meta || {}
152
- begin
153
- schema.validate!(frontmatter)
154
- { pass: true }
155
- rescue Textus::SchemaViolation => e
156
- { pass: false, reason: schema_reason(e) }
157
- end
158
- end
159
-
160
- def evaluate_raw_lane_ingest_only(ctx)
161
- return { pass: true } unless @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
162
- return { pass: true } if ctx.action == :ingest
163
-
164
- { pass: false, error: Textus::Error.new(
165
- :raw_lane_ingest_only,
166
- "raw lane '#{ctx.mentry.lane}' only accepts `textus ingest` — " \
167
- "use that verb instead of '#{ctx.action}'",
168
- ) }
169
- end
170
-
171
- def evaluate_raw_write_once(ctx)
172
- path = @manifest.resolver.resolve(ctx.target).path
173
- return { pass: true } unless File.exist?(path)
174
-
175
- { pass: false, error: Textus::Error.new(
176
- :raw_write_once,
177
- "raw entry '#{ctx.target}' already exists; " \
178
- "delete it first (`textus key-delete #{ctx.target}`), then re-ingest",
179
- ) }
180
- end
181
-
182
- # Deletion authority: the lane's write capability OR the author capability.
183
- # On raw-kind lanes only the author capability grants deletion (correction
184
- # escape hatch); the lane's own verb (ingest) is write-only. On all other
185
- # lane kinds the behaviour matches lane_writable_by — the lane's writer
186
- # can delete as before.
187
- def evaluate_lane_deletable_by(ctx)
188
- is_raw = @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
189
- pass = if is_raw
190
- ctx.actor_caps.include?("author")
191
- else
192
- ctx.actor_caps.include?(ctx.lane_verb.to_s) || ctx.actor_caps.include?("author")
193
- end
194
- return { pass: true } if pass
195
-
196
- extra_holders = is_raw ? ["author"] : [ctx.lane_verb.to_s, "author"]
197
- holders = extra_holders.flat_map { |v| @manifest.policy.roles_with_capability(v) }.uniq
198
- { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane,
199
- verb: ctx.lane_verb, holders:) }
200
- end
201
-
202
- def schema_reason(err)
203
- d = err.details
204
- return err.message.dup unless d.is_a?(Hash)
205
- return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
206
- return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
207
-
208
- err.message.dup
209
- end
210
- end
211
- end
212
- end
data/lib/textus/gate.rb DELETED
@@ -1,92 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- class Gate
5
- def initialize(container)
6
- @container = container
7
- end
8
-
9
- def dispatch(spec:, inputs:, role:, correlation_id: nil, session: nil, surface: nil)
10
- resolved = Binder.bind(spec, inputs, session: session)
11
- cmd = Value::Command.new(verb: spec.verb, params: resolved.freeze, role: role)
12
-
13
- cmd = normalize_propose_key(cmd) if cmd.verb == :propose
14
- action_class = Textus::Action::VERBS.fetch(cmd.verb) do
15
- raise Textus::UsageError.new("unknown command verb: #{cmd.verb}")
16
- end
17
-
18
- auth = Gate::Auth.new(@container)
19
- auth.check!(cmd)
20
- check_dispatch_auth(cmd, resolved, auth)
21
- call_obj = build_call(cmd, correlation_id: correlation_id)
22
- result = run_action(action_class, resolved, call_obj)
23
- cascade(cmd, result, call_obj) if CASCADE_VERBS.include?(cmd.verb) && !call_obj.dry_run
24
- return result unless surface
25
-
26
- spec.view(surface).call(result, resolved)
27
- end
28
-
29
- CASCADE_VERBS = %i[put propose accept reject key_mv key_delete].freeze
30
-
31
- AUTH_KEYS = {
32
- key_mv: ->(params) { [params[:old_key], params[:new_key]] },
33
- ingest: ->(params) { Textus::Action::Ingest.dispatch_key(**params) },
34
- }.freeze
35
-
36
- private
37
-
38
- def check_dispatch_auth(cmd, resolved, auth)
39
- return unless (resolver = AUTH_KEYS[cmd.verb])
40
-
41
- keys = Array(resolver.call(resolved))
42
- keys.each { |k| auth.check_action!(action: cmd.verb, actor: cmd.role, key: k) }
43
- end
44
-
45
- def normalize_propose_key(cmd)
46
- return cmd if cmd.pending_key
47
-
48
- zone = @container.manifest.policy.propose_lane_for(cmd.role.to_s)
49
- key = zone ? "#{zone}.#{cmd.key}" : nil
50
- cmd.with(params: cmd.params.merge(pending_key: key))
51
- end
52
-
53
- def run_action(klass, params, call_obj)
54
- result = klass.call(container: @container, call: call_obj, **params)
55
- Value::Result.unwrap(result)
56
- end
57
-
58
- def build_call(cmd, correlation_id: nil)
59
- dry_run = cmd.params.key?(:dry_run) ? !cmd.params[:dry_run].nil? : false
60
- Textus::Value::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
61
- end
62
-
63
- def cascade(cmd, result, call)
64
- key = result.is_a?(Hash) ? result["cascade_key"] : nil
65
- key ||= cascade_key_from_params(cmd)
66
- return unless key
67
-
68
- rdeps_result = Textus::Action::Rdeps.call(container: @container, call: call, key: key)
69
- rdeps = Value::Result.unwrap(rdeps_result).fetch("rdeps", [])
70
- producible = rdeps.select { |dep_key| producible?(dep_key) }
71
- producible.each do |dep_key|
72
- Textus::Store::Jobs::Materialize.call(container: @container, call: call, key: dep_key)
73
- end
74
- end
75
-
76
- def cascade_key_from_params(cmd)
77
- case cmd.verb
78
- when :put, :key_delete then cmd.params[:key]
79
- when :key_mv then cmd.params[:new_key]
80
- when :propose, :reject then cmd.params[:pending_key]
81
- when :accept then nil
82
- end
83
- end
84
-
85
- def producible?(key)
86
- entry = @container.manifest.resolver.resolve(key).entry
87
- !entry.publish_tree.nil?
88
- rescue Textus::Error
89
- false
90
- end
91
- end
92
- end
data/lib/textus/meta.rb DELETED
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Textus
6
- module Meta
7
- NO_META_FORMATS = %w[text].freeze
8
-
9
- FIELDS = {
10
- "uid" => {
11
- inject: lambda { |meta, content, existing_meta|
12
- m = meta.is_a?(Hash) ? meta.dup : {}
13
- existing = existing_meta.is_a?(Hash) ? existing_meta["uid"] : nil
14
- m["uid"] = existing || Textus::Value::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
15
- [m, content]
16
- },
17
- },
18
- "sources" => {
19
- inject: lambda { |meta, content, existing_meta|
20
- m = meta.is_a?(Hash) ? meta.dup : {}
21
- existing = existing_meta.is_a?(Hash) ? existing_meta["sources"] : nil
22
-
23
- if m.key?("sources")
24
- raise Textus::BadContent.new(nil, "_meta.sources must be an array") unless m["sources"].is_a?(Array)
25
-
26
- m["sources"] = m["sources"].map { |s| validate_source_shape!(s) }
27
- elsif existing.is_a?(Array) && !existing.empty?
28
- m["sources"] = existing
29
- end
30
-
31
- [m, content]
32
- },
33
- },
34
- }.freeze
35
-
36
- def self.inject_all(meta, content, existing_meta = {}, format: nil)
37
- return [meta, content] if NO_META_FORMATS.include?(format)
38
-
39
- FIELDS.each_value do |field|
40
- meta, content = field[:inject].call(meta, content, existing_meta)
41
- end
42
-
43
- [meta, content]
44
- end
45
-
46
- def self.validate_source_shape!(src)
47
- raise Textus::BadContent.new(nil, "each source must be a string") unless src.is_a?(String)
48
-
49
- raise Textus::BadContent.new(nil, "each source must start with 'raw.', got #{src.inspect}") unless src.match?(/\Araw\./)
50
-
51
- src
52
- end
53
- end
54
- end
@@ -1,54 +0,0 @@
1
- module Textus
2
- # Eager-loading schema cache. Loads every *.yaml under +dir+ at construction.
3
- # A missing directory is treated as "no schemas" (does not raise) to mirror
4
- # the lazy behavior previously embedded in Store#schema_for.
5
- class Schemas
6
- def initialize(dir)
7
- @dir = dir
8
- @schemas = {}
9
- load_all
10
- end
11
-
12
- def fetch(name)
13
- @schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
14
- end
15
-
16
- # Only nil short-circuits. A missing-but-named schema still raises IoError.
17
- def fetch_or_nil(name)
18
- return nil if name.nil?
19
-
20
- fetch(name)
21
- end
22
-
23
- def all
24
- @schemas.values
25
- end
26
-
27
- # Name-keyed view: { canonical_name => Schema }. The key is the schema's
28
- # file stem, which is authoritative even when a schema file carries no
29
- # top-level `name:` (Schema#name reads the body and may be nil). Symmetric
30
- # with #all (values); use this when you need the names too.
31
- def by_name
32
- @schemas.dup
33
- end
34
-
35
- private
36
-
37
- def load_all
38
- return unless File.directory?(@dir)
39
-
40
- Dir.glob(File.join(@dir, "*.yaml")).each do |path|
41
- name = File.basename(path, ".yaml")
42
- begin
43
- @schemas[name] = Schema.load(path)
44
- rescue StandardError
45
- # Tolerate broken schema files at construction time so the rest of
46
- # the store remains loadable. Surfacing the failure is the job of
47
- # Doctor::Check::SchemaParseError. Lookups via #fetch still raise
48
- # IoError for the missing-but-named schema.
49
- next
50
- end
51
- end
52
- end
53
- end
54
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- class Store
5
- class Compositor
6
- def initialize(container)
7
- @container = container
8
- end
9
-
10
- def write(key, mentry:, payload:, call:, if_etag: nil)
11
- Textus::Store::Envelope::Writer.from(container: @container, call: call)
12
- .put(key, mentry: mentry, payload: payload, if_etag: if_etag)
13
- end
14
-
15
- def read(key)
16
- Textus::Store::Envelope::Reader.from(container: @container).read(key)
17
- end
18
-
19
- def delete(key, call:, mentry: nil, if_etag: nil)
20
- Textus::Store::Envelope::Writer.from(container: @container, call: call)
21
- .delete(key, mentry: mentry, if_etag: if_etag)
22
- end
23
-
24
- def move(from_key:, to_key:, new_mentry:, call:, if_etag: nil)
25
- Textus::Store::Envelope::Writer.from(container: @container, call: call)
26
- .move(from_key: from_key, to_key: to_key, new_mentry: new_mentry, if_etag: if_etag)
27
- end
28
-
29
- def exists?(key)
30
- Textus::Store::Envelope::Reader.from(container: @container).exists?(key)
31
- end
32
- end
33
- end
34
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry-struct"
4
-
5
- module Textus
6
- # The agent session: per-connection (MCP), per-process (CLI), or per-loop
7
- # (Ruby) orientation state — the audit cursor plus the contract etag and
8
- # propose_lane captured at boot. Immutable Dry::Struct::Value; advance_cursor
9
- # and with return new instances. ADR 0036; contract_etag widened in ADR 0074.
10
- class Store
11
- class Session < Dry::Struct
12
- attribute :role, Value::Types::RoleName
13
- attribute :cursor, Value::Types::Cursor
14
- attribute :propose_lane, Value::Types::String.optional
15
- attribute :contract_etag, Value::Types::String
16
-
17
- def with(**attrs) = self.class.new(to_h.merge(attrs))
18
-
19
- def advance_cursor(new_cursor) = with(cursor: new_cursor)
20
-
21
- def check_etag!(observed_etag)
22
- return if observed_etag == contract_etag
23
-
24
- raise Textus::ContractDrift.new(
25
- "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
26
- "now #{short_etag(observed_etag)}); re-run boot",
27
- )
28
- end
29
-
30
- private
31
-
32
- # First 8 hex chars after the "sha256:" prefix — a stable short id for
33
- # the drift diagnostic.
34
- def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
35
- end
36
- end
37
- end
@@ -1,27 +0,0 @@
1
- module Textus
2
- module Surface
3
- class Projector
4
- def initialize(view_key: :default, binder_method: :inputs_from_wire)
5
- @view_key = view_key
6
- @binder_method = binder_method
7
- end
8
-
9
- def verbs(action_verbs = Textus::Action::VERBS)
10
- action_verbs.select do |_verb, klass|
11
- klass.respond_to?(:contract?) && klass.contract?
12
- end
13
- end
14
-
15
- def names(action_verbs = Textus::Action::VERBS)
16
- verbs(action_verbs).keys.map(&:to_s)
17
- end
18
-
19
- def dispatch(verb_name, inputs:, store:, role:, session: nil)
20
- klass = Textus::Action::VERBS.fetch(verb_name.to_sym)
21
- spec = klass.contract
22
- bound = Textus::Gate::Binder.public_send(@binder_method, spec, inputs)
23
- store.gate.dispatch(spec:, inputs: bound, role:, session:, surface: @view_key)
24
- end
25
- end
26
- end
27
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Surface
5
- # Role-scoped identity carrier. Holds the acting identity (role,
6
- # correlation_id, dry_run) bound to a container. All verb methods
7
- # (put, get, accept, ...) are injected by textus.rb's define_method
8
- # loop, which dispatches directly through Gate.
9
- class RoleScope
10
- attr_reader :container, :role, :correlation_id
11
-
12
- def initialize(container:, role:, dry_run: false, correlation_id: nil)
13
- @container = container
14
- @role = role.to_s
15
- @dry_run = dry_run
16
- @correlation_id = correlation_id || SecureRandom.uuid
17
- end
18
-
19
- def dry_run? = !!@dry_run
20
-
21
- def with_role(role)
22
- self.class.new(container: @container, role:, dry_run: @dry_run, correlation_id: @correlation_id)
23
- end
24
-
25
- def with_correlation_id(cid)
26
- self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
27
- end
28
-
29
- def with_dry_run
30
- self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
31
- end
32
- end
33
- end
34
- end