textus 0.30.0 → 0.38.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -241
  3. data/CHANGELOG.md +221 -0
  4. data/README.md +89 -69
  5. data/SPEC.md +359 -212
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +122 -87
  8. data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
  9. data/lib/textus/cli/verb/build.rb +1 -1
  10. data/lib/textus/cli/verb/fetch.rb +14 -0
  11. data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
  12. data/lib/textus/cli/verb/get.rb +1 -1
  13. data/lib/textus/cli/verb/hooks.rb +1 -1
  14. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  15. data/lib/textus/cli/verb/propose.rb +28 -0
  16. data/lib/textus/cli/verb/pulse.rb +12 -3
  17. data/lib/textus/cli/verb/put.rb +1 -1
  18. data/lib/textus/cli/verb/rule_list.rb +7 -7
  19. data/lib/textus/cli/verb/schema.rb +1 -1
  20. data/lib/textus/cli/verb.rb +3 -2
  21. data/lib/textus/cli.rb +2 -2
  22. data/lib/textus/container.rb +1 -2
  23. data/lib/textus/contract.rb +106 -0
  24. data/lib/textus/cursor_store.rb +24 -0
  25. data/lib/textus/dispatcher.rb +6 -4
  26. data/lib/textus/doctor/check/audit_log.rb +1 -1
  27. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
  28. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  29. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  30. data/lib/textus/doctor.rb +2 -1
  31. data/lib/textus/domain/action.rb +3 -3
  32. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  33. data/lib/textus/domain/freshness/policy.rb +2 -2
  34. data/lib/textus/domain/freshness.rb +7 -7
  35. data/lib/textus/domain/outcome.rb +2 -2
  36. data/lib/textus/domain/permission.rb +2 -10
  37. data/lib/textus/domain/policy/base_guards.rb +25 -0
  38. data/lib/textus/domain/policy/evaluation.rb +15 -0
  39. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
  40. data/lib/textus/domain/policy/guard.rb +35 -0
  41. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  42. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  43. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  44. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  45. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  46. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  47. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  48. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  49. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +4 -4
  53. data/lib/textus/init.rb +27 -18
  54. data/lib/textus/layout.rb +41 -0
  55. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  56. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  57. data/lib/textus/maintenance/migrate.rb +9 -0
  58. data/lib/textus/maintenance/rule_lint.rb +8 -0
  59. data/lib/textus/maintenance/zone_mv.rb +11 -1
  60. data/lib/textus/manifest/capabilities.rb +29 -0
  61. data/lib/textus/manifest/data.rb +14 -10
  62. data/lib/textus/manifest/policy.rb +37 -21
  63. data/lib/textus/manifest/rules.rb +16 -14
  64. data/lib/textus/manifest/schema.rb +48 -58
  65. data/lib/textus/manifest.rb +3 -3
  66. data/lib/textus/mcp/catalog.rb +72 -0
  67. data/lib/textus/mcp/server.rb +8 -5
  68. data/lib/textus/mcp/session.rb +3 -20
  69. data/lib/textus/mcp/tool_schemas.rb +6 -62
  70. data/lib/textus/mcp/tools.rb +4 -119
  71. data/lib/textus/ports/audit_log.rb +17 -15
  72. data/lib/textus/ports/audit_subscriber.rb +1 -1
  73. data/lib/textus/ports/build_lock.rb +1 -2
  74. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  75. data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/read/audit.rb +3 -3
  78. data/lib/textus/read/boot.rb +6 -0
  79. data/lib/textus/read/freshness.rb +9 -9
  80. data/lib/textus/read/get.rb +16 -8
  81. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  82. data/lib/textus/read/list.rb +8 -0
  83. data/lib/textus/read/policy_explain.rb +14 -10
  84. data/lib/textus/read/pulse.rb +12 -4
  85. data/lib/textus/read/rules.rb +24 -0
  86. data/lib/textus/read/schema_envelope.rb +7 -0
  87. data/lib/textus/read/validator.rb +1 -1
  88. data/lib/textus/role.rb +6 -2
  89. data/lib/textus/schema/tools.rb +5 -5
  90. data/lib/textus/session.rb +24 -0
  91. data/lib/textus/store.rb +11 -0
  92. data/lib/textus/version.rb +1 -1
  93. data/lib/textus/write/accept.rb +19 -55
  94. data/lib/textus/write/delete.rb +14 -2
  95. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
  96. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  97. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
  98. data/lib/textus/write/mv.rb +15 -3
  99. data/lib/textus/write/propose.rb +46 -0
  100. data/lib/textus/write/put.rb +26 -2
  101. data/lib/textus/write/reject.rb +11 -5
  102. data/lib/textus.rb +4 -0
  103. metadata +36 -21
  104. data/lib/textus/cli/verb/refresh.rb +0 -14
  105. data/lib/textus/domain/authorizer.rb +0 -37
  106. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  107. data/lib/textus/domain/policy/promote.rb +0 -26
  108. data/lib/textus/domain/policy/promotion.rb +0 -57
  109. data/lib/textus/manifest/role_kinds.rb +0 -21
  110. data/lib/textus/write/authority_gate.rb +0 -24
data/lib/textus/cli.rb CHANGED
@@ -90,8 +90,8 @@ module Textus
90
90
  textus get KEY
91
91
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
92
92
  textus freshness [--prefix=KEY] [--zone=Z]
93
- textus refresh KEY
94
- textus refresh stale [--prefix=KEY] [--zone=Z]
93
+ textus fetch KEY
94
+ textus fetch stale [--prefix=KEY] [--zone=Z]
95
95
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
96
96
  textus blame KEY [--limit=N]
97
97
  textus doctor
@@ -3,7 +3,7 @@ module Textus
3
3
  # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
4
4
  Container = Data.define(
5
5
  :manifest, :file_store, :schemas, :root,
6
- :audit_log, :events, :rpc, :authorizer
6
+ :audit_log, :events, :rpc
7
7
  )
8
8
 
9
9
  class Container
@@ -16,7 +16,6 @@ module Textus
16
16
  audit_log: store.audit_log,
17
17
  events: store.events,
18
18
  rpc: store.rpc,
19
- authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest),
20
19
  )
21
20
  end
22
21
  end
@@ -0,0 +1,106 @@
1
+ module Textus
2
+ # Declarative, co-located interface contract for a verb. One source of truth
3
+ # for the agent-facing summary, the argument schema, which transports expose
4
+ # the verb, and how the return value is shaped for the wire. CLI/Ruby/MCP and
5
+ # boot project from this; the MCP catalog is fully derived from it (ADR 0039).
6
+ module Contract
7
+ # One argument of a verb. `positional: true` means it is passed to the
8
+ # use-case as a positional (e.g. `get(key)`); otherwise as a keyword.
9
+ # `session_default` names a zero-arg method on `Textus::Session` (Symbol)
10
+ # that supplies the value when the wire arg is absent; `nil` means no default.
11
+ Arg = Data.define(:name, :type, :required, :positional, :session_default, :description)
12
+
13
+ JSON_TYPES = {
14
+ String => "string", Integer => "integer", Hash => "object",
15
+ Array => "array", :boolean => "boolean"
16
+ }.freeze
17
+
18
+ def self.json_type(type)
19
+ JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
20
+ end
21
+
22
+ Spec = Data.define(:verb, :summary, :args, :surfaces, :response) do
23
+ def mcp? = surfaces.include?(:mcp)
24
+
25
+ def required_args = args.select(&:required)
26
+
27
+ # JSON-Schema object for MCP tools/list inputSchema.
28
+ # Outer keys (:type, :properties, :required) are symbols; inner property
29
+ # keys are strings — matches the MCP/JSON wire shape expected by clients.
30
+ def input_schema
31
+ props = args.to_h do |a|
32
+ h = { "type" => Contract.json_type(a.type) }
33
+ h["description"] = a.description if a.description
34
+ [a.name.to_s, h]
35
+ end
36
+ { type: "object", properties: props, required: required_args.map { |a| a.name.to_s } }
37
+ end
38
+ end
39
+
40
+ # Mixed onto a use-case class via `extend`. Calls accumulate into ivars,
41
+ # frozen into a Spec on first read of `.contract`.
42
+ module DSL
43
+ def verb(name = nil)
44
+ if name
45
+ raise "contract already built; declare verb before reading .contract" if defined?(@__contract) && @__contract
46
+
47
+ @__verb = name
48
+ else
49
+ @__verb
50
+ end
51
+ end
52
+
53
+ def summary(text = nil)
54
+ if text
55
+ raise "contract already built; declare summary before reading .contract" if defined?(@__contract) && @__contract
56
+
57
+ @__summary = text
58
+ else
59
+ @__summary
60
+ end
61
+ end
62
+
63
+ def surfaces(*list)
64
+ if list.empty?
65
+ @__surfaces ||= []
66
+ else
67
+ raise "contract already built; declare surfaces before reading .contract" if defined?(@__contract) && @__contract
68
+
69
+ @__surfaces = list
70
+ end
71
+ end
72
+
73
+ def arg(name, type, required: false, positional: false, session_default: nil, description: nil)
74
+ raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
75
+
76
+ (@__args ||= []) << Arg.new(
77
+ name: name, type: type, required: required,
78
+ positional: positional, session_default: session_default, description: description
79
+ )
80
+ end
81
+
82
+ def response(&blk)
83
+ @__response = blk if blk
84
+ @__response || ->(v) { v }
85
+ end
86
+
87
+ def contract?
88
+ !@__verb.nil?
89
+ end
90
+
91
+ # rubocop:disable Naming/MemoizedInstanceVariableName
92
+ # @__contract uses double-underscore to match the other accumulator ivars
93
+ # (@__verb, @__args, etc.) and avoid name collision with user-defined `@contract`.
94
+ def contract
95
+ @__contract ||= Spec.new(
96
+ verb: @__verb,
97
+ summary: @__summary,
98
+ args: (@__args || []).freeze,
99
+ surfaces: (@__surfaces || []).freeze,
100
+ response: response,
101
+ )
102
+ end
103
+ # rubocop:enable Naming/MemoizedInstanceVariableName
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,24 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ # Per-role cursor cache under <root>/.run/state/cursor.<role>. A convenience so
5
+ # `textus pulse` (no --since) means "since I last looked". Gitignored;
6
+ # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
7
+ class CursorStore
8
+ def initialize(root:, role:)
9
+ @path = Textus::Layout.cursor(root, role)
10
+ end
11
+
12
+ def read
13
+ Integer(File.read(@path).strip)
14
+ rescue Errno::ENOENT, ArgumentError
15
+ 0
16
+ end
17
+
18
+ def write(seq)
19
+ FileUtils.mkdir_p(File.dirname(@path))
20
+ File.write(@path, seq.to_s)
21
+ seq
22
+ end
23
+ end
24
+ end
@@ -6,18 +6,19 @@ module Textus
6
6
  VERBS = {
7
7
  # Write
8
8
  put: Textus::Write::Put,
9
+ propose: Textus::Write::Propose,
9
10
  delete: Textus::Write::Delete,
10
11
  mv: Textus::Write::Mv,
11
12
  accept: Textus::Write::Accept,
12
13
  reject: Textus::Write::Reject,
13
14
  publish: Textus::Write::Publish,
14
- refresh: Textus::Write::RefreshWorker,
15
- refresh_all: Textus::Write::RefreshAll,
15
+ fetch: Textus::Write::FetchWorker,
16
+ fetch_all: Textus::Write::FetchAll,
16
17
  retention_sweep: Textus::Write::RetentionSweep,
17
18
 
18
19
  # Read
19
20
  get: Textus::Read::Get,
20
- get_or_refresh: Textus::Read::GetOrRefresh,
21
+ get_or_fetch: Textus::Read::GetOrFetch,
21
22
  list: Textus::Read::List,
22
23
  where: Textus::Read::Where,
23
24
  uid: Textus::Read::Uid,
@@ -30,11 +31,12 @@ module Textus
30
31
  pulse: Textus::Read::Pulse,
31
32
  policy_explain: Textus::Read::PolicyExplain,
32
33
  published: Textus::Read::Published,
33
- schema_envelope: Textus::Read::SchemaEnvelope,
34
+ schema: Textus::Read::SchemaEnvelope,
34
35
  validate_all: Textus::Read::ValidateAll,
35
36
  doctor: Textus::Read::Doctor,
36
37
  boot: Textus::Read::Boot,
37
38
  retainable: Textus::Read::Retainable,
39
+ rules: Textus::Read::Rules,
38
40
 
39
41
  # Maintenance
40
42
  migrate: Textus::Maintenance::Migrate,
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class AuditLog < Check
5
5
  def call
6
- path = File.join(root, "audit.log")
6
+ path = Textus::Layout.audit_log(root)
7
7
  Textus::Ports::AuditLog.new(root).verify_integrity.map do |v|
8
8
  {
9
9
  "code" => "audit.parse_error",
@@ -1,15 +1,15 @@
1
1
  module Textus
2
2
  module Doctor
3
3
  class Check
4
- # Lists per-key refresh lock files under <root>/.locks/ whose
4
+ # Lists per-key fetch lock files under <root>/.run/locks/ whose
5
5
  # recorded PID is no longer running. These are forensic artifacts only:
6
- # Refresh::Lock uses flock(2), which the kernel releases on process
6
+ # Fetch::Lock uses flock(2), which the kernel releases on process
7
7
  # death, so stale files do not block subsequent acquires. The check
8
8
  # exists to let users clean up clutter and notice unexpected accumulation
9
- # (e.g. a refresh path that crashes repeatedly).
10
- class RefreshLocks < Check
9
+ # (e.g. a fetch path that crashes repeatedly).
10
+ class FetchLocks < Check
11
11
  def call
12
- dir = File.join(root, ".locks")
12
+ dir = Textus::Layout.locks(root)
13
13
  return [] unless File.directory?(dir)
14
14
 
15
15
  Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
@@ -23,11 +23,11 @@ module Textus
23
23
  return nil if pid_alive?(pid)
24
24
 
25
25
  {
26
- "code" => "refresh_lock.stale",
26
+ "code" => "fetch_lock.stale",
27
27
  "level" => "info",
28
28
  "subject" => path,
29
- "message" => "refresh lock file at #{path} records dead PID #{pid} " \
30
- "(does not block refresh; flock is kernel-released on exit)",
29
+ "message" => "fetch lock file at #{path} records dead PID #{pid} " \
30
+ "(does not block fetch; flock is kernel-released on exit)",
31
31
  "fix" => "safe to delete: rm #{path}",
32
32
  }
33
33
  rescue Errno::ENOENT
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Flags pending proposals whose `proposal.target_key` cannot ever be
5
+ # accepted: it points at a non-canon zone or resolves to no declared
6
+ # entry (ADR 0035). Reads the live queue zone; silent when there is no
7
+ # queue zone. Warnings, not errors — they are stale junk, not store
8
+ # corruption (the accept gate already refuses them).
9
+ class ProposalTargets < Check
10
+ def call
11
+ queue = manifest.policy.queue_zone
12
+ return [] unless queue
13
+
14
+ dispatch(:list, zone: queue).filter_map { |row| issue_for(row["key"]) }
15
+ end
16
+
17
+ private
18
+
19
+ def issue_for(key)
20
+ target = dispatch(:get, key).meta&.dig("proposal", "target_key")
21
+ return nil if target.nil? # not a proposal entry — skip
22
+
23
+ zone = manifest.resolver.resolve(target).entry.zone
24
+ return nil if manifest.policy.declared_kind(zone.to_s) == :canon
25
+
26
+ {
27
+ "code" => "proposal.target_not_canon",
28
+ "level" => "warning",
29
+ "subject" => key,
30
+ "message" => "proposal '#{key}' targets '#{target}' in zone '#{zone}' (not canon); it can never be accepted",
31
+ "fix" => "delete the proposal, or repoint target_key at a canon zone",
32
+ }
33
+ rescue Textus::UnknownKey
34
+ {
35
+ "code" => "proposal.target_unresolved",
36
+ "level" => "warning",
37
+ "subject" => key,
38
+ "message" => "proposal '#{key}' targets '#{target}', which resolves to no declared entry",
39
+ "fix" => "delete the proposal, or fix target_key",
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,11 +2,11 @@ module Textus
2
2
  module Doctor
3
3
  class Check
4
4
  # Flags entries whose key is matched by two or more rule blocks of the
5
- # SAME specificity in the same slot (refresh / handler_allowlist /
6
- # promote). Ties are non-deterministic in the parser's pick step, so
5
+ # SAME specificity in the same slot (fetch / handler_allowlist /
6
+ # guard). Ties are non-deterministic in the parser's pick step, so
7
7
  # they're a configuration smell — surface them.
8
8
  class RuleAmbiguity < Check
9
- SLOTS = %i[refresh handler_allowlist promote].freeze
9
+ SLOTS = %i[fetch handler_allowlist guard].freeze
10
10
 
11
11
  def call
12
12
  out = []
data/lib/textus/doctor.rb CHANGED
@@ -23,7 +23,8 @@ module Textus
23
23
  Check::SchemaViolations,
24
24
  Check::RuleAmbiguity,
25
25
  Check::HandlerAllowlist,
26
- Check::RefreshLocks,
26
+ Check::FetchLocks,
27
+ Check::ProposalTargets,
27
28
  ].freeze
28
29
 
29
30
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
@@ -1,9 +1,9 @@
1
1
  module Textus
2
2
  module Domain
3
3
  module Action
4
- Return = Data.define
5
- RefreshSync = Data.define
6
- RefreshTimed = Data.define(:budget_ms)
4
+ Return = Data.define
5
+ FetchSync = Data.define
6
+ FetchTimed = Data.define(:budget_ms)
7
7
  end
8
8
  end
9
9
  end
@@ -9,15 +9,15 @@ module Textus
9
9
  def call(policy, envelope, now:)
10
10
  return Verdict.fresh if policy.ttl_seconds.nil?
11
11
 
12
- last_str = envelope&.meta&.dig("last_refreshed_at")
13
- return Verdict.stale("never refreshed") if last_str.nil?
12
+ last_str = envelope&.meta&.dig("last_fetched_at")
13
+ return Verdict.stale("never fetched") if last_str.nil?
14
14
 
15
15
  last = begin
16
16
  Time.parse(last_str.to_s)
17
17
  rescue ArgumentError, TypeError
18
18
  nil
19
19
  end
20
- return Verdict.stale("unparseable last_refreshed_at: #{last_str.inspect}") if last.nil?
20
+ return Verdict.stale("unparseable last_fetched_at: #{last_str.inspect}") if last.nil?
21
21
 
22
22
  age = now - last
23
23
  return Verdict.fresh if age <= policy.ttl_seconds
@@ -7,8 +7,8 @@ module Textus
7
7
 
8
8
  case on_stale
9
9
  when :warn then Action::Return.new
10
- when :sync then Action::RefreshSync.new
11
- when :timed_sync then Action::RefreshTimed.new(budget_ms: sync_budget_ms)
10
+ when :sync then Action::FetchSync.new
11
+ when :timed_sync then Action::FetchTimed.new(budget_ms: sync_budget_ms)
12
12
  else Action::Return.new
13
13
  end
14
14
  end
@@ -8,19 +8,19 @@ module Textus
8
8
  #
9
9
  # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
10
  # full field set. It emits the legacy keys ("stale", "stale_reason",
11
- # "refreshing", and "refresh_error" when present) so the CLI JSON wire
11
+ # "fetching", and "fetch_error" when present) so the CLI JSON wire
12
12
  # stays byte-identical with textus/3. The gem-side fields `checked_at`
13
13
  # and `ttl_remaining_ms` are NOT emitted on the wire in this phase.
14
14
  Freshness = Data.define(
15
- :stale, :refreshing, :reason, :refresh_error, :checked_at, :ttl_remaining_ms
15
+ :stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
16
16
  ) do
17
- def self.build(stale:, refreshing: false, reason: nil, refresh_error: nil,
17
+ def self.build(stale:, fetching: false, reason: nil, fetch_error: nil,
18
18
  checked_at: nil, ttl_remaining_ms: nil)
19
19
  new(
20
20
  stale: stale,
21
- refreshing: refreshing,
21
+ fetching: fetching,
22
22
  reason: reason,
23
- refresh_error: refresh_error,
23
+ fetch_error: fetch_error,
24
24
  checked_at: checked_at,
25
25
  ttl_remaining_ms: ttl_remaining_ms,
26
26
  )
@@ -30,9 +30,9 @@ module Textus
30
30
  h = {
31
31
  "stale" => stale,
32
32
  "stale_reason" => reason,
33
- "refreshing" => refreshing,
33
+ "fetching" => fetching,
34
34
  }
35
- h["refresh_error"] = refresh_error unless refresh_error.nil?
35
+ h["fetch_error"] = fetch_error unless fetch_error.nil?
36
36
  h
37
37
  end
38
38
  end
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  module Domain
3
3
  module Outcome
4
- Skipped = Data.define
5
- Refreshed = Data.define(:envelope)
4
+ Skipped = Data.define
5
+ Fetched = Data.define(:envelope)
6
6
  Detached = Data.define
7
7
  Failed = Data.define(:error)
8
8
  end
@@ -1,15 +1,7 @@
1
1
  module Textus
2
2
  module Domain
3
- Permission = Data.define(:zone, :write_policy, :read_policy) do
4
- def allows_write?(role)
5
- write_policy.include?(role.to_s)
6
- end
7
-
8
- def allows_read?(role)
9
- return true if [:all, ["all"]].include?(read_policy)
10
-
11
- read_policy.include?(role.to_s)
12
- end
3
+ Permission = Data.define(:zone, :writers) do
4
+ def allows_write?(role) = writers.include?(role.to_s)
13
5
  end
14
6
  end
15
7
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ # The CLOSED floor (ADR 0031 §4): predicate names every transition
7
+ # evaluates regardless of rules:. rules[].guard only ADDS to these.
8
+ module BaseGuards
9
+ # The minimal floor — only what the verb is meaningless without.
10
+ # schema_valid / etag_match / fresh_within are NOT here: they are
11
+ # composable-only, added per-key via rules[].guard (ADR 0031).
12
+ BASE = {
13
+ put: %w[zone_writable_by],
14
+ delete: %w[zone_writable_by],
15
+ mv: %w[zone_writable_by],
16
+ accept: %w[author_held target_is_canon],
17
+ reject: %w[author_held],
18
+ fetch: %w[zone_writable_by],
19
+ }.freeze
20
+
21
+ def self.for(transition) = BASE.fetch(transition, [])
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ # Immutable context handed to every predicate. `manifest` is the
7
+ # manifest (pure, no I/O); `envelope` is the entry under evaluation
8
+ # (nil when no bytes exist yet, e.g. a fresh put). `origin`/`target`
9
+ # are dotted keys; `transition` is the verb symbol.
10
+ Evaluation = Data.define(
11
+ :actor, :transition, :origin, :target, :envelope, :manifest
12
+ )
13
+ end
14
+ end
15
+ end
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Domain
3
3
  module Policy
4
- class Refresh
4
+ class Fetch
5
5
  ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
6
6
 
7
7
  attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ # An ordered list of pure predicates over one Evaluation (ADR 0031).
7
+ # check! short-circuits on the first failing predicate that defines a
8
+ # bespoke #error (only zone_writable_by → WriteForbidden, the product's
9
+ # legible topology refusal); every other failure accumulates into
10
+ # GuardFailed naming the unmet predicate(s).
11
+ class Guard
12
+ attr_reader :predicates
13
+
14
+ def initialize(predicates)
15
+ @predicates = predicates
16
+ end
17
+
18
+ def check!(eval)
19
+ accumulated = []
20
+ @predicates.each do |pred|
21
+ next if pred.call(eval)
22
+ raise pred.error(eval) if pred.respond_to?(:error)
23
+
24
+ accumulated << [pred.name, pred.reason]
25
+ end
26
+ raise Textus::GuardFailed.new(accumulated) unless accumulated.empty?
27
+ end
28
+
29
+ def explain(eval)
30
+ @predicates.map { |p| [p.name, p.call(eval), p.reason] }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ # Builds the effective Guard for (transition, key): base floor ++
7
+ # the predicates declared under rules[].guard[transition]. The single
8
+ # place the closed floor and the open ceiling are composed.
9
+ class GuardFactory
10
+ def initialize(manifest:, schemas:, extra: {})
11
+ @manifest = manifest
12
+ @schemas = schemas
13
+ @extra = extra # transient per-call params, e.g. { if_etag: "..." }
14
+ end
15
+
16
+ def for(transition, key)
17
+ specs = BaseGuards.for(transition) + composed(transition, key)
18
+ predicates = specs.map { |spec| build(spec) }.uniq(&:name)
19
+ Guard.new(predicates)
20
+ end
21
+
22
+ private
23
+
24
+ def composed(transition, key)
25
+ guard_map = @manifest.rules.for(key).guard
26
+ return [] if guard_map.nil?
27
+
28
+ Array(guard_map[transition.to_s])
29
+ end
30
+
31
+ def build(spec)
32
+ # etag_match takes a per-call param rather than a manifest one.
33
+ return Predicates::EtagMatch.new(if_etag: @extra[:if_etag]) if spec == "etag_match"
34
+
35
+ Predicates::Registry.build(spec, schemas: @schemas)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # Predicate: the acting role must hold the 'author' capability in the
8
+ # active manifest (ADR 0030 capability roles). Folds in the old
9
+ # Write::AuthorityGate so accept/reject and rules[].guard share one
10
+ # implementation. No bespoke #error — failures accumulate into
11
+ # GuardFailed (ADR 0031).
12
+ class AuthorHeld
13
+ attr_reader :reason
14
+
15
+ def name = "author_held"
16
+
17
+ def call(eval)
18
+ holders = eval.manifest.policy.roles_with_capability("author")
19
+ return true if holders.include?(eval.actor.to_s)
20
+
21
+ @reason =
22
+ if holders.empty?
23
+ "no role holds the 'author' capability; #{eval.transition} is disabled"
24
+ else
25
+ "role '#{eval.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
26
+ end
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # Advisory pre-flight etag check for policy explain. The
8
+ # authoritative compare-and-write stays in Envelope::IO::Writer
9
+ # (atomic write-then-audit, ADR 0017). Passes when no if_etag is
10
+ # supplied (params[:if_etag] nil) — guard does not require it.
11
+ class EtagMatch
12
+ attr_reader :reason
13
+
14
+ def initialize(if_etag: nil)
15
+ @if_etag = if_etag
16
+ end
17
+
18
+ def name = "etag_match"
19
+
20
+ def call(eval)
21
+ return true if @if_etag.nil?
22
+ return true if eval.envelope.nil? # creating; Writer handles race
23
+ return true if eval.envelope.etag == @if_etag
24
+
25
+ @reason = "etag mismatch: wanted #{@if_etag}, have #{eval.envelope.etag}"
26
+ false
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end