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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +2 -241
- data/CHANGELOG.md +221 -0
- data/README.md +89 -69
- data/SPEC.md +359 -212
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +122 -87
- 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/mcp_serve.rb +8 -3
- data/lib/textus/cli/verb/propose.rb +28 -0
- data/lib/textus/cli/verb/pulse.rb +12 -3
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/rule_list.rb +7 -7
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/cli.rb +2 -2
- data/lib/textus/container.rb +1 -2
- data/lib/textus/contract.rb +106 -0
- data/lib/textus/cursor_store.rb +24 -0
- data/lib/textus/dispatcher.rb +6 -4
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
- 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 +15 -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 +27 -18
- data/lib/textus/layout.rb +41 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
- data/lib/textus/maintenance/migrate.rb +9 -0
- data/lib/textus/maintenance/rule_lint.rb +8 -0
- data/lib/textus/maintenance/zone_mv.rb +11 -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/catalog.rb +72 -0
- data/lib/textus/mcp/server.rb +8 -5
- data/lib/textus/mcp/session.rb +3 -20
- data/lib/textus/mcp/tool_schemas.rb +6 -62
- data/lib/textus/mcp/tools.rb +4 -119
- data/lib/textus/ports/audit_log.rb +17 -15
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/build_lock.rb +1 -2
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/audit.rb +3 -3
- data/lib/textus/read/boot.rb +6 -0
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +16 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/list.rb +8 -0
- data/lib/textus/read/policy_explain.rb +14 -10
- data/lib/textus/read/pulse.rb +12 -4
- data/lib/textus/read/rules.rb +24 -0
- data/lib/textus/read/schema_envelope.rb +7 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role.rb +6 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/session.rb +24 -0
- data/lib/textus/store.rb +11 -0
- 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} +14 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
- data/lib/textus/write/mv.rb +15 -3
- data/lib/textus/write/propose.rb +46 -0
- data/lib/textus/write/put.rb +26 -2
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus.rb +4 -0
- metadata +36 -21
- 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
|
@@ -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)
|
data/lib/textus/envelope.rb
CHANGED
data/lib/textus/errors.rb
CHANGED
|
@@ -90,39 +90,21 @@ module Textus
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
class WriteForbidden < Error
|
|
93
|
-
def initialize(k, z,
|
|
94
|
-
|
|
95
|
-
if
|
|
96
|
-
|
|
93
|
+
def initialize(k, z, verb: nil, holders: nil)
|
|
94
|
+
holders_str =
|
|
95
|
+
if holders && !holders.empty?
|
|
96
|
+
holders.join(", ")
|
|
97
97
|
else
|
|
98
|
-
"
|
|
98
|
+
"no declared role"
|
|
99
99
|
end
|
|
100
100
|
details = { "key" => k, "zone" => z }
|
|
101
|
-
details["
|
|
101
|
+
details["verb"] = verb if verb
|
|
102
|
+
details["holders"] = holders if holders
|
|
102
103
|
super(
|
|
103
104
|
"write_forbidden",
|
|
104
|
-
"zone '#{z}'
|
|
105
|
+
"writing '#{k}' (zone '#{z}') needs capability '#{verb}'",
|
|
105
106
|
details: details,
|
|
106
|
-
hint: "
|
|
107
|
-
)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
class ReadForbidden < Error
|
|
112
|
-
def initialize(k, z, readers: nil)
|
|
113
|
-
readers_str =
|
|
114
|
-
if readers && !readers.empty?
|
|
115
|
-
readers.join(", ")
|
|
116
|
-
else
|
|
117
|
-
"the role(s) listed in the manifest 'read_policy:'"
|
|
118
|
-
end
|
|
119
|
-
details = { "key" => k, "zone" => z }
|
|
120
|
-
details["readers"] = readers if readers
|
|
121
|
-
super(
|
|
122
|
-
"read_forbidden",
|
|
123
|
-
"zone '#{z}' is not readable by role for key '#{k}'",
|
|
124
|
-
details: details,
|
|
125
|
-
hint: "this zone is readable by #{readers_str}; pass --as=<role>",
|
|
107
|
+
hint: "held by: #{holders_str}; pass --as=<role>",
|
|
126
108
|
)
|
|
127
109
|
end
|
|
128
110
|
end
|
|
@@ -163,7 +145,7 @@ module Textus
|
|
|
163
145
|
"invalid_role",
|
|
164
146
|
message || "role '#{r}' is not declared in any zone",
|
|
165
147
|
details: { "role" => r },
|
|
166
|
-
hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under
|
|
148
|
+
hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under roles: (each with a can: list)",
|
|
167
149
|
)
|
|
168
150
|
end
|
|
169
151
|
end
|
|
@@ -204,6 +186,21 @@ module Textus
|
|
|
204
186
|
def initialize(m) = super("proposal_error", m)
|
|
205
187
|
end
|
|
206
188
|
|
|
189
|
+
class GuardFailed < Error
|
|
190
|
+
def initialize(failed)
|
|
191
|
+
# failed: [[predicate_name, reason], ...]
|
|
192
|
+
rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
|
|
193
|
+
names = failed.map(&:first)
|
|
194
|
+
super(
|
|
195
|
+
"guard_failed",
|
|
196
|
+
"guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
|
|
197
|
+
details: { "failed" => rows },
|
|
198
|
+
hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
|
|
199
|
+
"unmet: #{names.join(", ")}",
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
207
204
|
class FlagRenamed < Error
|
|
208
205
|
def initialize(old_flag, new_flag)
|
|
209
206
|
super(
|
|
@@ -10,16 +10,16 @@ module Textus
|
|
|
10
10
|
EVENTS = {
|
|
11
11
|
entry_put: %i[ctx key envelope],
|
|
12
12
|
entry_deleted: %i[ctx key],
|
|
13
|
-
|
|
13
|
+
entry_fetched: %i[ctx key envelope change],
|
|
14
14
|
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
15
15
|
build_completed: %i[ctx key envelope sources],
|
|
16
16
|
proposal_accepted: %i[ctx key target_key],
|
|
17
17
|
proposal_rejected: %i[ctx key target_key],
|
|
18
18
|
file_published: %i[ctx key envelope source target],
|
|
19
19
|
store_loaded: %i[ctx],
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
fetch_started: %i[ctx key mode],
|
|
21
|
+
fetch_failed: %i[ctx key error_class error_message],
|
|
22
|
+
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
|
data/lib/textus/init.rb
CHANGED
|
@@ -2,20 +2,25 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Init
|
|
5
|
-
ZONES = %w[
|
|
5
|
+
ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
8
|
version: textus/3
|
|
9
|
+
roles:
|
|
10
|
+
- { name: human, can: [author, propose] }
|
|
11
|
+
- { name: agent, can: [propose, keep] }
|
|
12
|
+
- { name: automation, can: [fetch, build] }
|
|
9
13
|
zones:
|
|
10
|
-
- { name:
|
|
11
|
-
- { name:
|
|
12
|
-
- { name:
|
|
13
|
-
- { name:
|
|
14
|
-
- { name:
|
|
14
|
+
- { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
|
|
15
|
+
- { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
|
|
16
|
+
- { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
|
|
17
|
+
- { name: proposals, kind: queue, desc: "changes awaiting your accept" }
|
|
18
|
+
- { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
|
|
15
19
|
entries:
|
|
16
|
-
- { key: identity
|
|
17
|
-
- { key:
|
|
18
|
-
- { key:
|
|
20
|
+
- { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
|
|
21
|
+
- { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
|
|
22
|
+
- { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
23
|
+
- { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
19
24
|
YAML
|
|
20
25
|
|
|
21
26
|
HOOKS_README = <<~MD
|
|
@@ -31,12 +36,12 @@ module Textus
|
|
|
31
36
|
```ruby
|
|
32
37
|
Textus.hook do |reg|
|
|
33
38
|
reg.on(:resolve_intake, :my_source) do |config:, args:, **|
|
|
34
|
-
{ _meta: { "
|
|
39
|
+
{ _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
38
43
|
reg.on(:validate, :my_check) { |caps:, **| [] }
|
|
39
|
-
reg.on(:entry_put, :my_listener, keys: ["
|
|
44
|
+
reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
|
|
40
45
|
|
|
41
46
|
# Run a side-effect every time textus writes a file to your repo:
|
|
42
47
|
reg.on(:file_published, :notify) do |key:, target:, **|
|
|
@@ -51,25 +56,25 @@ module Textus
|
|
|
51
56
|
|
|
52
57
|
```yaml
|
|
53
58
|
entries:
|
|
54
|
-
- key:
|
|
59
|
+
- key: feeds.foo
|
|
55
60
|
kind: intake
|
|
56
|
-
path:
|
|
57
|
-
zone:
|
|
61
|
+
path: feeds/foo.md
|
|
62
|
+
zone: feeds
|
|
58
63
|
intake:
|
|
59
64
|
handler: my_source
|
|
60
65
|
|
|
61
66
|
rules:
|
|
62
|
-
- match:
|
|
63
|
-
|
|
67
|
+
- match: feeds.foo
|
|
68
|
+
fetch:
|
|
64
69
|
ttl: 10m
|
|
65
70
|
on_stale: timed_sync # warn | sync | timed_sync (default: warn)
|
|
66
71
|
```
|
|
67
72
|
|
|
68
73
|
Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
|
|
69
|
-
:entry_put, :entry_deleted, :
|
|
74
|
+
:entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
|
|
70
75
|
:build_completed, :proposal_accepted, :proposal_rejected,
|
|
71
76
|
:file_published, :store_loaded,
|
|
72
|
-
:
|
|
77
|
+
:fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
|
|
73
78
|
|
|
74
79
|
See SPEC.md §5.10 for the full table.
|
|
75
80
|
MD
|
|
@@ -87,6 +92,10 @@ module Textus
|
|
|
87
92
|
end
|
|
88
93
|
File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
|
|
89
94
|
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
|
95
|
+
FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
|
|
96
|
+
FileUtils.mkdir_p(Textus::Layout.state(target_root))
|
|
97
|
+
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
98
|
+
File.write(File.join(target_root, ".gitignore"), Textus::Layout::GITIGNORE)
|
|
90
99
|
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
91
100
|
end
|
|
92
101
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Single source of truth for every path textus owns under a store root.
|
|
3
|
+
# All disposable runtime state nests under <root>/.run/ so the
|
|
4
|
+
# tracked/disposable boundary is a directory boundary. ADR 0038.
|
|
5
|
+
module Layout
|
|
6
|
+
RUN = ".run"
|
|
7
|
+
|
|
8
|
+
def self.run(root)
|
|
9
|
+
File.join(root, RUN)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.state(root)
|
|
13
|
+
File.join(run(root), "state")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.cursor(root, role)
|
|
17
|
+
File.join(state(root), "cursor.#{role}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.locks(root)
|
|
21
|
+
File.join(run(root), "locks")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.build_lock(root)
|
|
25
|
+
File.join(run(root), "build.lock")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.audit_dir(root)
|
|
29
|
+
File.join(run(root), "audit")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.audit_log(root)
|
|
33
|
+
File.join(audit_dir(root), "audit.log")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
GITIGNORE = <<~GITIGNORE
|
|
37
|
+
# textus runtime artifacts — safe to delete, never commit
|
|
38
|
+
#{RUN}/
|
|
39
|
+
GITIGNORE
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -2,6 +2,15 @@ module Textus
|
|
|
2
2
|
module Maintenance
|
|
3
3
|
# Bulk-delete every leaf key under `prefix`.
|
|
4
4
|
class KeyDeletePrefix
|
|
5
|
+
extend Textus::Contract::DSL
|
|
6
|
+
|
|
7
|
+
verb :key_delete_prefix
|
|
8
|
+
summary "Bulk-delete every leaf key under prefix."
|
|
9
|
+
surfaces :cli, :ruby, :mcp
|
|
10
|
+
arg :prefix, String, required: true
|
|
11
|
+
arg :dry_run, :boolean
|
|
12
|
+
response(&:to_h)
|
|
13
|
+
|
|
5
14
|
def initialize(container:, call:)
|
|
6
15
|
@container = container
|
|
7
16
|
@call = call
|
|
@@ -3,6 +3,16 @@ module Textus
|
|
|
3
3
|
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
4
4
|
# Calls Write::Mv directly for each entry — emits one audit row per file moved.
|
|
5
5
|
class KeyMvPrefix
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :key_mv_prefix
|
|
9
|
+
summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
|
|
10
|
+
surfaces :cli, :ruby, :mcp
|
|
11
|
+
arg :from_prefix, String, required: true
|
|
12
|
+
arg :to_prefix, String, required: true
|
|
13
|
+
arg :dry_run, :boolean
|
|
14
|
+
response(&:to_h)
|
|
15
|
+
|
|
6
16
|
def initialize(container:, call:)
|
|
7
17
|
@container = container
|
|
8
18
|
@call = call
|
|
@@ -5,6 +5,15 @@ module Textus
|
|
|
5
5
|
# Loads a YAML migration plan and dispatches each op to the
|
|
6
6
|
# appropriate Maintenance use case. Concatenates resulting Plans.
|
|
7
7
|
class Migrate
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :migrate
|
|
11
|
+
summary "Run a YAML migration plan (multi-op)."
|
|
12
|
+
surfaces :cli, :ruby, :mcp
|
|
13
|
+
arg :plan_yaml, String, required: true
|
|
14
|
+
arg :dry_run, :boolean
|
|
15
|
+
response(&:to_h)
|
|
16
|
+
|
|
8
17
|
def initialize(container:, call:)
|
|
9
18
|
@container = container
|
|
10
19
|
@call = call
|
|
@@ -6,6 +6,14 @@ module Textus
|
|
|
6
6
|
# YAML string. Returns a Plan describing rule additions/removals/
|
|
7
7
|
# changes. Does NOT write anything.
|
|
8
8
|
class RuleLint
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :rule_lint
|
|
12
|
+
summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :candidate_yaml, String, required: true
|
|
15
|
+
response(&:to_h)
|
|
16
|
+
|
|
9
17
|
def initialize(container:, call:)
|
|
10
18
|
@container = container
|
|
11
19
|
@call = call
|
|
@@ -6,6 +6,16 @@ module Textus
|
|
|
6
6
|
# the `zone:` field on every entry under the old zone, and moves
|
|
7
7
|
# every file from zones/<old>/ to zones/<new>/.
|
|
8
8
|
class ZoneMv
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :zone_mv
|
|
12
|
+
summary "Rename a zone — manifest + files. Refuses if destination exists."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :from, String, required: true
|
|
15
|
+
arg :to, String, required: true
|
|
16
|
+
arg :dry_run, :boolean
|
|
17
|
+
response(&:to_h)
|
|
18
|
+
|
|
9
19
|
def initialize(container:, call:)
|
|
10
20
|
@container = container
|
|
11
21
|
@call = call
|
|
@@ -15,7 +25,7 @@ module Textus
|
|
|
15
25
|
|
|
16
26
|
def call(from:, to:, dry_run: false)
|
|
17
27
|
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
18
|
-
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.
|
|
28
|
+
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
|
|
19
29
|
|
|
20
30
|
dest_dir = File.join(@root, "zones", to)
|
|
21
31
|
raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
# Resolves a manifest's `roles:` block (or the absence of one) into a
|
|
4
|
+
# capability map: { role_name => [verbs] }. Verbs are a subset of the
|
|
5
|
+
# closed capability set (Schema::CAPABILITIES). See ADR 0030.
|
|
6
|
+
module Capabilities
|
|
7
|
+
# Fallback role set for a manifest that omits `roles:` entirely. Agent
|
|
8
|
+
# is intentionally minimal here (`propose` only) — narrower than the
|
|
9
|
+
# `textus init` scaffold, which declares `agent: [propose, keep]` so the
|
|
10
|
+
# default `notebook` workspace is writable. A roles-less manifest that
|
|
11
|
+
# declares a `kind: workspace` zone is therefore rejected at load (no
|
|
12
|
+
# `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
|
|
13
|
+
DEFAULT_MAPPING = {
|
|
14
|
+
"human" => %w[author propose].freeze,
|
|
15
|
+
"agent" => %w[propose].freeze,
|
|
16
|
+
"automation" => %w[fetch build].freeze,
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Returns { role_name => [verbs] }. When `roles:` is declared we use
|
|
20
|
+
# exactly that; defaults are *not* layered in (declaring roles is an
|
|
21
|
+
# opt-in to a fully user-defined vocabulary).
|
|
22
|
+
def self.resolve(raw_roles)
|
|
23
|
+
return DEFAULT_MAPPING if raw_roles.nil?
|
|
24
|
+
|
|
25
|
+
raw_roles.to_h { |r| [r["name"], Array(r["can"]).freeze] }.freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|