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/docs/conventions.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Conventions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Reference** · for integrators · **read when** you're shaping a `.textus/` tree and want the idiomatic choices
|
|
4
|
+
> **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-05 (v0.31)
|
|
5
|
+
|
|
6
|
+
Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build automation. The spec ([`../SPEC.md`](../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
|
|
4
7
|
|
|
5
8
|
## Key naming
|
|
6
9
|
|
|
@@ -18,14 +21,14 @@ Recommended top-level layout — the spec allows alternatives, but this is what
|
|
|
18
21
|
manifest.yaml
|
|
19
22
|
schemas/ # YAML schema definitions
|
|
20
23
|
zones/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
knowledge/ # authored truth: identity, voice, decisions — author-holders write
|
|
25
|
+
notebook/ # agent's own durable lane (workspace) — keep-holders write
|
|
26
|
+
feeds/ # automation-fed external inputs (fetch)
|
|
27
|
+
proposals/ # AI proposals awaiting accept (propose)
|
|
28
|
+
artifacts/ # generated by build automation — never edit by hand
|
|
26
29
|
```
|
|
27
30
|
|
|
28
|
-
Inside `
|
|
31
|
+
Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
|
|
29
32
|
|
|
30
33
|
## Schema design
|
|
31
34
|
|
|
@@ -46,75 +49,77 @@ Tooling around `git blame` or audit logs may filter on owner; the gem itself onl
|
|
|
46
49
|
|
|
47
50
|
## Derived entries
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
|
|
50
53
|
|
|
51
|
-
**`projection
|
|
54
|
+
**`compute: { kind: projection }`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
|
|
52
55
|
|
|
53
56
|
```yaml
|
|
54
|
-
- key:
|
|
55
|
-
path:
|
|
56
|
-
zone:
|
|
57
|
+
- key: artifacts.catalogs.people
|
|
58
|
+
path: artifacts/catalogs/people.md
|
|
59
|
+
zone: artifacts
|
|
57
60
|
schema: null
|
|
58
61
|
owner: build:catalog-people
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
compute:
|
|
63
|
+
kind: projection
|
|
64
|
+
select: knowledge.network.org # prefix or list of prefixes
|
|
61
65
|
pluck: [name, relationship, org]
|
|
62
66
|
sort_by: name
|
|
63
|
-
template: people.mustache
|
|
64
|
-
publish_to: [docs/people.md]
|
|
67
|
+
template: people.mustache # under .textus/templates/
|
|
68
|
+
publish_to: [docs/people.md] # optional repo-relative byte-copy targets
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
**`
|
|
71
|
+
**`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
|
|
68
72
|
|
|
69
73
|
```yaml
|
|
70
|
-
- key:
|
|
71
|
-
path:
|
|
72
|
-
zone:
|
|
74
|
+
- key: artifacts.catalogs.skills
|
|
75
|
+
path: artifacts/catalogs/skills.md
|
|
76
|
+
zone: artifacts
|
|
73
77
|
owner: build:catalog-skills
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
compute:
|
|
79
|
+
kind: external
|
|
80
|
+
command: "rake catalog:skills" # informational; the automation invokes it
|
|
81
|
+
sources: [knowledge.projects, knowledge.network]
|
|
77
82
|
```
|
|
78
83
|
|
|
79
|
-
The build
|
|
84
|
+
The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
|
|
80
85
|
|
|
81
|
-
Full contract for both shapes is in [`../SPEC.md` §5.2 and §5.2.
|
|
86
|
+
Full contract for both shapes is in [`../SPEC.md` §5.2.1 and §5.2.2](../SPEC.md). Transforms (`compute.transform:`) and per-leaf publishing (`publish_each:`) are also covered there.
|
|
82
87
|
|
|
83
88
|
## Intake and freshness
|
|
84
89
|
|
|
85
|
-
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler;
|
|
90
|
+
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; fetch is on demand:
|
|
86
91
|
|
|
87
92
|
```sh
|
|
88
|
-
textus
|
|
89
|
-
textus
|
|
93
|
+
textus fetch feeds.notion.roadmap --as=automation
|
|
94
|
+
textus fetch stale --zone=feeds --as=automation # everything past its TTL
|
|
90
95
|
```
|
|
91
96
|
|
|
92
97
|
Freshness budgets live in the top-level `rules:` block, matched by glob:
|
|
93
98
|
|
|
94
99
|
```yaml
|
|
95
100
|
rules:
|
|
96
|
-
- match:
|
|
97
|
-
|
|
101
|
+
- match: feeds.notion.**
|
|
102
|
+
fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
|
|
98
103
|
```
|
|
99
104
|
|
|
100
|
-
A typical scheduled-
|
|
105
|
+
A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
|
|
101
106
|
|
|
102
107
|
```sh
|
|
103
|
-
textus
|
|
108
|
+
textus fetch stale --zone=intake --as=automation # in cron / CI
|
|
104
109
|
```
|
|
105
110
|
|
|
106
111
|
See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
|
|
107
112
|
|
|
108
|
-
### Read vs.
|
|
113
|
+
### Read vs. fetch
|
|
109
114
|
|
|
110
115
|
There are two read operations, and the difference matters in custom code:
|
|
111
116
|
|
|
112
|
-
| Operation | Triggers
|
|
113
|
-
|
|
117
|
+
| Operation | Triggers fetch? | Use for |
|
|
118
|
+
|-----------|-----------------|---------|
|
|
114
119
|
| `ops.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
|
|
115
|
-
| `ops.
|
|
120
|
+
| `ops.get_or_fetch` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
|
|
116
121
|
|
|
117
|
-
Build always uses the pure path; injecting
|
|
122
|
+
Build always uses the pure path; injecting fetch into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_fetch` only when you genuinely want side effects on read.
|
|
118
123
|
|
|
119
124
|
## Body content
|
|
120
125
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -8,46 +8,56 @@ module Textus
|
|
|
8
8
|
module Boot
|
|
9
9
|
PROTOCOL_ID = PROTOCOL
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"working" => "active project state; humans, AI, and scripts share this surface",
|
|
16
|
-
"intake" => "declared external inputs; script-refreshed via actions",
|
|
17
|
-
"review" => "AI proposals awaiting human accept",
|
|
18
|
-
"output" => "build-computed outputs; never hand-edited",
|
|
19
|
-
}.freeze
|
|
20
|
-
|
|
21
|
-
# Per-kind write-flow templates. Each lambda receives the user-facing role
|
|
22
|
-
# name and returns a guidance string for that role. Roles whose kind has
|
|
23
|
-
# no template (e.g. unknown future kinds) are omitted from write_flows.
|
|
11
|
+
# Per-capability write-flow templates. Each lambda receives the user-facing
|
|
12
|
+
# role name and the manifest, and returns guidance for that verb with the
|
|
13
|
+
# live zone named by kind (ADR 0034). A role holding multiple verbs gets one
|
|
14
|
+
# joined string; roles whose verbs have no template are omitted.
|
|
24
15
|
WRITE_FLOW_TEMPLATES = {
|
|
25
|
-
|
|
26
|
-
"edit files in
|
|
16
|
+
author: lambda do |name, manifest|
|
|
17
|
+
"edit files in #{zone_label(manifest, :canon, "your canon zone")}, " \
|
|
18
|
+
"then 'textus put KEY --as=#{name}'"
|
|
27
19
|
end,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"the #{authority} role runs 'textus accept' to apply"
|
|
20
|
+
keep: lambda do |name, manifest|
|
|
21
|
+
"keep durable notes in #{zone_label(manifest, :workspace, "your workspace")}: " \
|
|
22
|
+
"'textus put KEY --as=#{name}' (no accept needed)"
|
|
32
23
|
end,
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
propose: lambda do |name, manifest|
|
|
25
|
+
authority = manifest.policy.roles_with_capability("author").first || "the author-holder"
|
|
26
|
+
"propose changes by writing #{manifest.policy.queue_zone}.* entries with --as=#{name} " \
|
|
27
|
+
"and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
|
|
35
28
|
end,
|
|
36
|
-
|
|
37
|
-
"
|
|
29
|
+
fetch: lambda do |name, manifest|
|
|
30
|
+
"fetch #{zone_label(manifest, :quarantine, "quarantine")} entries with " \
|
|
31
|
+
"'textus fetch KEY --as=#{name}' (uses the entry's declared action)"
|
|
32
|
+
end,
|
|
33
|
+
build: lambda do |_name, manifest|
|
|
34
|
+
derived = zone_label(manifest, :derived, "derived")
|
|
35
|
+
"'textus build' computes #{derived} entries from projections; " \
|
|
36
|
+
"#{derived} files are never hand-edited"
|
|
38
37
|
end,
|
|
39
38
|
}.freeze
|
|
40
39
|
|
|
41
40
|
def self.write_flows_for(manifest)
|
|
42
|
-
manifest.
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
manifest.data.role_caps.each_with_object({}) do |(name, caps), acc|
|
|
42
|
+
flows = caps.filter_map do |verb|
|
|
43
|
+
tmpl = WRITE_FLOW_TEMPLATES[verb.to_sym]
|
|
44
|
+
tmpl&.call(name, manifest)
|
|
45
|
+
end
|
|
46
|
+
acc[name] = flows.join(" / ") unless flows.empty?
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# Human-readable name(s) for the live zone(s) of a given kind, or `fallback`
|
|
51
|
+
# when the manifest declares none. Lets write-flow guidance name the live
|
|
52
|
+
# zone by kind instead of a hardcoded instance name (ADR 0034).
|
|
53
|
+
def self.zone_label(manifest, kind, fallback)
|
|
54
|
+
zones = manifest.policy.zones_of_kind(kind)
|
|
55
|
+
zones.empty? ? fallback : zones.join(", ")
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
# Static, store-independent parts of the agent-facing protocol. The
|
|
49
|
-
# `role_resolution`
|
|
50
|
-
# because role names are user-configurable.
|
|
59
|
+
# `recipes` and `role_resolution` blocks are derived per-manifest in
|
|
60
|
+
# agent_protocol(...) because zone and role names are user-configurable.
|
|
51
61
|
AGENT_PROTOCOL_TEMPLATE = {
|
|
52
62
|
"envelope_shape" => {
|
|
53
63
|
"summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
|
|
@@ -59,39 +69,6 @@ module Textus
|
|
|
59
69
|
},
|
|
60
70
|
"ref" => "SPEC.md §8",
|
|
61
71
|
},
|
|
62
|
-
"recipes" => {
|
|
63
|
-
"read" => {
|
|
64
|
-
"purpose" => "find and read an entry",
|
|
65
|
-
"steps" => [
|
|
66
|
-
"textus list --zone=ZONE --prefix=PREFIX # discover keys",
|
|
67
|
-
"textus get KEY # returns envelope JSON",
|
|
68
|
-
],
|
|
69
|
-
},
|
|
70
|
-
"write" => {
|
|
71
|
-
"purpose" => "create or update an entry",
|
|
72
|
-
"steps" => [
|
|
73
|
-
"textus schema get FAMILY # learn the _meta field shape",
|
|
74
|
-
"build an envelope JSON: {_meta: {...}, body: \"...\"}",
|
|
75
|
-
"echo ENVELOPE | textus put KEY --as=ROLE --stdin",
|
|
76
|
-
],
|
|
77
|
-
},
|
|
78
|
-
"propose" => {
|
|
79
|
-
"purpose" => "agent suggests a change for human review",
|
|
80
|
-
"agent_steps" => [
|
|
81
|
-
"echo ENVELOPE | textus put review.KEY --as=agent --stdin",
|
|
82
|
-
],
|
|
83
|
-
"human_steps" => [
|
|
84
|
-
"textus accept review.KEY --as=human # promotes the proposal to its target zone",
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
"refresh" => {
|
|
88
|
-
"purpose" => "rebuild stale intake-zone caches from their declared actions",
|
|
89
|
-
"steps" => [
|
|
90
|
-
"textus freshness --zone=intake # report fresh/stale per entry",
|
|
91
|
-
"textus refresh stale --zone=intake --as=runner",
|
|
92
|
-
],
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
72
|
}.freeze
|
|
96
73
|
|
|
97
74
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
@@ -104,11 +81,11 @@ module Textus
|
|
|
104
81
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
105
82
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
106
83
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
107
|
-
{ "name" => "accept", "summary" => "apply a
|
|
84
|
+
{ "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
|
|
108
85
|
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
109
86
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
110
|
-
{ "name" => "build", "summary" => "materialize
|
|
111
|
-
{ "name" => "
|
|
87
|
+
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
|
|
88
|
+
{ "name" => "fetch", "summary" => "run an action for a quarantine entry" },
|
|
112
89
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
113
90
|
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
114
91
|
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
@@ -117,15 +94,14 @@ module Textus
|
|
|
117
94
|
{ "name" => "hook",
|
|
118
95
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
119
96
|
{ "name" => "pulse",
|
|
120
|
-
"summary" => "delta since cursor — changed entries, stale, pending
|
|
97
|
+
"summary" => "delta since cursor — changed entries, stale, pending proposals, doctor summary" },
|
|
121
98
|
].freeze
|
|
122
99
|
|
|
123
100
|
def self.agent_quickstart(manifest, audit_log)
|
|
124
|
-
|
|
125
|
-
agent_role = proposer_roles.first
|
|
101
|
+
agent_role = manifest.policy.proposer_role
|
|
126
102
|
|
|
127
|
-
writable_zones = manifest.data.
|
|
128
|
-
acc << zname if agent_role &&
|
|
103
|
+
writable_zones = manifest.data.declared_zone_kinds.keys.each_with_object([]) do |zname, acc|
|
|
104
|
+
acc << zname if agent_role && manifest.policy.zone_writers(zname).include?(agent_role)
|
|
129
105
|
end
|
|
130
106
|
|
|
131
107
|
propose_zone = manifest.policy.propose_zone_for(agent_role)
|
|
@@ -139,12 +115,51 @@ module Textus
|
|
|
139
115
|
}
|
|
140
116
|
end
|
|
141
117
|
|
|
118
|
+
def self.recipes(manifest)
|
|
119
|
+
queue = manifest.policy.queue_zone
|
|
120
|
+
feeds = zone_label(manifest, :quarantine, "the quarantine zone")
|
|
121
|
+
{
|
|
122
|
+
"read" => {
|
|
123
|
+
"purpose" => "find and read an entry",
|
|
124
|
+
"steps" => [
|
|
125
|
+
"textus list --zone=ZONE --prefix=PREFIX # discover keys",
|
|
126
|
+
"textus get KEY # returns envelope JSON",
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
"write" => {
|
|
130
|
+
"purpose" => "create or update an entry",
|
|
131
|
+
"steps" => [
|
|
132
|
+
"textus schema get FAMILY # learn the _meta field shape",
|
|
133
|
+
"build an envelope JSON: {_meta: {...}, body: \"...\"}",
|
|
134
|
+
"echo ENVELOPE | textus put KEY --as=ROLE --stdin",
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
"propose" => {
|
|
138
|
+
"purpose" => "agent suggests a change for human review",
|
|
139
|
+
"agent_steps" => [
|
|
140
|
+
"echo ENVELOPE | textus put #{queue}.KEY --as=agent --stdin",
|
|
141
|
+
],
|
|
142
|
+
"human_steps" => [
|
|
143
|
+
"textus accept #{queue}.KEY --as=human # promotes the proposal to its target zone",
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
"fetch" => {
|
|
147
|
+
"purpose" => "rebuild stale quarantine-zone caches from their declared actions",
|
|
148
|
+
"steps" => [
|
|
149
|
+
"textus freshness --zone=#{feeds} # report fresh/stale per entry",
|
|
150
|
+
"textus fetch stale --zone=#{feeds} --as=automation",
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
142
156
|
def self.agent_protocol(manifest)
|
|
143
157
|
AGENT_PROTOCOL_TEMPLATE.merge(
|
|
158
|
+
"recipes" => recipes(manifest),
|
|
144
159
|
"role_resolution" => {
|
|
145
160
|
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
|
|
146
161
|
"default 'human'",
|
|
147
|
-
"roles" => manifest.
|
|
162
|
+
"roles" => manifest.data.role_caps.keys,
|
|
148
163
|
"ref" => "SPEC.md §5",
|
|
149
164
|
},
|
|
150
165
|
)
|
|
@@ -167,12 +182,12 @@ module Textus
|
|
|
167
182
|
end
|
|
168
183
|
|
|
169
184
|
def self.zones_for(manifest)
|
|
170
|
-
manifest.data.
|
|
171
|
-
row = { "name" => name, "writers" =>
|
|
185
|
+
manifest.data.declared_zone_kinds.keys.map do |name|
|
|
186
|
+
row = { "name" => name, "writers" => manifest.policy.zone_writers(name) }
|
|
172
187
|
kind = manifest.policy.declared_kind(name)
|
|
173
188
|
row["kind"] = kind.to_s if kind
|
|
174
|
-
purpose =
|
|
175
|
-
row["purpose"] = purpose if purpose
|
|
189
|
+
purpose = manifest.data.zone_descs[name]
|
|
190
|
+
row["purpose"] = purpose if purpose && !purpose.empty?
|
|
176
191
|
row
|
|
177
192
|
end
|
|
178
193
|
end
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Group
|
|
4
|
-
class
|
|
5
|
-
command_name "
|
|
4
|
+
class Fetch < Group
|
|
5
|
+
command_name "fetch"
|
|
6
6
|
|
|
7
7
|
def parse(argv)
|
|
8
8
|
if argv.first == "stale"
|
|
9
9
|
argv.shift
|
|
10
|
-
@sub_klass = Verb::
|
|
10
|
+
@sub_klass = Verb::FetchStale
|
|
11
11
|
else
|
|
12
|
-
@sub_klass = Verb::
|
|
12
|
+
@sub_klass = Verb::Fetch
|
|
13
13
|
end
|
|
14
14
|
@sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
|
|
15
15
|
@sub.parse(argv)
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
Textus::Ports::BuildLock.with(root: store.root) do
|
|
11
|
-
role = store.manifest.policy.
|
|
11
|
+
role = store.manifest.policy.roles_with_capability("build").first || "automation"
|
|
12
12
|
ops = store.as(role)
|
|
13
13
|
result = ops.publish(prefix: prefix)
|
|
14
14
|
emit(result)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Fetch < Verb
|
|
5
|
+
option :as_flag, "--as=ROLE"
|
|
6
|
+
|
|
7
|
+
def call(store)
|
|
8
|
+
key = positional.shift or raise UsageError.new("fetch requires a key")
|
|
9
|
+
emit(session_for(store).fetch(key).to_h_for_wire)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class
|
|
4
|
+
class FetchStale < Verb
|
|
5
5
|
command_name "stale"
|
|
6
|
-
parent_group Group::
|
|
6
|
+
parent_group Group::Fetch
|
|
7
7
|
|
|
8
8
|
option :prefix, "--prefix=KEY"
|
|
9
9
|
option :zone, "--zone=Z"
|
|
10
10
|
option :as_flag, "--as=ROLE"
|
|
11
11
|
|
|
12
12
|
def call(store)
|
|
13
|
-
result = session_for(store).
|
|
13
|
+
result = session_for(store).fetch_all(prefix: prefix, zone: zone)
|
|
14
14
|
emit(result)
|
|
15
15
|
result["ok"] ? 0 : 1
|
|
16
16
|
end
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
11
|
-
result = session_for(store).
|
|
11
|
+
result = session_for(store).get_or_fetch(key)
|
|
12
12
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
|
|
13
13
|
|
|
14
14
|
emit(result.to_h_for_wire)
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Textus
|
|
|
25
25
|
{
|
|
26
26
|
"_meta" => {
|
|
27
27
|
"name" => basename,
|
|
28
|
-
"
|
|
28
|
+
"last_fetched_at" => Time.now.utc.iso8601,
|
|
29
29
|
"fetched_with" => fetch_name,
|
|
30
30
|
}.merge(result[:_meta] || result["_meta"] || {}),
|
|
31
31
|
"body" => result[:body] || result["body"] || "",
|
|
@@ -8,16 +8,16 @@ module Textus
|
|
|
8
8
|
def call(store)
|
|
9
9
|
policies = store.manifest.rules.blocks.map do |b|
|
|
10
10
|
row = { "match" => b.match }
|
|
11
|
-
if b.
|
|
12
|
-
row["
|
|
13
|
-
"ttl_seconds" => b.
|
|
14
|
-
"on_stale" => b.
|
|
15
|
-
"sync_budget_ms" => b.
|
|
16
|
-
"fetch_timeout_seconds" => b.
|
|
11
|
+
if b.fetch
|
|
12
|
+
row["fetch"] = {
|
|
13
|
+
"ttl_seconds" => b.fetch.ttl_seconds,
|
|
14
|
+
"on_stale" => b.fetch.on_stale,
|
|
15
|
+
"sync_budget_ms" => b.fetch.sync_budget_ms,
|
|
16
|
+
"fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
|
|
17
17
|
}
|
|
18
18
|
end
|
|
19
19
|
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
20
|
-
row["
|
|
20
|
+
row["guard"] = b.guard if b.guard
|
|
21
21
|
row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
|
|
22
22
|
row
|
|
23
23
|
end
|
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
|
|
94
|
-
textus
|
|
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
|
data/lib/textus/container.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -11,13 +11,13 @@ module Textus
|
|
|
11
11
|
accept: Textus::Write::Accept,
|
|
12
12
|
reject: Textus::Write::Reject,
|
|
13
13
|
publish: Textus::Write::Publish,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
fetch: Textus::Write::FetchWorker,
|
|
15
|
+
fetch_all: Textus::Write::FetchAll,
|
|
16
16
|
retention_sweep: Textus::Write::RetentionSweep,
|
|
17
17
|
|
|
18
18
|
# Read
|
|
19
19
|
get: Textus::Read::Get,
|
|
20
|
-
|
|
20
|
+
get_or_fetch: Textus::Read::GetOrFetch,
|
|
21
21
|
list: Textus::Read::List,
|
|
22
22
|
where: Textus::Read::Where,
|
|
23
23
|
uid: Textus::Read::Uid,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Doctor
|
|
3
3
|
class Check
|
|
4
|
-
# Lists per-key
|
|
4
|
+
# Lists per-key fetch lock files under <root>/.locks/ whose
|
|
5
5
|
# recorded PID is no longer running. These are forensic artifacts only:
|
|
6
|
-
#
|
|
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
|
|
10
|
-
class
|
|
9
|
+
# (e.g. a fetch path that crashes repeatedly).
|
|
10
|
+
class FetchLocks < Check
|
|
11
11
|
def call
|
|
12
12
|
dir = File.join(root, ".locks")
|
|
13
13
|
return [] unless File.directory?(dir)
|
|
@@ -23,11 +23,11 @@ module Textus
|
|
|
23
23
|
return nil if pid_alive?(pid)
|
|
24
24
|
|
|
25
25
|
{
|
|
26
|
-
"code" => "
|
|
26
|
+
"code" => "fetch_lock.stale",
|
|
27
27
|
"level" => "info",
|
|
28
28
|
"subject" => path,
|
|
29
|
-
"message" => "
|
|
30
|
-
"(does not block
|
|
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 (
|
|
6
|
-
#
|
|
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[
|
|
9
|
+
SLOTS = %i[fetch handler_allowlist guard].freeze
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
12
|
out = []
|