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
@@ -0,0 +1,66 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class PulseEntries
5
+ def initialize(manifest:, audit_log:, file_store:, orchestration:, job_store: nil)
6
+ @manifest = manifest
7
+ @audit_log = audit_log
8
+ @file_store = file_store
9
+ @orchestration = orchestration
10
+ @job_store = job_store
11
+ end
12
+
13
+ def call(command, call)
14
+ root = @manifest.data.root
15
+ since = command.since || Textus::Store::Cursor.new(root: root, role: call.role).read
16
+
17
+ changed = changed_since(since, call)
18
+
19
+ result = {
20
+ "cursor" => @audit_log.latest_seq,
21
+ "changed" => changed,
22
+ "pending_review" => review_keys(call),
23
+ "contract_etag" => Textus::Value::Etag.for_contract(root),
24
+ "index_etag" => index_etag,
25
+ }
26
+
27
+ Textus::Store::Cursor.new(root: root, role: call.role).write(result["cursor"])
28
+ Value::Result.success(result)
29
+ end
30
+
31
+ private
32
+
33
+ def changed_since(since, call)
34
+ if @job_store
35
+ sqlite_rows = @job_store.audit_events_since(seq: since)
36
+ return sqlite_rows.map { |r| { "key" => r["key"], "verb" => r["verb"], "seq" => r["seq"] } } if sqlite_rows.any?
37
+ end
38
+
39
+ # Fall back to flat-log scan when SQLite index is empty for this window
40
+ # (writes that bypassed dispatch, fresh stores, or pre-migration entries).
41
+ audit = @orchestration.audit_entries(seq_since: since, call: call)
42
+ return [] if audit.failure?
43
+
44
+ audit.value.fetch("rows") || []
45
+ end
46
+
47
+ def review_keys(call)
48
+ queue = @manifest.policy.queue_lane
49
+ return [] unless queue
50
+
51
+ result = @orchestration.list_keys(prefix: nil, lane: queue, call: call)
52
+ return [] unless result.success?
53
+
54
+ result.value.fetch("rows").map { |r| r["key"] }
55
+ end
56
+
57
+ def index_etag
58
+ path = @manifest.resolver.resolve("artifacts.system.index").path
59
+ File.exist?(path) ? @file_store.etag(path) : nil
60
+ rescue Textus::Error
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class RdepsEntry
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(command, _call)
10
+ rdeps = @manifest.data.entries.each_with_object([]) do |entry, acc|
11
+ next unless entry.external?
12
+
13
+ sources = Array(entry.source&.sources).compact
14
+ acc << entry.key if sources.any? { |source| source == command.key || command.key.start_with?("#{source}.") }
15
+ end
16
+ Value::Result.success("key" => command.key, "rdeps" => rdeps)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class UidEntry
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, _call)
10
+ envelope = Store::Entry::Reader.from(container: @container).read(command.key)
11
+ return Value::Result.failure(:not_found, "no entry at #{command.key}") unless envelope
12
+
13
+ Value::Result.success(envelope.uid)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class WhereEntry
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(command, _call)
10
+ res = @manifest.resolver.resolve(command.key)
11
+ mentry = res.entry
12
+ Value::Result.success("protocol" => Textus::PROTOCOL, "key" => command.key,
13
+ "lane" => mentry.lane, "owner" => mentry.owner, "path" => res.path)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class AcceptProposal
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ reader = Store::Entry::Reader.from(container: @container)
11
+ env = reader.read(command.pending_key)
12
+ proposal = env&.meta&.dig("proposal") or
13
+ return Value::Result.failure(:proposal_error, "entry has no proposal block: #{command.pending_key}")
14
+ target = proposal["target_key"] or
15
+ return Value::Result.failure(:proposal_error, "proposal missing target_key")
16
+ action = proposal["action"] || "put"
17
+
18
+ writer = Store::Entry::Writer.from(container: @container, call: call)
19
+ case action
20
+ when "put"
21
+ mentry = @container.manifest.resolver.resolve(target).entry
22
+ writer.put(
23
+ target, mentry: mentry,
24
+ payload: Textus::Value::Payload.new(meta: env.meta["_meta"] || {}, body: env.body, content: nil)
25
+ )
26
+ when "delete"
27
+ writer.delete(target)
28
+ else
29
+ return Value::Result.failure(:proposal_error, "unknown action: #{action}")
30
+ end
31
+
32
+ writer.delete(command.pending_key)
33
+ Value::Result.success("protocol" => Textus::PROTOCOL, "accepted" => command.pending_key,
34
+ "target_key" => target, "action" => action, "cascade_key" => target)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,55 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Handlers
5
+ module Write
6
+ class DataMv
7
+ def initialize(container:)
8
+ @container = container
9
+ end
10
+
11
+ def call(command, _call)
12
+ manifest = @container.manifest
13
+ geom = @container.layout
14
+
15
+ return Value::Result.failure(:usage_error, "from and to required") if command.from.nil? || command.to.nil?
16
+ unless manifest.data.declared_lane_kinds.key?(command.from)
17
+ return Value::Result.failure(:usage_error,
18
+ "data lane '#{command.from}' not declared")
19
+ end
20
+
21
+ dest_dir = geom.lane_path(command.to)
22
+ return Value::Result.failure(:usage_error, "destination 'data/#{command.to}' already exists") if File.exist?(dest_dir)
23
+
24
+ affected_keys = manifest.data.entries.select { |entry| entry.lane == command.from }.map(&:key)
25
+
26
+ steps = [{ "op" => "rename_zone", "from" => command.from, "to" => command.to }]
27
+ steps += affected_keys.map do |key|
28
+ { "op" => "mv", "from" => key, "to" => "#{command.to}#{key[command.from.length..]}" }
29
+ end
30
+
31
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: [])
32
+ return Value::Result.success(plan) if command.dry_run
33
+
34
+ rewrite_manifest!(geom, from: command.from, to: command.to)
35
+ FileUtils.mv(geom.lane_path(command.from), dest_dir)
36
+ Value::Result.success(plan)
37
+ end
38
+
39
+ private
40
+
41
+ def rewrite_manifest!(geom, from:, to:)
42
+ path = geom.manifest_path
43
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
44
+ raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
45
+ raw["entries"].each do |entry|
46
+ entry["lane"] = to if entry["lane"] == from
47
+ entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
48
+ entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
49
+ end
50
+ File.write(path, YAML.dump(raw))
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class DeleteKey
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ writer = Store::Entry::Writer.from(container: @container, call: call)
11
+ writer.delete(command.key, if_etag: command.if_etag)
12
+ Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true, "key" => command.key, "deleted" => true)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class EnqueueJob
5
+ def initialize(job_store:)
6
+ @job_store = job_store
7
+ end
8
+
9
+ def call(command, call)
10
+ action_class = Textus::Jobs.fetch(command.type.to_s)
11
+
12
+ if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
13
+ return Value::Result.failure(:forbidden,
14
+ "role '#{call.role}' is not authorized to enqueue this job type",
15
+ details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE })
16
+ end
17
+
18
+ job = Textus::Store::Jobs::Queue::Job.new(type: command.type, args: command.args, role: call.role, max_attempts: 3)
19
+ Textus::Store::Jobs::Queue.new(store: @job_store).enqueue(job)
20
+ Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id)
21
+ rescue Textus::UsageError
22
+ Value::Result.failure(:usage_error, "unregistered job type '#{command.type}'")
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class KeyDeletePrefix
5
+ def initialize(orchestration:)
6
+ @orchestration = orchestration
7
+ end
8
+
9
+ def call(command, call)
10
+ return Value::Result.failure(:usage_error, "prefix required") if command.prefix.nil? || command.prefix.empty?
11
+
12
+ list = @orchestration.list_keys(prefix: command.prefix, lane: nil, call: call)
13
+ return list if list.failure?
14
+
15
+ leaves = list.value.fetch("rows")
16
+
17
+ warnings = leaves.empty? ? ["no keys under #{command.prefix}"] : []
18
+ steps = leaves.map { |row| { "op" => "delete", "key" => row["key"] } }
19
+
20
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
21
+ return Value::Result.success(plan) if command.dry_run
22
+
23
+ steps.each do |step|
24
+ delete = @orchestration.delete_key(key: step["key"], call: call)
25
+ return delete if delete.failure?
26
+ end
27
+ Value::Result.success(plan)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class KeyMvPrefix
5
+ def initialize(orchestration:)
6
+ @orchestration = orchestration
7
+ end
8
+
9
+ def call(command, call)
10
+ if command.from_prefix.nil? || command.to_prefix.nil?
11
+ return Value::Result.failure(:usage_error,
12
+ "from_prefix and to_prefix required")
13
+ end
14
+
15
+ list = @orchestration.list_keys(prefix: command.from_prefix, lane: nil, call: call)
16
+ return list if list.failure?
17
+
18
+ leaves = list.value.fetch("rows")
19
+
20
+ if leaves.any? { |r| r["key"] == command.from_prefix }
21
+ return Value::Result.failure(:usage_error,
22
+ "from_prefix '#{command.from_prefix}' is itself a leaf — use `mv` to rename a single key")
23
+ end
24
+
25
+ warnings = leaves.empty? ? ["no keys under #{command.from_prefix}"] : []
26
+ steps = leaves.map do |row|
27
+ old_key = row["key"]
28
+ tail = old_key.delete_prefix("#{command.from_prefix}.")
29
+ new_key = "#{command.to_prefix}.#{tail}"
30
+ { "op" => "mv", "from" => old_key, "to" => new_key }
31
+ end
32
+
33
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
34
+ return Value::Result.success(plan) if command.dry_run
35
+
36
+ steps.each do |step|
37
+ move = @orchestration.move_key(old_key: step["from"], new_key: step["to"], call: call)
38
+ return move if move.failure?
39
+ end
40
+ Value::Result.success(plan)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,80 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class MoveKey
5
+ def initialize(container:, manifest:)
6
+ @container = container
7
+ @manifest = manifest
8
+ end
9
+
10
+ def call(command, call)
11
+ Textus::Manifest::Data.validate_key!(command.old_key)
12
+ Textus::Manifest::Data.validate_key!(command.new_key)
13
+
14
+ return Value::Result.failure(:usage_error, "mv: old and new keys are identical") if command.old_key == command.new_key
15
+
16
+ old_res = @manifest.resolver.resolve(command.old_key)
17
+ new_res = @manifest.resolver.resolve(command.new_key)
18
+
19
+ reader = Store::Entry::Reader.from(container: @container)
20
+
21
+ unless reader.exists?(command.old_key)
22
+ return Value::Result.failure(:not_found,
23
+ "source key '#{command.old_key}' not found")
24
+ end
25
+
26
+ zone_check = validate_zone(old_res.entry, new_res.entry)
27
+ return zone_check if zone_check
28
+
29
+ if reader.exists?(command.new_key)
30
+ return Value::Result.failure(:usage_error, "mv: target '#{command.new_key}' already exists at #{new_res.path}")
31
+ end
32
+
33
+ pre_env = reader.read(command.old_key)
34
+ writer = Store::Entry::Writer.from(container: @container, call: call)
35
+ unless pre_env.uid
36
+ writer.put(
37
+ command.old_key, mentry: old_res.entry,
38
+ payload: Textus::Value::Payload.new(meta: pre_env.meta, body: pre_env.body, content: pre_env.content)
39
+ )
40
+ end
41
+
42
+ if command.dry_run
43
+ return Value::Result.success({
44
+ "protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
45
+ "from_key" => command.old_key, "to_key" => command.new_key,
46
+ "from_path" => old_res.path, "to_path" => new_res.path,
47
+ "uid" => pre_env.uid
48
+ })
49
+ end
50
+
51
+ envelope = writer.move(
52
+ from_key: command.old_key, to_key: command.new_key,
53
+ new_mentry: new_res.entry
54
+ )
55
+
56
+ Value::Result.success({
57
+ "protocol" => Textus::PROTOCOL, "ok" => true,
58
+ "from_key" => command.old_key, "to_key" => command.new_key,
59
+ "from_path" => old_res.path, "to_path" => new_res.path,
60
+ "uid" => envelope.uid, "envelope" => envelope.to_h_for_wire
61
+ })
62
+ end
63
+
64
+ private
65
+
66
+ def validate_zone(old_mentry, new_mentry)
67
+ if old_mentry.lane != new_mentry.lane
68
+ return Value::Result.failure(:usage_error,
69
+ "mv: cross-zone refused (#{old_mentry.lane} -> #{new_mentry.lane}). Use put+delete.")
70
+ end
71
+ if old_mentry.format != new_mentry.format
72
+ return Value::Result.failure(:usage_error,
73
+ "mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
74
+ end
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class ProposeEntry
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ zone = @container.manifest.policy.propose_lane_for(call.role)
11
+ unless zone
12
+ return Value::Result.failure(:propose_forbidden,
13
+ "role '#{call.role}' has no writable propose_lane",
14
+ details: { "role" => call.role })
15
+ end
16
+
17
+ key = "#{zone}.#{command.key}"
18
+ mentry = @container.manifest.resolver.resolve(key).entry
19
+ writer = Store::Entry::Writer.from(container: @container, call: call)
20
+ envelope = writer.put(
21
+ key, mentry: mentry,
22
+ payload: Textus::Value::Payload.new(meta: command.meta || {}, body: command.body, content: command.content)
23
+ )
24
+ Value::Result.success(envelope)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class PutEntry
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ Textus::Manifest::Data.validate_key!(command.key)
11
+ mentry = @container.manifest.resolver.resolve(command.key).entry
12
+
13
+ writer = Store::Entry::Writer.from(container: @container, call: call)
14
+ envelope = writer.put(
15
+ command.key,
16
+ mentry: mentry,
17
+ payload: Textus::Value::Payload.new(
18
+ meta: command.meta,
19
+ body: command.body,
20
+ content: command.content,
21
+ ),
22
+ if_etag: command.if_etag,
23
+ )
24
+ Value::Result.success(envelope)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class RejectProposal
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ mentry = @container.manifest.resolver.resolve(command.pending_key).entry
11
+ unless mentry.in_proposal_lane?(@container.manifest.policy)
12
+ return Value::Result.failure(:proposal_error,
13
+ "reject: '#{command.pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
14
+ end
15
+
16
+ reader = Store::Entry::Reader.from(container: @container)
17
+ env = reader.read(command.pending_key)
18
+ proposal = env&.meta&.dig("proposal") or
19
+ return Value::Result.failure(:proposal_error, "entry has no proposal block: #{command.pending_key}")
20
+ target_key = proposal["target_key"]
21
+
22
+ writer = Store::Entry::Writer.from(container: @container, call: call)
23
+ writer.delete(command.pending_key, mentry: mentry)
24
+ Value::Result.success("protocol" => Textus::PROTOCOL, "rejected" => command.pending_key, "target_key" => target_key)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/textus/init.rb CHANGED
@@ -3,7 +3,7 @@ require "pathname"
3
3
 
4
4
  module Textus
5
5
  module Init
6
- ZONES = %w[knowledge notebook proposals artifacts raw].freeze
6
+ ZONES = %w[knowledge scratchpad proposals artifacts raw].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
9
  version: textus/4
@@ -13,13 +13,13 @@ module Textus
13
13
  - { name: automation, can: [converge] }
14
14
  lanes:
15
15
  - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
16
- - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
16
+ - { name: scratchpad, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
17
17
  - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
18
18
  - { name: artifacts, kind: machine, desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
19
19
  entries:
20
20
  - { key: knowledge.identity, path: data/knowledge/identity.md, lane: knowledge, schema: null, owner: human:self, kind: leaf }
21
21
  - { key: knowledge.notes, path: data/knowledge/notes, lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
- - { key: notebook.notes, path: data/notebook/notes, lane: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
22
+ - { key: scratchpad.notes, path: data/scratchpad/notes, lane: scratchpad, schema: null, owner: agent:self, nested: true, kind: nested }
23
23
  - { key: proposals.notes, path: data/proposals/notes, lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
24
24
  # A per-host snapshot, populated by a registered workflow. Nested so it
25
25
  # grows to a fleet — add leaves over SSH without renaming. tracked:false →
@@ -92,7 +92,7 @@ module Textus
92
92
  end
93
93
 
94
94
  def self.setup_state_dirs(target_root)
95
- FileUtils.mkdir_p(Textus::Store::Geometry.new(target_root).audit_dir_path)
95
+ FileUtils.mkdir_p(Textus::Store::Layout.new(target_root).audit_dir_path)
96
96
  end
97
97
 
98
98
  def self.write_gitignore(target_root)
@@ -151,7 +151,7 @@ module Textus
151
151
  Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
152
152
  end
153
153
  end
154
- Textus::Store::Geometry.new(target_root).gitignore_body(untracked_entries: untracked)
154
+ Textus::Store::Layout.new(target_root).gitignore_body(untracked_entries: untracked)
155
155
  end
156
156
  end
157
157
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  # Fallback role set for a manifest that omits `roles:` entirely. Agent
8
8
  # is intentionally minimal here (`propose` only) — narrower than the
9
9
  # `textus init` scaffold, which declares `agent: [propose, keep]` so the
10
- # default `notebook` workspace is writable. A roles-less manifest that
10
+ # default `scratchpad` workspace is writable. A roles-less manifest that
11
11
  # declares a `kind: workspace` zone is therefore rejected at load (no
12
12
  # `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
13
13
  DEFAULT_MAPPING = {
@@ -4,7 +4,7 @@ module Textus
4
4
  class Base < Entry
5
5
  attr_reader :raw, :key, :path, :lane, :schema, :owner, :format, :publish_targets
6
6
 
7
- # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
7
+ # rubocop:disable Lint/MissingSuper
8
8
  def initialize(raw:, key:, path:, lane:, schema:, owner:, format:, publish_targets: [])
9
9
  @raw = raw
10
10
  @key = key
@@ -15,7 +15,7 @@ module Textus
15
15
  @format = format
16
16
  @publish_targets = Array(publish_targets)
17
17
  end
18
- # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
18
+ # rubocop:enable Lint/MissingSuper
19
19
 
20
20
  def lane_writers(policy)
21
21
  verb = policy.verb_for_lane(@lane)
@@ -75,7 +75,7 @@ module Textus
75
75
  # Read a named template from the store's templates/ directory.
76
76
  # Raises TemplateError when the file doesn't exist.
77
77
  def read_template(name)
78
- path = container.geometry.template_path(name)
78
+ path = container.layout.template_path(name)
79
79
  unless File.exist?(path)
80
80
  raise Textus::TemplateError.new(
81
81
  "template '#{name}' not found",
@@ -14,7 +14,7 @@ module Textus
14
14
  @publisher = publisher
15
15
  end
16
16
 
17
- def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
17
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
18
18
  targets = entry.publish_targets.select(&:to_target?)
19
19
 
20
20
  return nil if targets.empty?
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class AuthorHeld
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ holders = manifest.policy.roles_with_capability("author")
8
+ pass = holders.include?(actor.to_s)
9
+ reason = if pass
10
+ nil
11
+ elsif holders.empty?
12
+ "no role holds the 'author' capability; #{action} is disabled"
13
+ else
14
+ "role '#{actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
15
+ end
16
+ { pass:, reason: }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class EtagMatch
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ if_etag = extra[:if_etag]
8
+ return { pass: true } if if_etag.nil?
9
+
10
+ current = envelope&.etag
11
+ pass = current.nil? || current == if_etag
12
+ { pass:, error: pass ? nil : Textus::EtagMismatch.new(key, if_etag, current) }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policy
4
+ module Predicates
5
+ class FreshWithin
6
+ def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
7
+ { pass: true }
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end