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/docs/conventions.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Conventions
2
2
 
3
- Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build runners. The spec ([`../SPEC.md`](../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
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
- identity/ # identity, voice, slow-changing facts humans only
22
- working/ # agent-writable working memory
23
- intake/ # runner-fed external inputs
24
- review/ # AI proposals awaiting accept
25
- output/ # generated by build runners — never edit by hand
24
+ knowledge/ # authored truth: identity, voice, decisionsauthor-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 `working/`, group by **domain** (people, projects, decisions, runbooks), not by file type or date. Inside `output/`, group by **producer** (`output/catalogs/`, `output/indexes/`) so it's clear which build job owns what.
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
- textus supports two shapes for derived entries:
52
+ A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
50
53
 
51
- **`projection:`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
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: output.catalogs.people
55
- path: output/catalogs/people.md
56
- zone: output
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
- projection:
60
- select: working.network.org # prefix or list of prefixes
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 # under .textus/templates/
64
- publish_to: [docs/people.md] # optional repo-relative byte-copy targets
67
+ template: people.mustache # under .textus/templates/
68
+ publish_to: [docs/people.md] # optional repo-relative byte-copy targets
65
69
  ```
66
70
 
67
- **`generator:`** — 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`.
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: output.catalogs.skills
71
- path: output/catalogs/skills.md
72
- zone: output
74
+ - key: artifacts.catalogs.skills
75
+ path: artifacts/catalogs/skills.md
76
+ zone: artifacts
73
77
  owner: build:catalog-skills
74
- generator:
75
- command: "rake catalog:skills" # informational; the runner invokes it
76
- sources: [working.projects, working.network]
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 runner is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `generator.sources` — same list, recorded twice so a diff proves what was consumed.
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.1](../SPEC.md). Reducers (`projection.reduce:`) and per-leaf publishing (`publish_each:`) are also covered there.
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; refresh is on demand:
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 refresh intake.notion.roadmap --as=runner
89
- textus refresh-stale --zone=intake --as=runner # everything past its TTL
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: intake.notion.**
97
- refresh: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
101
+ - match: feeds.notion.**
102
+ fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
98
103
  ```
99
104
 
100
- A typical scheduled-refresh integration shells the `refresh-stale` sweep itself:
105
+ A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
101
106
 
102
107
  ```sh
103
- textus refresh-stale --zone=intake --as=runner # in cron / CI
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. refresh
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 refresh? | Use for |
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.get_or_refresh` | 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 |
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 refresh into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_refresh` only when you genuinely want side effects on read.
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
- # Conventional zone purposes. Unknown zones (declared in the manifest
12
- # but not listed here) get no `purpose` field.
13
- ZONE_PURPOSES = {
14
- "identity" => "slow-changing identity; human-only writes",
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
- accept_authority: lambda do |name, _manifest|
26
- "edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
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
- proposer: lambda do |name, manifest|
29
- authority = manifest.policy.roles_with_kind(:accept_authority).first || "accept_authority"
30
- "propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
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
- runner: lambda do |name, _manifest|
34
- "refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
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
- generator: lambda do |_name, _manifest|
37
- "'textus build' computes output entries from projections; output files are never hand-edited"
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.policy.role_mapping.each_with_object({}) do |(name, kind), acc|
43
- tmpl = WRITE_FLOW_TEMPLATES[kind]
44
- acc[name] = tmpl.call(name, manifest) if tmpl
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` block is derived per-manifest in agent_protocol(...)
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 review.* proposal; --as=human only" },
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 output entries; publish_to and publish_each fan out copies" },
111
- { "name" => "refresh", "summary" => "run an action for an intake entry" },
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 review, doctor summary" },
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
- proposer_roles = manifest.policy.roles_with_kind(:proposer)
125
- agent_role = proposer_roles.first
101
+ agent_role = manifest.policy.proposer_role
126
102
 
127
- writable_zones = manifest.data.zones.each_with_object([]) do |(zname, writers), acc|
128
- acc << zname if agent_role && writers.include?(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.policy.role_mapping.keys,
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.zones.map do |name, writers|
171
- row = { "name" => name, "writers" => Array(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 = ZONE_PURPOSES[name]
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 Refresh < Group
5
- command_name "refresh"
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::RefreshStale
10
+ @sub_klass = Verb::FetchStale
11
11
  else
12
- @sub_klass = Verb::Refresh
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.roles_with_kind(:generator).first || "builder"
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 RefreshStale < Verb
4
+ class FetchStale < Verb
5
5
  command_name "stale"
6
- parent_group Group::Refresh
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).refresh_all(prefix: prefix, zone: zone)
13
+ result = session_for(store).fetch_all(prefix: prefix, zone: zone)
14
14
  emit(result)
15
15
  result["ok"] ? 0 : 1
16
16
  end
@@ -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).get_or_refresh(key)
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)
@@ -35,7 +35,7 @@ module Textus
35
35
 
36
36
  rows << {
37
37
  "event" => evt.to_s, "mode" => "manifest", "exec" => defn["exec"],
38
- "key" => e.key, "as" => defn["as"] || "runner"
38
+ "key" => e.key, "as" => defn["as"] || "automation"
39
39
  }
40
40
  end
41
41
  end
@@ -25,7 +25,7 @@ module Textus
25
25
  {
26
26
  "_meta" => {
27
27
  "name" => basename,
28
- "last_refreshed_at" => Time.now.utc.iso8601,
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.refresh
12
- row["refresh"] = {
13
- "ttl_seconds" => b.refresh.ttl_seconds,
14
- "on_stale" => b.refresh.on_stale,
15
- "sync_budget_ms" => b.refresh.sync_budget_ms,
16
- "fetch_timeout_seconds" => b.refresh.fetch_timeout_seconds,
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["promotion"] = { "requires" => b.promote.requires } if b.promote
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 refresh KEY
94
- textus refresh stale [--prefix=KEY] [--zone=Z]
93
+ textus fetch KEY
94
+ textus fetch stale [--prefix=KEY] [--zone=Z]
95
95
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
96
96
  textus blame KEY [--limit=N]
97
97
  textus doctor
@@ -3,7 +3,7 @@ module Textus
3
3
  # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
4
4
  Container = Data.define(
5
5
  :manifest, :file_store, :schemas, :root,
6
- :audit_log, :events, :rpc, :authorizer
6
+ :audit_log, :events, :rpc
7
7
  )
8
8
 
9
9
  class Container
@@ -16,7 +16,6 @@ module Textus
16
16
  audit_log: store.audit_log,
17
17
  events: store.events,
18
18
  rpc: store.rpc,
19
- authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest),
20
19
  )
21
20
  end
22
21
  end
@@ -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
- refresh: Textus::Write::RefreshWorker,
15
- refresh_all: Textus::Write::RefreshAll,
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
- get_or_refresh: Textus::Read::GetOrRefresh,
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 refresh lock files under <root>/.locks/ whose
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
- # Refresh::Lock uses flock(2), which the kernel releases on process
6
+ # Fetch::Lock uses flock(2), which the kernel releases on process
7
7
  # death, so stale files do not block subsequent acquires. The check
8
8
  # exists to let users clean up clutter and notice unexpected accumulation
9
- # (e.g. a refresh path that crashes repeatedly).
10
- class RefreshLocks < Check
9
+ # (e.g. a fetch path that crashes repeatedly).
10
+ class FetchLocks < Check
11
11
  def call
12
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" => "refresh_lock.stale",
26
+ "code" => "fetch_lock.stale",
27
27
  "level" => "info",
28
28
  "subject" => path,
29
- "message" => "refresh lock file at #{path} records dead PID #{pid} " \
30
- "(does not block refresh; flock is kernel-released on exit)",
29
+ "message" => "fetch lock file at #{path} records dead PID #{pid} " \
30
+ "(does not block fetch; flock is kernel-released on exit)",
31
31
  "fix" => "safe to delete: rm #{path}",
32
32
  }
33
33
  rescue Errno::ENOENT
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Flags pending proposals whose `proposal.target_key` cannot ever be
5
+ # accepted: it points at a non-canon zone or resolves to no declared
6
+ # entry (ADR 0035). Reads the live queue zone; silent when there is no
7
+ # queue zone. Warnings, not errors — they are stale junk, not store
8
+ # corruption (the accept gate already refuses them).
9
+ class ProposalTargets < Check
10
+ def call
11
+ queue = manifest.policy.queue_zone
12
+ return [] unless queue
13
+
14
+ dispatch(:list, zone: queue).filter_map { |row| issue_for(row["key"]) }
15
+ end
16
+
17
+ private
18
+
19
+ def issue_for(key)
20
+ target = dispatch(:get, key).meta&.dig("proposal", "target_key")
21
+ return nil if target.nil? # not a proposal entry — skip
22
+
23
+ zone = manifest.resolver.resolve(target).entry.zone
24
+ return nil if manifest.policy.declared_kind(zone.to_s) == :canon
25
+
26
+ {
27
+ "code" => "proposal.target_not_canon",
28
+ "level" => "warning",
29
+ "subject" => key,
30
+ "message" => "proposal '#{key}' targets '#{target}' in zone '#{zone}' (not canon); it can never be accepted",
31
+ "fix" => "delete the proposal, or repoint target_key at a canon zone",
32
+ }
33
+ rescue Textus::UnknownKey
34
+ {
35
+ "code" => "proposal.target_unresolved",
36
+ "level" => "warning",
37
+ "subject" => key,
38
+ "message" => "proposal '#{key}' targets '#{target}', which resolves to no declared entry",
39
+ "fix" => "delete the proposal, or fix target_key",
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,11 +2,11 @@ module Textus
2
2
  module Doctor
3
3
  class Check
4
4
  # Flags entries whose key is matched by two or more rule blocks of the
5
- # SAME specificity in the same slot (refresh / handler_allowlist /
6
- # promote). Ties are non-deterministic in the parser's pick step, so
5
+ # SAME specificity in the same slot (fetch / handler_allowlist /
6
+ # guard). Ties are non-deterministic in the parser's pick step, so
7
7
  # they're a configuration smell — surface them.
8
8
  class RuleAmbiguity < Check
9
- SLOTS = %i[refresh handler_allowlist promote].freeze
9
+ SLOTS = %i[fetch handler_allowlist guard].freeze
10
10
 
11
11
  def call
12
12
  out = []