textus 0.53.0 → 0.54.1

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +53 -73
  4. data/SPEC.md +2 -5
  5. data/docs/architecture/README.md +149 -159
  6. data/lib/textus/action/accept.rb +0 -9
  7. data/lib/textus/action/audit.rb +0 -2
  8. data/lib/textus/action/blame.rb +0 -2
  9. data/lib/textus/action/boot.rb +2 -11
  10. data/lib/textus/action/data_mv.rb +1 -3
  11. data/lib/textus/action/deps.rb +1 -15
  12. data/lib/textus/action/doctor.rb +0 -2
  13. data/lib/textus/action/drain.rb +4 -6
  14. data/lib/textus/action/enqueue.rb +3 -5
  15. data/lib/textus/action/get.rb +1 -3
  16. data/lib/textus/action/ingest.rb +135 -0
  17. data/lib/textus/action/jobs.rb +1 -3
  18. data/lib/textus/action/key_delete.rb +0 -10
  19. data/lib/textus/action/key_delete_prefix.rb +1 -3
  20. data/lib/textus/action/key_mv.rb +1 -15
  21. data/lib/textus/action/key_mv_prefix.rb +1 -3
  22. data/lib/textus/action/list.rb +0 -2
  23. data/lib/textus/action/propose.rb +0 -2
  24. data/lib/textus/action/published.rb +0 -2
  25. data/lib/textus/action/pulse.rb +7 -38
  26. data/lib/textus/action/put.rb +1 -12
  27. data/lib/textus/action/rdeps.rb +2 -12
  28. data/lib/textus/action/reject.rb +0 -12
  29. data/lib/textus/action/rule_explain.rb +0 -4
  30. data/lib/textus/action/rule_lint.rb +1 -3
  31. data/lib/textus/action/rule_list.rb +0 -4
  32. data/lib/textus/action/schema_envelope.rb +0 -2
  33. data/lib/textus/action/uid.rb +0 -2
  34. data/lib/textus/action/where.rb +0 -2
  35. data/lib/textus/action/write_verb.rb +4 -18
  36. data/lib/textus/boot.rb +40 -109
  37. data/lib/textus/command.rb +6 -1
  38. data/lib/textus/container.rb +21 -5
  39. data/lib/textus/core/freshness/evaluator.rb +18 -34
  40. data/lib/textus/core/freshness/verdict.rb +1 -1
  41. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  42. data/lib/textus/doctor/check/protocol_version.rb +6 -6
  43. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  44. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
  45. data/lib/textus/doctor/check.rb +0 -1
  46. data/lib/textus/doctor.rb +3 -42
  47. data/lib/textus/envelope/reader.rb +46 -0
  48. data/lib/textus/envelope/writer.rb +209 -0
  49. data/lib/textus/envelope.rb +19 -6
  50. data/lib/textus/errors.rb +9 -5
  51. data/lib/textus/{entry → format}/base.rb +1 -6
  52. data/lib/textus/{entry → format}/json.rb +1 -4
  53. data/lib/textus/{entry → format}/markdown.rb +2 -6
  54. data/lib/textus/{entry → format}/text.rb +2 -6
  55. data/lib/textus/{entry → format}/yaml.rb +1 -4
  56. data/lib/textus/{entry.rb → format.rb} +9 -10
  57. data/lib/textus/gate/auth.rb +58 -12
  58. data/lib/textus/gate.rb +2 -0
  59. data/lib/textus/init.rb +16 -79
  60. data/lib/textus/jobs/base.rb +23 -0
  61. data/lib/textus/jobs/materialize.rb +20 -0
  62. data/lib/textus/{background → jobs}/plan.rb +1 -1
  63. data/lib/textus/jobs/planner.rb +101 -0
  64. data/lib/textus/jobs/retention.rb +48 -0
  65. data/lib/textus/jobs/sweep.rb +27 -0
  66. data/lib/textus/{background → jobs}/worker.rb +2 -2
  67. data/lib/textus/jobs.rb +15 -0
  68. data/lib/textus/key/path.rb +1 -1
  69. data/lib/textus/manifest/entry/base.rb +4 -19
  70. data/lib/textus/manifest/entry/parser.rb +27 -10
  71. data/lib/textus/manifest/entry/produced.rb +13 -34
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  73. data/lib/textus/manifest/entry/publish.rb +0 -12
  74. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  75. data/lib/textus/manifest/entry/validators.rb +0 -1
  76. data/lib/textus/manifest/policy/source.rb +14 -62
  77. data/lib/textus/manifest/policy.rb +2 -5
  78. data/lib/textus/manifest/resolver.rb +2 -2
  79. data/lib/textus/manifest/schema/contract.rb +61 -0
  80. data/lib/textus/manifest/schema/keys.rb +6 -20
  81. data/lib/textus/manifest/schema/semantics.rb +232 -0
  82. data/lib/textus/manifest/schema/validator.rb +24 -302
  83. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  84. data/lib/textus/manifest/schema.rb +1 -4
  85. data/lib/textus/manifest.rb +1 -3
  86. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  87. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  88. data/lib/textus/produce/engine.rb +50 -0
  89. data/lib/textus/produce/render.rb +20 -0
  90. data/lib/textus/session.rb +15 -8
  91. data/lib/textus/store.rb +5 -14
  92. data/lib/textus/surfaces/mcp/catalog.rb +23 -11
  93. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  94. data/lib/textus/surfaces/mcp/server.rb +87 -110
  95. data/lib/textus/surfaces/role_scope.rb +0 -4
  96. data/lib/textus/surfaces/watcher.rb +3 -3
  97. data/lib/textus/types.rb +15 -0
  98. data/lib/textus/version.rb +2 -2
  99. data/lib/textus/workflow/collector.rb +27 -0
  100. data/lib/textus/workflow/context.rb +5 -0
  101. data/lib/textus/workflow/dsl.rb +44 -0
  102. data/lib/textus/workflow/loader.rb +17 -0
  103. data/lib/textus/workflow/pattern.rb +18 -0
  104. data/lib/textus/workflow/registry.rb +21 -0
  105. data/lib/textus/workflow/runner.rb +99 -0
  106. data/lib/textus/workflow.rb +28 -0
  107. data/lib/textus.rb +12 -1
  108. metadata +67 -66
  109. data/lib/textus/action/pulse/scanner.rb +0 -118
  110. data/lib/textus/background/job/base.rb +0 -27
  111. data/lib/textus/background/job/materialize.rb +0 -31
  112. data/lib/textus/background/job/refresh.rb +0 -22
  113. data/lib/textus/background/job/sweep.rb +0 -31
  114. data/lib/textus/background/job.rb +0 -19
  115. data/lib/textus/background/planner/plan.rb +0 -113
  116. data/lib/textus/background/retention/apply.rb +0 -50
  117. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  118. data/lib/textus/doctor/check/hooks.rb +0 -25
  119. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  120. data/lib/textus/envelope/io/reader.rb +0 -48
  121. data/lib/textus/envelope/io/writer.rb +0 -211
  122. data/lib/textus/events.rb +0 -21
  123. data/lib/textus/init/templates/machine_intake.rb +0 -49
  124. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  125. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  126. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  127. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  128. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  129. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  130. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  131. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  132. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  133. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  134. data/lib/textus/pipeline/engine.rb +0 -97
  135. data/lib/textus/pipeline/render.rb +0 -25
  136. data/lib/textus/ports/audit_subscriber.rb +0 -42
  137. data/lib/textus/projection.rb +0 -103
  138. data/lib/textus/step/base.rb +0 -35
  139. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  140. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  141. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  142. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  143. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  144. data/lib/textus/step/builtin.rb +0 -22
  145. data/lib/textus/step/catalog.rb +0 -37
  146. data/lib/textus/step/context.rb +0 -74
  147. data/lib/textus/step/discovery.rb +0 -24
  148. data/lib/textus/step/error_log.rb +0 -32
  149. data/lib/textus/step/event_bus.rb +0 -113
  150. data/lib/textus/step/fetch.rb +0 -13
  151. data/lib/textus/step/fire_report.rb +0 -23
  152. data/lib/textus/step/loader.rb +0 -108
  153. data/lib/textus/step/observe.rb +0 -31
  154. data/lib/textus/step/registry_store.rb +0 -66
  155. data/lib/textus/step/signature.rb +0 -31
  156. data/lib/textus/step/transform.rb +0 -12
  157. data/lib/textus/step/validate.rb +0 -11
  158. data/lib/textus/step.rb +0 -10
  159. data/lib/textus/surfaces/mcp/session.rb +0 -9
  160. data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ class Envelope
3
+ # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
4
+ # bytes, parses them via the format strategy, and hands back an
5
+ # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
6
+ # (existing-uid lookup for the uid-preservation step in #put).
7
+ #
8
+ # No audit, no events, no permission checks — those live one layer up.
9
+ class Reader
10
+ def self.from(container:)
11
+ new(file_store: container.file_store, manifest: container.manifest)
12
+ end
13
+
14
+ def initialize(file_store:, manifest:)
15
+ @file_store = file_store
16
+ @manifest = manifest
17
+ end
18
+
19
+ def read(key)
20
+ res = @manifest.resolver.resolve(key)
21
+ path = res.path
22
+ return nil unless @file_store.exists?(path)
23
+
24
+ mentry = res.entry
25
+ raw = @file_store.read(path)
26
+ parsed = Format.for(mentry.format).parse(raw, path: path)
27
+ Textus::Envelope.build(
28
+ key: key, mentry: mentry, path: path,
29
+ meta: parsed["_meta"], body: parsed["body"],
30
+ etag: Etag.for_bytes(raw), content: parsed["content"]
31
+ )
32
+ end
33
+
34
+ def existing_uid(key)
35
+ env = read(key)
36
+ env&.uid
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def exists?(key)
42
+ @file_store.exists?(@manifest.resolver.resolve(key).path)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,209 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Envelope
5
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit).
6
+ # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
7
+ # Reader for the existing-uid lookup.
8
+ #
9
+ # Invariant: every public method's final action is @audit_log.append(...).
10
+ #
11
+ # No permission check, no event firing — those belong to the caller
12
+ # (Write::Put / ::Delete / ::Mv).
13
+ class Writer
14
+ Payload = Data.define(:meta, :body, :content)
15
+
16
+ def self.from(container:, call:)
17
+ new(
18
+ file_store: container.file_store, manifest: container.manifest,
19
+ schemas: container.schemas, audit_log: container.audit_log,
20
+ call: call, reader: Reader.from(container: container)
21
+ )
22
+ end
23
+
24
+ def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
25
+ @file_store = file_store
26
+ @manifest = manifest
27
+ @schemas = schemas
28
+ @audit_log = audit_log
29
+ @call = call
30
+ @reader = reader
31
+ end
32
+
33
+ def put(key, mentry:, payload:, if_etag: nil)
34
+ path = resolve_path(key)
35
+ meta, content = prepare_uid(mentry, payload, key)
36
+ bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
37
+ enforce_name_match!(path, eff_meta, mentry.format)
38
+ validate_schema(mentry, eff_meta, eff_content)
39
+ etag_before = check_etag!(path, key, if_etag)
40
+ write_bytes(path, bytes)
41
+ envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
42
+ audit_put(key, etag_before, envelope.etag)
43
+ envelope
44
+ end
45
+
46
+ def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
47
+ # `mentry:` is accepted for symmetry with `put` / `move` and to
48
+ # leave room for future format-specific delete hooks; no field
49
+ # on it is needed today.
50
+ path = @manifest.resolver.resolve(key).path
51
+ raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
52
+
53
+ etag_before = @file_store.etag(path)
54
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
55
+
56
+ @file_store.delete(path)
57
+ prune_empty_parents(path)
58
+ @audit_log.append(
59
+ role: @call.role, verb: "key_delete", key: key,
60
+ etag_before: etag_before, etag_after: nil,
61
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
62
+ )
63
+ end
64
+
65
+ def move(from_key:, to_key:, new_mentry:, if_etag: nil)
66
+ from_path = @manifest.resolver.resolve(from_key).path
67
+ to_path = @manifest.resolver.resolve(to_key).path
68
+ raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
69
+
70
+ etag_before = @file_store.etag(from_path)
71
+ raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
72
+
73
+ FileUtils.mkdir_p(File.dirname(to_path))
74
+ FileUtils.mv(from_path, to_path)
75
+ prune_empty_parents(from_path)
76
+ basename = to_key.split(".").last
77
+ Format.for(new_mentry.format).rewrite_name(to_path, basename)
78
+ etag_after = Etag.for_file(to_path)
79
+
80
+ raw = @file_store.read(to_path)
81
+ parsed = Format.for(new_mentry.format).parse(raw, path: to_path)
82
+ envelope = Textus::Envelope.build(
83
+ key: to_key, mentry: new_mentry, path: to_path,
84
+ meta: parsed["_meta"], body: parsed["body"],
85
+ etag: etag_after, content: parsed["content"]
86
+ )
87
+
88
+ extras = {
89
+ "from_key" => from_key, "to_key" => to_key,
90
+ "from_path" => from_path, "to_path" => to_path,
91
+ "uid" => envelope.uid
92
+ }
93
+ extras["correlation_id"] = @call.correlation_id if @call.correlation_id
94
+
95
+ @audit_log.append(
96
+ role: @call.role, verb: "key_mv", key: to_key,
97
+ etag_before: etag_before, etag_after: etag_after,
98
+ extras: extras
99
+ )
100
+
101
+ envelope
102
+ end
103
+
104
+ private
105
+
106
+ # After a file leaves a directory (delete or move-source), remove any
107
+ # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
108
+ # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
109
+ # declared, first-class container, so its own dir is preserved even when
110
+ # momentarily empty; only the sub-dirs the bulk op carved out are
111
+ # pruned. Stops at the first non-empty ancestor, so a dir holding a
112
+ # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
113
+ # non-empty dir is silently fine, never fatal to the write.
114
+ def prune_empty_parents(path)
115
+ floor = zone_floor(path)
116
+ return unless floor
117
+
118
+ dir = File.dirname(path)
119
+ while dir.start_with?("#{floor}/") && Dir.empty?(dir)
120
+ Dir.rmdir(dir)
121
+ dir = File.dirname(dir)
122
+ end
123
+ rescue SystemCallError
124
+ nil
125
+ end
126
+
127
+ # The zone directory under which `path` lives (`<root>/zones/<zone>`),
128
+ # or nil if `path` is not under the store's zones tree.
129
+ def zone_floor(path)
130
+ zones_root = File.join(@manifest.data.root, "data")
131
+ prefix = "#{zones_root}/"
132
+ return nil unless path.start_with?(prefix)
133
+
134
+ zone_seg = path.delete_prefix(prefix).split("/").first
135
+ zone_seg && File.join(zones_root, zone_seg)
136
+ end
137
+
138
+ def ensure_uid(format, meta, content, existing_uid)
139
+ Textus::Format.for(format).inject_uid(meta, content, existing_uid)
140
+ end
141
+
142
+ def enforce_name_match!(path, meta, format)
143
+ Textus::Format.for(format).enforce_name_match!(path, meta)
144
+ end
145
+
146
+ def serialize_for_put(mentry:, path:, meta:, body:, content:)
147
+ Textus::Format.for(mentry.format).serialize_for_put(
148
+ meta: meta, body: body, content: content, path: path,
149
+ )
150
+ end
151
+
152
+ def resolve_path(key)
153
+ @manifest.resolver.resolve(key).path
154
+ end
155
+
156
+ def prepare_uid(mentry, payload, key)
157
+ meta = payload.meta || {}
158
+ existing_uid = @reader.existing_uid(key)
159
+ ensure_uid(mentry.format, meta, payload.content, existing_uid)
160
+ end
161
+
162
+ def serialize_entry(mentry, path, meta, payload, content)
163
+ serialize_for_put(
164
+ mentry: mentry, path: path,
165
+ meta: meta, body: payload.body, content: content
166
+ )
167
+ end
168
+
169
+ def validate_schema(mentry, eff_meta, eff_content)
170
+ schema = @schemas.fetch_or_nil(mentry.schema)
171
+ return unless schema
172
+
173
+ Format.for(mentry.format).validate_against(
174
+ schema,
175
+ { "_meta" => eff_meta, "content" => eff_content },
176
+ )
177
+ end
178
+
179
+ def check_etag!(path, key, if_etag)
180
+ etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
181
+ raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
182
+
183
+ etag_before
184
+ end
185
+
186
+ def write_bytes(path, bytes)
187
+ @file_store.write(path, bytes)
188
+ end
189
+
190
+ def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
191
+ Textus::Envelope.build(
192
+ key: key, mentry: mentry, path: path,
193
+ meta: eff_meta, body: eff_body,
194
+ etag: Etag.for_bytes(@file_store.read(path)),
195
+ content: eff_content
196
+ )
197
+ end
198
+
199
+ def audit_put(key, etag_before, etag_after)
200
+ extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
201
+ @audit_log.append(
202
+ role: @call.role, verb: "put", key: key,
203
+ etag_before: etag_before, etag_after: etag_after,
204
+ extras: extras
205
+ )
206
+ end
207
+ end
208
+ end
209
+ end
@@ -1,10 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry-struct"
4
+
3
5
  module Textus
4
- Envelope = Data.define(
5
- :protocol, :key, :lane, :owner, :path, :format,
6
- :uid, :etag, :schema_ref, :meta, :body, :content, :freshness
7
- ) do
6
+ class Envelope < Dry::Struct
7
+ attribute :protocol, Types::String
8
+ attribute :key, Types::String
9
+ attribute :lane, Types::String
10
+ attribute :owner, Types::String.optional
11
+ attribute :path, Types::String
12
+ attribute :format, Types::FormatName
13
+ attribute :etag, Types::String
14
+ attribute :uid, Types::String.optional
15
+ attribute :schema_ref, Types::String.optional
16
+ attribute :meta, Types::Hash.default({}.freeze)
17
+ attribute :body, Types::String.optional
18
+ attribute :content, Types::Any.optional
19
+ attribute :freshness, Types::Any.optional
20
+
8
21
  # rubocop:disable Metrics/ParameterLists
9
22
  def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
10
23
  # rubocop:enable Metrics/ParameterLists
@@ -30,6 +43,8 @@ module Textus
30
43
  v.is_a?(String) ? v : nil
31
44
  end
32
45
 
46
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
47
+
33
48
  def to_h_for_wire
34
49
  h = {
35
50
  "protocol" => protocol,
@@ -49,8 +64,6 @@ module Textus
49
64
  h
50
65
  end
51
66
 
52
- alias_method :zone, :lane
53
-
54
67
  def stale?
55
68
  return false if freshness.nil?
56
69
 
data/lib/textus/errors.rb CHANGED
@@ -158,10 +158,6 @@ module Textus
158
158
  end
159
159
  end
160
160
 
161
- class InvalidProjection < Error
162
- def initialize(m) = super("invalid_projection", m)
163
- end
164
-
165
161
  class TemplateError < Error
166
162
  def initialize(m, template_name: nil)
167
163
  hint =
@@ -209,11 +205,19 @@ module Textus
209
205
  end
210
206
  end
211
207
 
208
+ class ContractDrift < Error
209
+ JSONRPC_CODE = -32_001
210
+
211
+ def initialize(message, details: {})
212
+ super("contract_drift", message, details: details)
213
+ end
214
+ end
215
+
212
216
  class FlagRenamed < Error
213
217
  def initialize(old_flag, new_flag)
214
218
  super(
215
219
  "flag_renamed",
216
- "#{old_flag} was renamed in textus/3 — use #{new_flag}",
220
+ "#{old_flag} was renamed in textus/4 — use #{new_flag}",
217
221
  details: { "old" => old_flag, "new" => new_flag },
218
222
  hint: "Use #{new_flag} instead.",
219
223
  exit_code: 2,
@@ -1,8 +1,5 @@
1
1
  module Textus
2
- module Entry
3
- # Abstract base for entry format strategies. Each concrete strategy
4
- # owns parsing, serialization, file-extension claims, and schema
5
- # validation for entries declared with its format.
2
+ module Format
6
3
  class Base
7
4
  def self.parse(_raw, path: nil)
8
5
  _ = path
@@ -20,8 +17,6 @@ module Textus
20
17
  raise NotImplementedError.new("#{name}.extensions not implemented")
21
18
  end
22
19
 
23
- # Default: validate the meta hash. Overridden by formats that put the
24
- # validatable payload elsewhere (json/yaml put it under "content").
25
20
  def self.validate_against(schema, parsed)
26
21
  schema.validate!(parsed["_meta"] || {})
27
22
  end
@@ -1,8 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # JSON entry storage. Top-level must be an object so we can carry _meta.
4
+ module Format
6
5
  class Json < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -23,7 +22,6 @@ module Textus
23
22
 
24
23
  def self.serialize(meta:, body:, content: nil)
25
24
  if content.is_a?(Hash)
26
- # Re-inject _meta as the first key so on-disk shape is stable.
27
25
  on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
26
  out = ::JSON.pretty_generate(on_disk)
29
27
  out += "\n" unless out.end_with?("\n")
@@ -61,7 +59,6 @@ module Textus
61
59
  end
62
60
  end
63
61
 
64
- # Mutating filesystem op; returns true if a write happened.
65
62
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
66
63
  raw = File.binread(path)
67
64
  parsed = parse(raw, path: path)
@@ -1,8 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # Markdown with YAML frontmatter. Original Entry implementation.
4
+ module Format
6
5
  class Markdown < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -26,7 +25,7 @@ module Textus
26
25
  end
27
26
 
28
27
  def self.serialize(meta:, body:, content: nil)
29
- _ = content # markdown ignores content
28
+ _ = content
30
29
  fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
31
30
  body = body.to_s
32
31
  body += "\n" unless body.empty? || body.end_with?("\n")
@@ -44,9 +43,6 @@ module Textus
44
43
  [bytes, meta, body.to_s, nil]
45
44
  end
46
45
 
47
- # Mutating filesystem op; returns true if a write happened (boolean is
48
- # informational, not a predicate). Rubocop's predicate-name heuristic
49
- # disabled here on purpose.
50
46
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
51
47
  raw = File.binread(path)
52
48
  parsed = parse(raw, path: path)
@@ -1,6 +1,5 @@
1
1
  module Textus
2
- module Entry
3
- # Plain-text entry storage. No frontmatter or structured content.
2
+ module Format
4
3
  class Text < Base
5
4
  def self.parse(raw, path: nil)
6
5
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -25,9 +24,7 @@ module Textus
25
24
  [meta, content]
26
25
  end
27
26
 
28
- def self.enforce_name_match!(_path, _meta)
29
- # text has no meta home; no-op
30
- end
27
+ def self.enforce_name_match!(_path, _meta); end
31
28
 
32
29
  def self.serialize_for_put(meta:, body:, content:, path:)
33
30
  _ = path
@@ -36,7 +33,6 @@ module Textus
36
33
  [bytes, meta, body.to_s, nil]
37
34
  end
38
35
 
39
- # No-op; text has no meta. Returns false (never writes).
40
36
  def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
41
37
  false
42
38
  end
@@ -1,8 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # YAML entry storage. Top-level must be a mapping so we can carry _meta.
4
+ module Format
6
5
  class Yaml < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -23,7 +22,6 @@ module Textus
23
22
 
24
23
  def self.serialize(meta:, body:, content: nil)
25
24
  if content.is_a?(Hash)
26
- # Re-inject _meta as the first key so on-disk shape is stable.
27
25
  on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
26
  ::YAML.dump(on_disk).sub(/\A---\n/, "")
29
27
  elsif body && !body.to_s.empty?
@@ -59,7 +57,6 @@ module Textus
59
57
  end
60
58
  end
61
59
 
62
- # Mutating filesystem op; returns true if a write happened.
63
60
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
64
61
  raw = File.binread(path)
65
62
  parsed = parse(raw, path: path)
@@ -1,13 +1,12 @@
1
1
  module Textus
2
- # Public entry-format dispatcher.
3
- module Entry
2
+ module Format
4
3
  SEP = "---".freeze
5
4
 
6
5
  STRATEGIES = {
7
- "markdown" => Markdown,
8
- "json" => Json,
9
- "yaml" => Yaml,
10
- "text" => Text,
6
+ "markdown" => -> { Format::Markdown },
7
+ "json" => -> { Format::Json },
8
+ "yaml" => -> { Format::Yaml },
9
+ "text" => -> { Format::Text },
11
10
  }.freeze
12
11
 
13
12
  EXT_TO_FORMAT = {
@@ -18,8 +17,8 @@ module Textus
18
17
  ".txt" => "text",
19
18
  }.freeze
20
19
 
21
- def self.for_format(format)
22
- STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
20
+ def self.for(format)
21
+ STRATEGIES.fetch(format.to_s) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
23
22
  end
24
23
 
25
24
  def self.infer_from_extension(ext)
@@ -31,11 +30,11 @@ module Textus
31
30
  end
32
31
 
33
32
  def self.parse(raw, path: nil, format: "markdown")
34
- for_format(format).parse(raw, path: path)
33
+ Format.for(format).parse(raw, path: path)
35
34
  end
36
35
 
37
36
  def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
38
- for_format(format).serialize(meta: meta, body: body, content: content)
37
+ Format.for(format).serialize(meta: meta, body: body, content: content)
39
38
  end
40
39
  end
41
40
  end
@@ -4,14 +4,15 @@ module Textus
4
4
  class Gate
5
5
  class Auth
6
6
  FLOOR = {
7
- put: %w[lane_writable_by],
8
- key_delete: %w[lane_writable_by],
9
- key_mv: %w[lane_writable_by],
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
10
  accept: %w[author_held],
11
11
  reject: %w[author_held],
12
- propose: %w[lane_writable_by],
13
- key_mv_prefix: %w[lane_writable_by],
14
- key_delete_prefix: %w[lane_writable_by],
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],
15
16
  }.freeze
16
17
 
17
18
  AuthContext = Struct.new(
@@ -104,12 +105,15 @@ module Textus
104
105
 
105
106
  def evaluate(pred_name, ctx, extra)
106
107
  case pred_name
107
- when "lane_writable_by" then evaluate_lane_writable_by(ctx)
108
- when "author_held" then evaluate_author_held(ctx)
109
- when "target_is_canon" then evaluate_target_is_canon(ctx)
110
- when "etag_match" then evaluate_etag_match(ctx, extra)
111
- when "schema_valid" then evaluate_schema_valid(ctx)
112
- when "fresh_within" then { pass: true }
108
+ when "lane_writable_by" then evaluate_lane_writable_by(ctx)
109
+ when "author_held" then evaluate_author_held(ctx)
110
+ when "target_is_canon" then evaluate_target_is_canon(ctx)
111
+ when "etag_match" then evaluate_etag_match(ctx, extra)
112
+ when "schema_valid" then evaluate_schema_valid(ctx)
113
+ when "fresh_within" then { pass: true }
114
+ when "raw_lane_ingest_only" then evaluate_raw_lane_ingest_only(ctx)
115
+ when "raw_write_once" then evaluate_raw_write_once(ctx)
116
+ when "lane_deletable_by" then evaluate_lane_deletable_by(ctx)
113
117
  else raise Textus::UsageError.new("unknown predicate '#{pred_name}'")
114
118
  end
115
119
  end
@@ -168,6 +172,48 @@ module Textus
168
172
  end
169
173
  end
170
174
 
175
+ def evaluate_raw_lane_ingest_only(ctx)
176
+ return { pass: true } unless @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
177
+ return { pass: true } if ctx.action == :ingest
178
+
179
+ { pass: false, error: Textus::Error.new(
180
+ :raw_lane_ingest_only,
181
+ "raw lane '#{ctx.mentry.lane}' only accepts `textus ingest` — " \
182
+ "use that verb instead of '#{ctx.action}'",
183
+ ) }
184
+ end
185
+
186
+ def evaluate_raw_write_once(ctx)
187
+ path = @manifest.resolver.resolve(ctx.target).path
188
+ return { pass: true } unless File.exist?(path)
189
+
190
+ { pass: false, error: Textus::Error.new(
191
+ :raw_write_once,
192
+ "raw entry '#{ctx.target}' already exists; " \
193
+ "delete it first (`textus key-delete #{ctx.target}`), then re-ingest",
194
+ ) }
195
+ end
196
+
197
+ # Deletion authority: the lane's write capability OR the author capability.
198
+ # On raw-kind lanes only the author capability grants deletion (correction
199
+ # escape hatch); the lane's own verb (ingest) is write-only. On all other
200
+ # lane kinds the behaviour matches lane_writable_by — the lane's writer
201
+ # can delete as before.
202
+ def evaluate_lane_deletable_by(ctx)
203
+ is_raw = @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
204
+ pass = if is_raw
205
+ ctx.actor_caps.include?("author")
206
+ else
207
+ ctx.actor_caps.include?(ctx.lane_verb.to_s) || ctx.actor_caps.include?("author")
208
+ end
209
+ return { pass: true } if pass
210
+
211
+ extra_holders = is_raw ? ["author"] : [ctx.lane_verb.to_s, "author"]
212
+ holders = extra_holders.flat_map { |v| @manifest.policy.roles_with_capability(v) }.uniq
213
+ { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane,
214
+ verb: ctx.lane_verb, holders:) }
215
+ end
216
+
171
217
  def schema_reason(err)
172
218
  d = err.details
173
219
  return err.message.dup unless d.is_a?(Hash)
data/lib/textus/gate.rb CHANGED
@@ -31,6 +31,7 @@ module Textus
31
31
  key_mv_prefix: Textus::Command::KeyMvPrefix,
32
32
  key_delete_prefix: Textus::Command::KeyDeletePrefix,
33
33
  drain: Textus::Command::Drain,
34
+ ingest: Textus::Command::Ingest,
34
35
  }.freeze
35
36
 
36
37
  ROUTES = {
@@ -62,6 +63,7 @@ module Textus
62
63
  Command::KeyMvPrefix => [Textus::Action::KeyMvPrefix],
63
64
  Command::KeyDeletePrefix => [Textus::Action::KeyDeletePrefix],
64
65
  Command::Drain => [Textus::Action::Drain],
66
+ Command::Ingest => [Textus::Action::Ingest],
65
67
  }.freeze
66
68
 
67
69
  def initialize(container)