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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +2 -241
- data/CHANGELOG.md +113 -0
- data/README.md +83 -62
- data/SPEC.md +352 -211
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +89 -74
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/rule_list.rb +7 -7
- data/lib/textus/cli.rb +2 -2
- data/lib/textus/container.rb +1 -2
- data/lib/textus/dispatcher.rb +3 -3
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +18 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +4 -4
- data/lib/textus/init.rb +23 -18
- data/lib/textus/maintenance/zone_mv.rb +1 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +14 -10
- data/lib/textus/manifest/policy.rb +37 -21
- data/lib/textus/manifest/rules.rb +16 -14
- data/lib/textus/manifest/schema.rb +48 -58
- data/lib/textus/manifest.rb +3 -3
- data/lib/textus/mcp/server.rb +1 -1
- data/lib/textus/mcp/tool_schemas.rb +3 -3
- data/lib/textus/mcp/tools.rb +7 -7
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +8 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/policy_explain.rb +14 -10
- data/lib/textus/read/pulse.rb +5 -4
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +14 -2
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +21 -14
- data/lib/textus/write/mv.rb +15 -3
- data/lib/textus/write/put.rb +14 -2
- data/lib/textus/write/reject.rb +11 -5
- metadata +24 -18
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
data/lib/textus/doctor.rb
CHANGED
data/lib/textus/domain/action.rb
CHANGED
|
@@ -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("
|
|
13
|
-
return Verdict.stale("never
|
|
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
|
|
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::
|
|
11
|
-
when :timed_sync then Action::
|
|
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
|
-
# "
|
|
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, :
|
|
15
|
+
:stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
|
|
16
16
|
) do
|
|
17
|
-
def self.build(stale:,
|
|
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
|
-
|
|
21
|
+
fetching: fetching,
|
|
22
22
|
reason: reason,
|
|
23
|
-
|
|
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
|
-
"
|
|
33
|
+
"fetching" => fetching,
|
|
34
34
|
}
|
|
35
|
-
h["
|
|
35
|
+
h["fetch_error"] = fetch_error unless fetch_error.nil?
|
|
36
36
|
h
|
|
37
37
|
end
|
|
38
38
|
end
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Domain
|
|
3
|
-
Permission = Data.define(:zone, :
|
|
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
|
|
@@ -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
|
|
9
|
-
|
|
13
|
+
def initialize(schemas:)
|
|
14
|
+
@schemas = schemas
|
|
10
15
|
end
|
|
11
16
|
|
|
12
|
-
def
|
|
13
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
30
|
-
|
|
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).
|
|
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
|
|
29
|
+
return "never fetched" unless @file_stat.exists?(path)
|
|
30
30
|
|
|
31
|
-
last_str =
|
|
32
|
-
return "never
|
|
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
|
|
39
|
-
Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["
|
|
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)
|