textus 0.35.1 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -2
  3. data/README.md +34 -13
  4. data/SPEC.md +10 -4
  5. data/lib/textus/boot.rb +41 -21
  6. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  7. data/lib/textus/cli/verb/propose.rb +28 -0
  8. data/lib/textus/cli/verb/pulse.rb +12 -3
  9. data/lib/textus/cli/verb/schema.rb +1 -1
  10. data/lib/textus/cli/verb.rb +3 -2
  11. data/lib/textus/contract.rb +106 -0
  12. data/lib/textus/cursor_store.rb +24 -0
  13. data/lib/textus/dispatcher.rb +3 -1
  14. data/lib/textus/doctor/check/audit_log.rb +1 -1
  15. data/lib/textus/doctor/check/fetch_locks.rb +2 -2
  16. data/lib/textus/doctor/check/illegal_keys.rb +10 -4
  17. data/lib/textus/domain/policy/evaluation.rb +3 -6
  18. data/lib/textus/init.rb +4 -0
  19. data/lib/textus/layout.rb +41 -0
  20. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  21. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  22. data/lib/textus/maintenance/migrate.rb +9 -0
  23. data/lib/textus/maintenance/rule_lint.rb +8 -0
  24. data/lib/textus/maintenance/zone_mv.rb +10 -0
  25. data/lib/textus/manifest/entry/base.rb +5 -0
  26. data/lib/textus/manifest/entry/ignore_matcher.rb +46 -0
  27. data/lib/textus/manifest/entry/nested.rb +9 -2
  28. data/lib/textus/manifest/entry/validators/ignore.rb +28 -0
  29. data/lib/textus/manifest/entry/validators.rb +1 -0
  30. data/lib/textus/manifest/resolver.rb +2 -0
  31. data/lib/textus/manifest/schema.rb +1 -1
  32. data/lib/textus/mcp/catalog.rb +72 -0
  33. data/lib/textus/mcp/server.rb +8 -5
  34. data/lib/textus/mcp/session.rb +3 -20
  35. data/lib/textus/mcp/tool_schemas.rb +6 -62
  36. data/lib/textus/mcp/tools.rb +4 -119
  37. data/lib/textus/ports/audit_log.rb +17 -15
  38. data/lib/textus/ports/build_lock.rb +1 -2
  39. data/lib/textus/ports/fetch/lock.rb +1 -1
  40. data/lib/textus/read/audit.rb +3 -3
  41. data/lib/textus/read/boot.rb +6 -0
  42. data/lib/textus/read/get.rb +8 -0
  43. data/lib/textus/read/list.rb +8 -0
  44. data/lib/textus/read/pulse.rb +7 -0
  45. data/lib/textus/read/rules.rb +24 -0
  46. data/lib/textus/read/schema_envelope.rb +7 -0
  47. data/lib/textus/role.rb +6 -2
  48. data/lib/textus/session.rb +24 -0
  49. data/lib/textus/store.rb +11 -0
  50. data/lib/textus/version.rb +1 -1
  51. data/lib/textus/write/accept.rb +1 -1
  52. data/lib/textus/write/delete.rb +1 -1
  53. data/lib/textus/write/fetch_all.rb +8 -0
  54. data/lib/textus/write/fetch_worker.rb +9 -1
  55. data/lib/textus/write/mv.rb +1 -1
  56. data/lib/textus/write/propose.rb +46 -0
  57. data/lib/textus/write/put.rb +13 -1
  58. data/lib/textus/write/reject.rb +1 -1
  59. data/lib/textus.rb +4 -0
  60. metadata +15 -5
  61. data/docs/conventions.md +0 -148
@@ -1,129 +1,14 @@
1
1
  module Textus
2
2
  module MCP
3
- # Dispatch table for MCP tool names → implementations. Each implementation
4
- # receives (session:, store:, args:) and returns a JSON-encodable value.
5
- # Tool errors are wrapped in ToolError; ContractDrift / CursorExpired
6
- # propagate verbatim so the server can map them to JSON-RPC codes.
3
+ # Thin delegator kept for name stability (ADR 0039). The dispatch table
4
+ # and JSON schemas are now DERIVED from per-verb contracts by MCP::Catalog;
5
+ # this module only forwards.
7
6
  module Tools
8
7
  module_function
9
8
 
10
9
  def call(name, session:, store:, args:)
11
- impl = REGISTRY[name] or raise ToolError.new("unknown tool: #{name}")
12
- impl.call(session, store, args || {})
13
- rescue ContractDrift, CursorExpired
14
- raise
15
- rescue Textus::Error => e
16
- raise ToolError.new("#{name}: #{e.message}")
10
+ Catalog.call(name, session: session, store: store, args: args || {})
17
11
  end
18
-
19
- def ops_for(session, store)
20
- store.as(session.role)
21
- end
22
-
23
- REGISTRY = {
24
- "boot" => ->(_s, store, _a) { store.boot },
25
-
26
- "find" => lambda do |s, store, args|
27
- ops_for(s, store).list(zone: args["zone"], prefix: args["prefix"])
28
- end,
29
-
30
- "read" => lambda do |s, store, args|
31
- key = args.fetch("key") { raise ToolError.new("read: missing key") }
32
- env = ops_for(s, store).get(key)
33
- env.to_h_for_wire
34
- end,
35
-
36
- "tick" => lambda do |s, store, args|
37
- since = (args["since"] || s.cursor).to_i
38
- ops_for(s, store).pulse(since: since)
39
- end,
40
-
41
- "write" => lambda do |s, store, args|
42
- key = args.fetch("key") { raise ToolError.new("write: missing key") }
43
- env = ops_for(s, store).put(
44
- key,
45
- meta: args["meta"] || {},
46
- body: args["body"],
47
- content: args["content"],
48
- if_etag: args["if_etag"],
49
- )
50
- { "uid" => env.uid, "etag" => env.etag }
51
- end,
52
-
53
- "propose" => lambda do |s, store, args|
54
- raise ToolError.new("propose: session has no propose_zone") unless s.propose_zone
55
-
56
- rel = args.fetch("key") { raise ToolError.new("propose: missing key") }
57
- target = "#{s.propose_zone}.#{rel}"
58
- env = ops_for(s, store).put(
59
- target,
60
- meta: args["meta"] || {},
61
- body: args["body"],
62
- content: args["content"],
63
- )
64
- { "uid" => env.uid, "etag" => env.etag, "key" => target }
65
- end,
66
-
67
- "fetch" => lambda do |s, store, args|
68
- key = args.fetch("key") { raise ToolError.new("fetch: missing key") }
69
- outcome = ops_for(s, store).fetch(key)
70
- { "outcome" => outcome.class.name.split("::").last.downcase }
71
- end,
72
-
73
- "fetch_stale" => lambda do |s, store, args|
74
- ops_for(s, store).fetch_all(zone: args["zone"], prefix: args["prefix"])
75
- end,
76
-
77
- "schema" => lambda do |_s, store, args|
78
- family = args.fetch("family") { raise ToolError.new("schema: missing family") }
79
- store.schemas.fetch(family)
80
- end,
81
-
82
- "rules" => lambda do |_s, store, args|
83
- key = args.fetch("key") { raise ToolError.new("rules: missing key") }
84
- set = store.manifest.rules.for(key)
85
- {
86
- "fetch" => set.fetch&.to_h,
87
- "guard" => set.guard,
88
- }.compact
89
- end,
90
-
91
- "key_mv_prefix" => lambda do |s, store, args|
92
- ops_for(s, store).key_mv_prefix(
93
- from_prefix: args.fetch("from_prefix") { raise ToolError.new("key_mv_prefix: missing from_prefix") },
94
- to_prefix: args.fetch("to_prefix") { raise ToolError.new("key_mv_prefix: missing to_prefix") },
95
- dry_run: args["dry_run"] || false,
96
- ).to_h
97
- end,
98
-
99
- "key_delete_prefix" => lambda do |s, store, args|
100
- ops_for(s, store).key_delete_prefix(
101
- prefix: args.fetch("prefix") { raise ToolError.new("key_delete_prefix: missing prefix") },
102
- dry_run: args["dry_run"] || false,
103
- ).to_h
104
- end,
105
-
106
- "zone_mv" => lambda do |s, store, args|
107
- ops_for(s, store).zone_mv(
108
- from: args.fetch("from") { raise ToolError.new("zone_mv: missing from") },
109
- to: args.fetch("to") { raise ToolError.new("zone_mv: missing to") },
110
- dry_run: args["dry_run"] || false,
111
- ).to_h
112
- end,
113
-
114
- "rule_lint" => lambda do |s, store, args|
115
- ops_for(s, store).rule_lint(
116
- candidate_yaml: args.fetch("candidate_yaml") { raise ToolError.new("rule_lint: missing candidate_yaml") },
117
- ).to_h
118
- end,
119
-
120
- "migrate" => lambda do |s, store, args|
121
- ops_for(s, store).migrate(
122
- plan_yaml: args.fetch("plan_yaml") { raise ToolError.new("migrate: missing plan_yaml") },
123
- dry_run: args["dry_run"] || false,
124
- ).to_h
125
- end,
126
- }.freeze
127
12
  end
128
13
  end
129
14
  end
@@ -10,7 +10,7 @@ module Textus
10
10
 
11
11
  def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
12
12
  @root = root
13
- @path = File.join(root, "audit.log")
13
+ @path = Textus::Layout.audit_log(root)
14
14
  @max_size = max_size
15
15
  @keep = keep
16
16
  end
@@ -54,6 +54,7 @@ module Textus
54
54
  end
55
55
 
56
56
  def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
57
+ FileUtils.mkdir_p(File.dirname(@path))
57
58
  File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
58
59
  f.flock(File::LOCK_EX)
59
60
  next_seq = current_max_seq_unlocked + 1
@@ -81,6 +82,14 @@ module Textus
81
82
 
82
83
  private
83
84
 
85
+ def rotated(n)
86
+ File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}")
87
+ end
88
+
89
+ def rotated_meta(n)
90
+ File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}.meta.json")
91
+ end
92
+
84
93
  # Caller holds the flock. Returns the highest seq across the active log,
85
94
  # OR the most-recent rotated file's max_seq if the active log is empty.
86
95
  def current_max_seq_unlocked
@@ -113,7 +122,7 @@ module Textus
113
122
  end
114
123
 
115
124
  def read_meta(n)
116
- path = File.join(@root, "audit.log.#{n}.meta.json")
125
+ path = rotated_meta(n)
117
126
  return nil unless File.exist?(path)
118
127
 
119
128
  JSON.parse(File.read(path))
@@ -151,25 +160,18 @@ module Textus
151
160
  meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
152
161
 
153
162
  # Drop the file that would be shifted past @keep.
154
- oldest = File.join(@root, "audit.log.#{@keep}")
155
- oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
156
- FileUtils.rm_f(oldest)
157
- FileUtils.rm_f(oldest_meta)
163
+ FileUtils.rm_f(rotated(@keep))
164
+ FileUtils.rm_f(rotated_meta(@keep))
158
165
 
159
166
  # Shift .N → .(N+1) for N = keep-1 down to 1.
160
167
  (@keep - 1).downto(1) do |n|
161
- src = File.join(@root, "audit.log.#{n}")
162
- dst = File.join(@root, "audit.log.#{n + 1}")
163
- File.rename(src, dst) if File.exist?(src)
164
-
165
- src_meta = File.join(@root, "audit.log.#{n}.meta.json")
166
- dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
167
- File.rename(src_meta, dst_meta) if File.exist?(src_meta)
168
+ File.rename(rotated(n), rotated(n + 1)) if File.exist?(rotated(n))
169
+ File.rename(rotated_meta(n), rotated_meta(n + 1)) if File.exist?(rotated_meta(n))
168
170
  end
169
171
 
170
172
  # Active log → .1
171
- File.rename(@path, File.join(@root, "audit.log.1"))
172
- File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
173
+ File.rename(@path, rotated(1))
174
+ File.write(rotated_meta(1), JSON.generate(meta) + "\n")
173
175
  # Next append will create a fresh audit.log via File::CREAT.
174
176
  end
175
177
 
@@ -5,7 +5,6 @@ require "time"
5
5
  module Textus
6
6
  module Ports
7
7
  class BuildLock
8
- LOCK_FILENAME = ".build.lock"
9
8
  MAX_HOLDER_BYTES = 512
10
9
 
11
10
  def self.with(root:, &)
@@ -13,7 +12,7 @@ module Textus
13
12
  end
14
13
 
15
14
  def initialize(root:)
16
- @path = File.join(root, LOCK_FILENAME)
15
+ @path = Textus::Layout.build_lock(root)
17
16
  @file = nil
18
17
  end
19
18
 
@@ -7,7 +7,7 @@ module Textus
7
7
  def initialize(root:, key:)
8
8
  @root = root
9
9
  @key = key
10
- @path = File.join(root, ".locks", "#{safe_key}.lock")
10
+ @path = File.join(Textus::Layout.locks(root), "#{safe_key}.lock")
11
11
  @file = nil
12
12
  end
13
13
 
@@ -3,7 +3,7 @@ require "time"
3
3
 
4
4
  module Textus
5
5
  module Read
6
- # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
6
+ # Queries .textus/.run/audit/audit.log. Filters: key, zone, role, verb, since,
7
7
  # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
8
8
  # rows produce nil and are skipped).
9
9
  class Audit
@@ -33,7 +33,7 @@ module Textus
33
33
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
34
34
  @manifest = container.manifest
35
35
  @root = container.root
36
- @log_path = File.join(container.root, "audit.log")
36
+ @log_path = Textus::Layout.audit_log(container.root)
37
37
  @audit_log = container.audit_log
38
38
  end
39
39
 
@@ -84,7 +84,7 @@ module Textus
84
84
  end
85
85
 
86
86
  def all_log_files
87
- rotated = Dir.glob(File.join(@root, "audit.log.*"))
87
+ rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
88
88
  .reject { |p| p.end_with?(".meta.json") }
89
89
  .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
90
90
  active = File.exist?(@log_path) ? [@log_path] : []
@@ -5,6 +5,12 @@ module Textus
5
5
  # (container:, call:) entry point that Dispatcher::VERBS resolves to.
6
6
  # Boot is role-independent, so `call` is not consulted.
7
7
  class Boot
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :boot
11
+ summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
12
+ surfaces :cli, :ruby, :mcp
13
+
8
14
  def initialize(container:, call:)
9
15
  @container = container
10
16
  @call = call
@@ -6,6 +6,14 @@ module Textus
6
6
  # For interactive reads that want fetch-on-stale, use
7
7
  # `Read::GetOrFetch`, which composes this with the orchestrator.
8
8
  class Get
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :get
12
+ summary "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness)."
13
+ surfaces :cli, :ruby, :mcp
14
+ arg :key, String, required: true, positional: true
15
+ response(&:to_h_for_wire)
16
+
9
17
  def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
10
18
  @container = container
11
19
  @call = call
@@ -1,6 +1,14 @@
1
1
  module Textus
2
2
  module Read
3
3
  class List
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :list
7
+ summary "List keys filtered by zone and/or prefix."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :prefix, String
10
+ arg :zone, String
11
+
4
12
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
13
  @manifest = container.manifest
6
14
  end
@@ -7,6 +7,13 @@ module Textus
7
7
  # APIs; pulse is sugar with a stable envelope shape and a monotonic
8
8
  # cursor (seq).
9
9
  class Pulse
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :pulse
13
+ summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
14
+ surfaces :cli, :ruby, :mcp
15
+ arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
16
+
10
17
  def initialize(container:, call:)
11
18
  @container = container
12
19
  @call = call
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Read
3
+ # Effective rule set (fetch + guard) for a key. Was the inlined MCP
4
+ # `rules` tool; promoted to a first-class verb so MCP is a pure projection
5
+ # (ADR 0039).
6
+ class Rules
7
+ extend Textus::Contract::DSL
8
+
9
+ verb :rules
10
+ summary "Return effective rules for a key (fetch, guard, ...)."
11
+ surfaces :ruby, :mcp
12
+ arg :key, String, required: true, positional: true
13
+
14
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
15
+ @manifest = container.manifest
16
+ end
17
+
18
+ def call(key)
19
+ set = @manifest.rules.for(key)
20
+ { "fetch" => set.fetch&.to_h, "guard" => set.guard }.compact
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,6 +1,13 @@
1
1
  module Textus
2
2
  module Read
3
3
  class SchemaEnvelope
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :schema
7
+ summary "Return the schema (field shape) for an entry's family, by key."
8
+ surfaces :ruby, :mcp
9
+ arg :key, String, required: true, positional: true
10
+
4
11
  def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
12
  @manifest = container.manifest
6
13
  @schemas = container.schemas
data/lib/textus/role.rb CHANGED
@@ -2,9 +2,13 @@ module Textus
2
2
  module Role
3
3
  PATTERN = /\A[a-z][a-z0-9_-]*\z/
4
4
  DEFAULT = "human".freeze
5
+ # The default acting identity for the MCP transport (ADR 0040): an agent
6
+ # over stdio proposes; it does not inherit the human's authority. CLI
7
+ # callers keep the `human` DEFAULT.
8
+ AGENT = "agent".freeze
5
9
 
6
- def self.resolve(root:, flag: nil, env: ENV)
7
- candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || DEFAULT
10
+ def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
11
+ candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
8
12
  raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
9
13
 
10
14
  candidate
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ # The agent session: per-connection (MCP), per-process (CLI), or per-loop
3
+ # (Ruby) orientation state — the audit cursor plus the manifest etag and
4
+ # propose_zone captured at boot. Immutable Data value; advance_cursor
5
+ # returns a new instance. ADR 0036.
6
+ Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
7
+ def advance_cursor(new_cursor) = with(cursor: new_cursor)
8
+
9
+ def check_etag!(observed_etag)
10
+ return if observed_etag == manifest_etag
11
+
12
+ raise Textus::MCP::ContractDrift.new(
13
+ "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ # First 8 hex chars after the "sha256:" prefix — a stable short id for
20
+ # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
21
+ # a no-op when the prefix is absent).
22
+ def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
23
+ end
24
+ end
data/lib/textus/store.rb CHANGED
@@ -50,6 +50,17 @@ module Textus
50
50
  @container ||= Textus::Container.from_store(self)
51
51
  end
52
52
 
53
+ # Build an agent Session oriented at the current cursor/manifest — the
54
+ # Ruby equivalent of an MCP `initialize`. ADR 0036.
55
+ def session(role:)
56
+ Textus::Session.new(
57
+ role: role,
58
+ cursor: audit_log.latest_seq,
59
+ propose_zone: manifest.policy.propose_zone_for(role),
60
+ manifest_etag: file_store.etag(File.join(root, "manifest.yaml")),
61
+ )
62
+ end
63
+
53
64
  def as(role, dry_run: false, correlation_id: nil)
54
65
  RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
55
66
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.35.1"
2
+ VERSION = "0.39.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -18,7 +18,7 @@ module Textus
18
18
  guard.for(:accept, target).check!(
19
19
  Textus::Domain::Policy::Evaluation.new(
20
20
  actor: @call.role, transition: :accept, origin: pending_key,
21
- target: target, envelope: env, snapshot: @manifest
21
+ target: target, envelope: env, manifest: @manifest
22
22
  ),
23
23
  )
24
24
 
@@ -36,7 +36,7 @@ module Textus
36
36
  def eval_for(transition, target_key:, envelope: nil)
37
37
  Textus::Domain::Policy::Evaluation.new(
38
38
  actor: @call.role, transition: transition, origin: nil,
39
- target: target_key, envelope: envelope, snapshot: @manifest
39
+ target: target_key, envelope: envelope, manifest: @manifest
40
40
  )
41
41
  end
42
42
 
@@ -1,6 +1,14 @@
1
1
  module Textus
2
2
  module Write
3
3
  class FetchAll
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :fetch_all
7
+ summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :prefix, String
10
+ arg :zone, String
11
+
4
12
  def initialize(container:, call:)
5
13
  @container = container
6
14
  @call = call
@@ -3,6 +3,14 @@ require "timeout"
3
3
  module Textus
4
4
  module Write
5
5
  class FetchWorker
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :fetch
9
+ summary "Run a fetch action for one quarantine entry."
10
+ surfaces :cli, :ruby, :mcp
11
+ arg :key, String, required: true, positional: true
12
+ response { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
13
+
6
14
  FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
7
15
 
8
16
  def initialize(container:, call:)
@@ -101,7 +109,7 @@ module Textus
101
109
  ).for(:fetch, key).check!(
102
110
  Textus::Domain::Policy::Evaluation.new(
103
111
  actor: @call.role, transition: :fetch, origin: nil,
104
- target: key, envelope: nil, snapshot: @manifest
112
+ target: key, envelope: nil, manifest: @manifest
105
113
  ),
106
114
  )
107
115
  envelope = writer.put(
@@ -109,7 +109,7 @@ module Textus
109
109
  def eval_for(transition, target_key:, envelope: nil)
110
110
  Textus::Domain::Policy::Evaluation.new(
111
111
  actor: @call.role, transition: transition, origin: nil,
112
- target: target_key, envelope: envelope, snapshot: @manifest
112
+ target: target_key, envelope: envelope, manifest: @manifest
113
113
  )
114
114
  end
115
115
 
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Write
3
+ # Queue a proposal: resolve the acting role's propose_zone, prefix the key,
4
+ # and write there via the Put verb. Was inlined in the MCP `propose` tool
5
+ # and the CLI propose verb; promoted to a first-class verb so all three
6
+ # transports share one implementation (ADR 0036, ADR 0039).
7
+ class Propose
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :propose
11
+ summary "Write a proposal to the role's propose_zone. Auto-prefixes the key."
12
+ surfaces :cli, :ruby, :mcp
13
+ arg :key, String, required: true, positional: true,
14
+ description: "key relative to propose_zone, e.g. 'decisions.feature-x'"
15
+ arg :meta, Hash, required: true
16
+ arg :body, String
17
+ arg :content, Hash
18
+ response { |env| { "uid" => env.uid, "etag" => env.etag, "key" => env.key } }
19
+
20
+ def initialize(container:, call:)
21
+ @container = container
22
+ @call = call
23
+ @manifest = container.manifest
24
+ end
25
+
26
+ # if_etag is intentionally absent: a proposal is always a fresh queue write.
27
+ def call(key, meta: nil, body: nil, content: nil)
28
+ zone = @manifest.policy.propose_zone_for(@call.role)
29
+ unless zone
30
+ raise Textus::Error.new(
31
+ "propose_forbidden",
32
+ "role '#{@call.role}' has no writable propose_zone",
33
+ details: { "role" => @call.role },
34
+ hint: "the manifest must define a queue zone and '#{@call.role}' must hold the 'propose' capability",
35
+ )
36
+ end
37
+
38
+ Textus::Dispatcher.invoke(
39
+ :put, container: @container, call: @call,
40
+ args: ["#{zone}.#{key}"],
41
+ kwargs: { meta: meta || {}, body: body, content: content }
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,6 +1,18 @@
1
1
  module Textus
2
2
  module Write
3
3
  class Put
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :put
7
+ summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :key, String, required: true, positional: true
10
+ arg :meta, Hash, required: true
11
+ arg :body, String
12
+ arg :content, Hash
13
+ arg :if_etag, String
14
+ response { |env| { "uid" => env.uid, "etag" => env.etag } }
15
+
4
16
  def initialize(container:, call:)
5
17
  @container = container
6
18
  @call = call
@@ -41,7 +53,7 @@ module Textus
41
53
  def eval_for(transition, target_key:, envelope: nil)
42
54
  Textus::Domain::Policy::Evaluation.new(
43
55
  actor: @call.role, transition: transition, origin: nil,
44
- target: target_key, envelope: envelope, snapshot: @manifest
56
+ target: target_key, envelope: envelope, manifest: @manifest
45
57
  )
46
58
  end
47
59
 
@@ -13,7 +13,7 @@ module Textus
13
13
  guard.for(:reject, pending_key).check!(
14
14
  Textus::Domain::Policy::Evaluation.new(
15
15
  actor: @call.role, transition: :reject, origin: pending_key,
16
- target: pending_key, envelope: nil, snapshot: @manifest
16
+ target: pending_key, envelope: nil, manifest: @manifest
17
17
  ),
18
18
  )
19
19
 
data/lib/textus.rb CHANGED
@@ -20,6 +20,10 @@ loader.ignore(File.expand_path("textus/mcp/errors.rb", __dir__))
20
20
  loader.setup
21
21
  loader.eager_load
22
22
 
23
+ # Derive CLI_VERBS after eager_load so all contract-declaring files are present
24
+ # (boot.rb loads first alphabetically; Dispatcher contracts are declared later).
25
+ Textus::Boot::CLI_VERBS = Textus::Boot.build_cli_verbs.freeze
26
+
23
27
  module Textus
24
28
  @hook_mutex = Mutex.new
25
29
  @hook_blocks = []