textus 0.29.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -235
  3. data/CHANGELOG.md +169 -0
  4. data/README.md +85 -64
  5. data/SPEC.md +366 -201
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +93 -76
  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/hook_run.rb +2 -6
  14. data/lib/textus/cli/verb/hooks.rb +1 -1
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +8 -8
  18. data/lib/textus/cli.rb +21 -18
  19. data/lib/textus/container.rb +1 -2
  20. data/lib/textus/dispatcher.rb +11 -3
  21. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
  22. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  23. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  24. data/lib/textus/doctor/check.rb +8 -5
  25. data/lib/textus/doctor.rb +2 -1
  26. data/lib/textus/domain/action.rb +3 -3
  27. data/lib/textus/domain/duration.rb +22 -0
  28. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  29. data/lib/textus/domain/freshness/policy.rb +2 -2
  30. data/lib/textus/domain/freshness.rb +7 -7
  31. data/lib/textus/domain/outcome.rb +2 -2
  32. data/lib/textus/domain/permission.rb +2 -10
  33. data/lib/textus/domain/policy/base_guards.rb +25 -0
  34. data/lib/textus/domain/policy/evaluation.rb +18 -0
  35. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
  36. data/lib/textus/domain/policy/guard.rb +35 -0
  37. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  38. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  39. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  40. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  41. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  42. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  43. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  44. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  45. data/lib/textus/domain/policy/retention.rb +26 -0
  46. data/lib/textus/domain/retention.rb +44 -0
  47. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  48. data/lib/textus/envelope/io/reader.rb +4 -0
  49. data/lib/textus/envelope/io/writer.rb +8 -0
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +12 -24
  53. data/lib/textus/hooks/rpc_registry.rb +9 -35
  54. data/lib/textus/hooks/signature.rb +31 -0
  55. data/lib/textus/init.rb +24 -18
  56. data/lib/textus/maintenance/zone_mv.rb +1 -1
  57. data/lib/textus/manifest/capabilities.rb +29 -0
  58. data/lib/textus/manifest/data.rb +16 -8
  59. data/lib/textus/manifest/entry/base.rb +2 -2
  60. data/lib/textus/manifest/policy.rb +62 -19
  61. data/lib/textus/manifest/rules.rb +25 -14
  62. data/lib/textus/manifest/schema.rb +78 -38
  63. data/lib/textus/manifest.rb +6 -5
  64. data/lib/textus/mcp/server.rb +2 -10
  65. data/lib/textus/mcp/session.rb +7 -23
  66. data/lib/textus/mcp/tool_schemas.rb +3 -3
  67. data/lib/textus/mcp/tools.rb +7 -7
  68. data/lib/textus/ports/audit_subscriber.rb +1 -1
  69. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  70. data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
  71. data/lib/textus/projection.rb +1 -1
  72. data/lib/textus/read/freshness.rb +9 -9
  73. data/lib/textus/read/get.rb +8 -8
  74. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  75. data/lib/textus/read/policy_explain.rb +19 -10
  76. data/lib/textus/read/pulse.rb +5 -4
  77. data/lib/textus/read/retainable.rb +17 -0
  78. data/lib/textus/read/validator.rb +1 -1
  79. data/lib/textus/role_scope.rb +3 -2
  80. data/lib/textus/schema/tools.rb +5 -5
  81. data/lib/textus/version.rb +1 -1
  82. data/lib/textus/write/accept.rb +19 -55
  83. data/lib/textus/write/delete.rb +15 -17
  84. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
  85. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  86. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
  87. data/lib/textus/write/intake_fetch.rb +23 -0
  88. data/lib/textus/write/mv.rb +17 -15
  89. data/lib/textus/write/put.rb +15 -17
  90. data/lib/textus/write/reject.rb +11 -5
  91. data/lib/textus/write/retention_sweep.rb +55 -0
  92. metadata +32 -18
  93. data/lib/textus/cli/verb/refresh.rb +0 -14
  94. data/lib/textus/domain/authorizer.rb +0 -37
  95. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  96. data/lib/textus/domain/policy/promote.rb +0 -26
  97. data/lib/textus/domain/policy/promotion.rb +0 -57
  98. data/lib/textus/manifest/role_kinds.rb +0 -21
  99. 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,18 +94,17 @@ 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
- propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
107
+ propose_zone = manifest.policy.propose_zone_for(agent_role)
132
108
 
133
109
  {
134
110
  "read_verbs" => %w[boot get list audit pulse freshness doctor],
@@ -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,17 +182,19 @@ 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) }
172
- purpose = ZONE_PURPOSES[name]
173
- row["purpose"] = purpose if purpose
185
+ manifest.data.declared_zone_kinds.keys.map do |name|
186
+ row = { "name" => name, "writers" => manifest.policy.zone_writers(name) }
187
+ kind = manifest.policy.declared_kind(name)
188
+ row["kind"] = kind.to_s if kind
189
+ purpose = manifest.data.zone_descs[name]
190
+ row["purpose"] = purpose if purpose && !purpose.empty?
174
191
  row
175
192
  end
176
193
  end
177
194
 
178
195
  def self.entries_for(manifest)
179
196
  manifest.data.entries.map do |e|
180
- derived = manifest.policy.zone_kinds(e.zone).include?(:generator)
197
+ derived = manifest.policy.derived_zone?(e.zone)
181
198
  {
182
199
  "key" => e.key,
183
200
  "zone" => e.zone,
@@ -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)
@@ -29,12 +29,8 @@ module Textus
29
29
  Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
30
 
31
31
  begin
32
- Timeout.timeout(Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
33
- store.rpc.invoke(:resolve_intake, name, caps: nil, config: {}, args: args)
34
- end
35
- rescue Timeout::Error
36
- raise UsageError.new(
37
- "hook run '#{name}' exceeded #{Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
32
+ Textus::Write::IntakeFetch.invoke(
33
+ rpc: store.rpc, handler: name, config: {}, args: args, label: "hook run",
38
34
  )
39
35
  rescue Textus::Error
40
36
  raise
@@ -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
@@ -17,24 +17,15 @@ module Textus
17
17
  raw = @stdin.read
18
18
  payload =
19
19
  if fetch_name
20
- result =
21
- begin
22
- Timeout.timeout(Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
23
- store.rpc.invoke(:resolve_intake, fetch_name,
24
- caps: nil,
25
- config: { "bytes" => raw },
26
- args: {})
27
- end
28
- rescue Timeout::Error
29
- raise UsageError.new(
30
- "fetch '#{fetch_name}' exceeded #{Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
31
- )
32
- end
20
+ result = Textus::Write::IntakeFetch.invoke(
21
+ rpc: store.rpc, handler: fetch_name,
22
+ config: { "bytes" => raw }, args: {}, label: "fetch"
23
+ )
33
24
  basename = key.split(".").last
34
25
  {
35
26
  "_meta" => {
36
27
  "name" => basename,
37
- "last_refreshed_at" => Time.now.utc.iso8601,
28
+ "last_fetched_at" => Time.now.utc.iso8601,
38
29
  "fetched_with" => fetch_name,
39
30
  }.merge(result[:_meta] || result["_meta"] || {}),
40
31
  "body" => result[:body] || result["body"] || "",
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Retain < Verb
5
+ command_name "retain"
6
+
7
+ option :prefix, "--prefix=KEY"
8
+ option :zone, "--zone=Z"
9
+ option :as_flag, "--as=ROLE"
10
+
11
+ def call(store)
12
+ result = session_for(store).retention_sweep(prefix: prefix, zone: zone)
13
+ emit(result)
14
+ result["ok"] ? 0 : 1
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -8,17 +8,17 @@ 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
21
- row["retention"] = b.retention if b.retention
20
+ row["guard"] = b.guard if b.guard
21
+ row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
22
22
  row
23
23
  end
24
24
  emit({ "verb" => "policy_list", "policies" => policies })
data/lib/textus/cli.rb CHANGED
@@ -27,22 +27,25 @@ module Textus
27
27
  end
28
28
 
29
29
  def run(argv)
30
- OptionParser.new { |o| o.on("--root=PATH") { |v| @root_arg = v } }.order!(argv)
30
+ # Define --version/--help ourselves so OptionParser doesn't intercept them
31
+ # with its built-in handlers (which print "version unknown" and a bare usage
32
+ # line, then exit before we ever reach the verb dispatch below).
33
+ show_version = false
34
+ show_help = false
35
+ OptionParser.new do |o|
36
+ o.on("--root=PATH") { |v| @root_arg = v }
37
+ o.on("--version", "-v") { show_version = true }
38
+ o.on("--help", "-h") { show_help = true }
39
+ end.order!(argv)
40
+
41
+ return @stdout.puts(VERSION) || 0 if show_version
42
+ return print_help || 0 if show_help
43
+
31
44
  verb = argv.shift
32
45
  raise UsageError.new("missing verb") if verb.nil?
33
46
 
34
- result =
35
- case verb
36
- when "--version", "-v" then @stdout.puts(VERSION)
37
- 0
38
- when "--help", "-h" then print_help
39
- 0
40
- else
41
- klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
42
- dispatch(klass, argv)
43
- end
44
-
45
- coerce_exit_code(result)
47
+ klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
48
+ coerce_exit_code(dispatch(klass, argv))
46
49
  rescue Textus::Error => e
47
50
  emit_error(e)
48
51
  end
@@ -87,16 +90,16 @@ module Textus
87
90
  textus get KEY
88
91
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
89
92
  textus freshness [--prefix=KEY] [--zone=Z]
90
- textus refresh KEY
91
- textus refresh stale [--prefix=KEY] [--zone=Z]
93
+ textus fetch KEY
94
+ textus fetch stale [--prefix=KEY] [--zone=Z]
92
95
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
93
96
  textus blame KEY [--limit=N]
94
97
  textus doctor
95
98
  textus boot
96
99
 
97
- textus key {mv,uid,normalize}
98
- textus rule {list,explain}
99
- textus schema {show,init,diff,migrate}
100
+ textus key {delete,mv,uid}
101
+ textus rule {explain,lint,list}
102
+ textus schema {diff,init,migrate,show}
100
103
  textus hook {list,run}
101
104
  HELP
102
105
  end
@@ -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