textus 0.30.0 → 0.35.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -241
  3. data/CHANGELOG.md +113 -0
  4. data/README.md +83 -62
  5. data/SPEC.md +352 -211
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +89 -74
  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/put.rb +1 -1
  15. data/lib/textus/cli/verb/rule_list.rb +7 -7
  16. data/lib/textus/cli.rb +2 -2
  17. data/lib/textus/container.rb +1 -2
  18. data/lib/textus/dispatcher.rb +3 -3
  19. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
  20. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  21. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  22. data/lib/textus/doctor.rb +2 -1
  23. data/lib/textus/domain/action.rb +3 -3
  24. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  25. data/lib/textus/domain/freshness/policy.rb +2 -2
  26. data/lib/textus/domain/freshness.rb +7 -7
  27. data/lib/textus/domain/outcome.rb +2 -2
  28. data/lib/textus/domain/permission.rb +2 -10
  29. data/lib/textus/domain/policy/base_guards.rb +25 -0
  30. data/lib/textus/domain/policy/evaluation.rb +18 -0
  31. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
  32. data/lib/textus/domain/policy/guard.rb +35 -0
  33. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  34. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  35. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  36. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  37. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  38. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  39. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  40. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  41. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  42. data/lib/textus/envelope.rb +2 -2
  43. data/lib/textus/errors.rb +25 -28
  44. data/lib/textus/hooks/event_bus.rb +4 -4
  45. data/lib/textus/init.rb +23 -18
  46. data/lib/textus/maintenance/zone_mv.rb +1 -1
  47. data/lib/textus/manifest/capabilities.rb +29 -0
  48. data/lib/textus/manifest/data.rb +14 -10
  49. data/lib/textus/manifest/policy.rb +37 -21
  50. data/lib/textus/manifest/rules.rb +16 -14
  51. data/lib/textus/manifest/schema.rb +48 -58
  52. data/lib/textus/manifest.rb +3 -3
  53. data/lib/textus/mcp/server.rb +1 -1
  54. data/lib/textus/mcp/tool_schemas.rb +3 -3
  55. data/lib/textus/mcp/tools.rb +7 -7
  56. data/lib/textus/ports/audit_subscriber.rb +1 -1
  57. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  58. data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
  59. data/lib/textus/projection.rb +1 -1
  60. data/lib/textus/read/freshness.rb +9 -9
  61. data/lib/textus/read/get.rb +8 -8
  62. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  63. data/lib/textus/read/policy_explain.rb +14 -10
  64. data/lib/textus/read/pulse.rb +5 -4
  65. data/lib/textus/read/validator.rb +1 -1
  66. data/lib/textus/schema/tools.rb +5 -5
  67. data/lib/textus/version.rb +1 -1
  68. data/lib/textus/write/accept.rb +19 -55
  69. data/lib/textus/write/delete.rb +14 -2
  70. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
  71. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  72. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +21 -14
  73. data/lib/textus/write/mv.rb +15 -3
  74. data/lib/textus/write/put.rb +14 -2
  75. data/lib/textus/write/reject.rb +11 -5
  76. metadata +24 -18
  77. data/lib/textus/cli/verb/refresh.rb +0 -14
  78. data/lib/textus/domain/authorizer.rb +0 -37
  79. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  80. data/lib/textus/domain/policy/promote.rb +0 -26
  81. data/lib/textus/domain/policy/promotion.rb +0 -57
  82. data/lib/textus/manifest/role_kinds.rb +0 -21
  83. data/lib/textus/write/authority_gate.rb +0 -24
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,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ # Immutable context handed to every predicate. `snapshot` 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, :snapshot
12
+ ) do
13
+ def manifest = snapshot
14
+ def role = actor
15
+ end
16
+ end
17
+ end
18
+ 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
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Textus
6
+ module Domain
7
+ module Policy
8
+ module Predicates
9
+ # Parameterized predicate: the entry must have been written within
10
+ # `duration` of now. Duration strings ("1h", "30m", "7d") parse via
11
+ # Domain::Duration.seconds. Passes when no envelope exists yet.
12
+ class FreshWithin
13
+ attr_reader :reason
14
+
15
+ def initialize(duration:, now: nil)
16
+ @seconds = Textus::Domain::Duration.seconds(duration)
17
+ @now = now
18
+ end
19
+
20
+ def name = "fresh_within"
21
+
22
+ def call(eval)
23
+ return true if eval.envelope.nil? || @seconds.nil?
24
+
25
+ written = written_at(eval.envelope)
26
+ return true if written.nil?
27
+
28
+ now = @now || Textus::Ports::Clock.now
29
+ return true if now - written <= @seconds
30
+
31
+ @reason = "entry older than #{@seconds}s (written #{written.iso8601})"
32
+ false
33
+ end
34
+
35
+ private
36
+
37
+ # Domain-pure: reads the stored write timestamp from the envelope's
38
+ # freshness (checked_at) or meta (last_fetched_at/generated_at) and
39
+ # parses the stored ISO-8601 string. Parsing a stored string is not
40
+ # I/O (allowed in domain, ADR 0024).
41
+ def written_at(envelope)
42
+ raw = envelope.freshness&.checked_at ||
43
+ envelope.meta&.dig("last_fetched_at") ||
44
+ envelope.meta&.dig("generated_at")
45
+ return raw if raw.is_a?(Time)
46
+ return nil if raw.nil?
47
+
48
+ begin
49
+ Time.parse(raw.to_s)
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # The single source of truth for the predicate vocabulary
8
+ # (ADR 0031 §3). Replaces both Promote::KNOWN and Promotion::REGISTRY.
9
+ # Each entry is name => ->(params:, schemas:) { predicate }.
10
+ module Registry
11
+ ENTRIES = {
12
+ "zone_writable_by" => ->(**) { ZoneWritableBy.new },
13
+ "author_held" => ->(**) { AuthorHeld.new },
14
+ "target_is_canon" => ->(**) { TargetIsCanon.new },
15
+ "schema_valid" => ->(schemas:, **) { SchemaValid.new(schemas: schemas) },
16
+ "etag_match" => ->(params:, **) { EtagMatch.new(if_etag: params) },
17
+ "fresh_within" => ->(params:, **) { FreshWithin.new(duration: params) },
18
+ }.freeze
19
+
20
+ # Accepts either "name" or { "name" => params }.
21
+ def self.build(spec, schemas:)
22
+ name, params =
23
+ if spec.is_a?(Hash)
24
+ spec.first
25
+ else
26
+ [spec.to_s, nil]
27
+ end
28
+ ctor = ENTRIES[name.to_s] or raise Textus::UsageError.new(
29
+ "unknown guard predicate: '#{name}' (known: #{ENTRIES.keys.join(", ")})",
30
+ )
31
+ ctor.call(params: params, schemas: schemas)
32
+ end
33
+
34
+ def self.known = ENTRIES.keys
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,48 +1,59 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Textus
2
4
  module Domain
3
5
  module Policy
4
6
  module Predicates
7
+ # Predicate: the entry's effective frontmatter satisfies the schema
8
+ # bound to the target key. For accept, the frontmatter lives under
9
+ # envelope.meta["frontmatter"]; for a direct put it is envelope.meta.
5
10
  class SchemaValid
6
11
  attr_reader :reason
7
12
 
8
- def name
9
- "schema_valid"
13
+ def initialize(schemas:)
14
+ @schemas = schemas
10
15
  end
11
16
 
12
- def call(entry:, schemas:, manifest:) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
13
- return true if entry.nil? || manifest.nil? || schemas.nil?
17
+ def name = "schema_valid"
18
+
19
+ def call(eval)
20
+ manifest = eval.manifest
21
+ return true if eval.envelope.nil? || manifest.nil? || @schemas.nil?
14
22
 
15
- target_key = entry.meta&.dig("proposal", "target_key")
23
+ target_key = eval.target
16
24
  return true unless target_key
17
25
 
18
26
  mentry = manifest.resolver.resolve(target_key).entry
19
27
  schema_ref = mentry&.schema
20
28
  return true unless schema_ref
21
29
 
22
- schema = schemas.fetch_or_nil(schema_ref)
30
+ schema = @schemas.fetch_or_nil(schema_ref)
23
31
  return true unless schema
24
32
 
25
- frontmatter = entry.meta&.dig("frontmatter") || {}
33
+ frontmatter =
34
+ eval.envelope.meta&.dig("frontmatter") || eval.envelope.meta || {}
26
35
  begin
27
36
  schema.validate!(frontmatter)
37
+ true
28
38
  rescue Textus::SchemaViolation => e
29
- @reason = e.message.dup
30
- d = e.details
31
- if d.is_a?(Hash)
32
- if d["missing"]
33
- @reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
34
- elsif d["field"]
35
- @reason = "field '#{d["field"]}': #{d["reason"]}"
36
- end
37
- end
38
- return false
39
+ @reason = humanize(e)
40
+ false
39
41
  end
40
-
41
- true
42
42
  rescue StandardError => e
43
43
  @reason = "schema validation error: #{e.message}"
44
44
  false
45
45
  end
46
+
47
+ private
48
+
49
+ def humanize(err)
50
+ d = err.details
51
+ return err.message.dup unless d.is_a?(Hash)
52
+ return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
53
+ return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
54
+
55
+ err.message.dup
56
+ end
46
57
  end
47
58
  end
48
59
  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: a proposal may only target a `canon` zone (ADR 0035). Runs
8
+ # on the `accept` floor, where Evaluation#target is the proposal's
9
+ # resolved target_key. Refuses promotion into workspace/derived/
10
+ # quarantine/queue — the queue→canon path is the only coherent one.
11
+ # No bespoke #error; failures accumulate into GuardFailed (ADR 0031).
12
+ class TargetIsCanon
13
+ attr_reader :reason
14
+
15
+ def name = "target_is_canon"
16
+
17
+ def call(eval)
18
+ zone = eval.manifest.resolver.resolve(eval.target).entry.zone
19
+ kind = eval.manifest.policy.declared_kind(zone.to_s)
20
+ return true if kind == :canon
21
+
22
+ @reason = "proposal target '#{eval.target}' is in zone '#{zone}' " \
23
+ "(kind: #{kind || "none"}); proposals may only target a canon zone"
24
+ false
25
+ rescue Textus::UnknownKey
26
+ @reason = "proposal target '#{eval.target}' resolves to no declared entry"
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # Predicate #0 of every write guard. Wraps the post-0.31.0 capability
8
+ # topology gate (role.can ⊇ verb_for(zone.kind)). On failure, #error
9
+ # raises the capability-shaped WriteForbidden so the topology refusal
10
+ # — textus's signature product feature — is unchanged.
11
+ class ZoneWritableBy
12
+ attr_reader :reason
13
+
14
+ def name = "zone_writable_by"
15
+
16
+ def call(eval)
17
+ manifest = eval.manifest
18
+ @mentry = manifest.resolver.resolve(eval.target).entry
19
+ return true if manifest.policy.permission_for(@mentry.zone.to_s).allows_write?(eval.actor)
20
+
21
+ @verb = manifest.policy.verb_for_zone(@mentry.zone) # capability the kind requires
22
+ @holders = manifest.policy.roles_with_capability(@verb)
23
+ @reason = "zone '#{@mentry.zone}' needs capability '#{@verb}'; '#{eval.actor}' lacks it"
24
+ false
25
+ end
26
+
27
+ # Matches the capability-shaped WriteForbidden landed by ADR 0030
28
+ # Task 3:
29
+ # WriteForbidden.new(key, zone, verb:, holders:)
30
+ # → "writing '<k>' (zone '<z>') needs capability '<verb>'",
31
+ # hint: "held by: <holders>; pass --as=<role>".
32
+ def error(_eval)
33
+ Textus::WriteForbidden.new(@mentry.key, @mentry.zone, verb: @verb, holders: @holders)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -15,7 +15,7 @@ module Textus
15
15
  def rows_for(mentry)
16
16
  return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
17
17
 
18
- ttl = @manifest.rules.for(mentry.key).refresh&.ttl_seconds
18
+ ttl = @manifest.rules.for(mentry.key).fetch&.ttl_seconds
19
19
  return [] unless ttl
20
20
 
21
21
  path = Textus::Key::Path.resolve(@manifest.data, mentry)
@@ -26,17 +26,17 @@ module Textus
26
26
  private
27
27
 
28
28
  def ttl_reason(mentry, path, ttl)
29
- return "never refreshed" unless @file_stat.exists?(path)
29
+ return "never fetched" unless @file_stat.exists?(path)
30
30
 
31
- last_str = last_refreshed_of(mentry, path)
32
- return "never refreshed (no last_refreshed_at)" if last_str.nil?
31
+ last_str = last_fetched_of(mentry, path)
32
+ return "never fetched (no last_fetched_at)" if last_str.nil?
33
33
 
34
34
  last = parse_time(last_str)
35
35
  "ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
36
36
  end
37
37
 
38
- def last_refreshed_of(mentry, path)
39
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_refreshed_at"]
38
+ def last_fetched_of(mentry, path)
39
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
40
40
  end
41
41
 
42
42
  def parse_time(str)
@@ -55,10 +55,10 @@ module Textus
55
55
  freshness.stale == true
56
56
  end
57
57
 
58
- def refreshing?
58
+ def fetching?
59
59
  return false if freshness.nil?
60
60
 
61
- freshness.refreshing == true
61
+ freshness.fetching == true
62
62
  end
63
63
  end
64
64
  end