textus 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +8 -2
  3. data/CHANGELOG.md +56 -0
  4. data/README.md +9 -9
  5. data/SPEC.md +32 -8
  6. data/lib/textus/boot.rb +4 -2
  7. data/lib/textus/cli/verb/hook_run.rb +2 -6
  8. data/lib/textus/cli/verb/put.rb +4 -13
  9. data/lib/textus/cli/verb/retain.rb +19 -0
  10. data/lib/textus/cli/verb/rule_list.rb +1 -1
  11. data/lib/textus/cli.rb +19 -16
  12. data/lib/textus/dispatcher.rb +8 -0
  13. data/lib/textus/doctor/check.rb +8 -5
  14. data/lib/textus/domain/duration.rb +22 -0
  15. data/lib/textus/domain/policy/refresh.rb +1 -15
  16. data/lib/textus/domain/policy/retention.rb +26 -0
  17. data/lib/textus/domain/retention.rb +44 -0
  18. data/lib/textus/envelope/io/reader.rb +4 -0
  19. data/lib/textus/envelope/io/writer.rb +8 -0
  20. data/lib/textus/hooks/event_bus.rb +8 -20
  21. data/lib/textus/hooks/rpc_registry.rb +9 -35
  22. data/lib/textus/hooks/signature.rb +31 -0
  23. data/lib/textus/init.rb +7 -6
  24. data/lib/textus/manifest/data.rb +5 -1
  25. data/lib/textus/manifest/entry/base.rb +2 -2
  26. data/lib/textus/manifest/policy.rb +34 -7
  27. data/lib/textus/manifest/rules.rb +10 -1
  28. data/lib/textus/manifest/schema.rb +54 -4
  29. data/lib/textus/manifest.rb +3 -2
  30. data/lib/textus/mcp/server.rb +1 -9
  31. data/lib/textus/mcp/session.rb +7 -23
  32. data/lib/textus/read/policy_explain.rb +5 -0
  33. data/lib/textus/read/retainable.rb +17 -0
  34. data/lib/textus/role_scope.rb +3 -2
  35. data/lib/textus/version.rb +1 -1
  36. data/lib/textus/write/delete.rb +1 -15
  37. data/lib/textus/write/intake_fetch.rb +23 -0
  38. data/lib/textus/write/mv.rb +2 -12
  39. data/lib/textus/write/put.rb +1 -15
  40. data/lib/textus/write/refresh_worker.rb +2 -16
  41. data/lib/textus/write/retention_sweep.rb +55 -0
  42. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e8208a516e0a7760953e7c44a74fb459f168192ac4b0a20059b8686285f137a
4
- data.tar.gz: 063deb01f793cec399a68620ce23bf1e9daf6d797edb985d919cb3bd6b9a1f87
3
+ metadata.gz: d84508fac499044df50d5fa793000fbd2249732c6b867f1b0f88535bfab4f083
4
+ data.tar.gz: 5069218d7c3c1a8360f4507bb99f94951e92a7d345ba4fb44ee72819fb4739e4
5
5
  SHA512:
6
- metadata.gz: 29d392ea08bbd23f460761c1849c90d144a97b9e9208355818b364acbf72c708e2129c650ecf032a562b62271064094dac96ed5f6497c97128666e004959d47d
7
- data.tar.gz: 2260fe709abe9685285cad7171e36def69a6e25047762c2077dcb4c70e670f8232123156b33b5811dfd79393592b563470819c641ff9508ec2b795f3b87065ad
6
+ metadata.gz: abf8e5e74b77227f34bbc95dd38c1e9b8d716f56f1cda11664f3da284f417d8581a51e4c27accdcb88173c274ff1aa929443bb027b851a95cbced029fdf34852
7
+ data.tar.gz: bd63c95ae46643c063c1b035fc859957418f185795fc32b54f798b44779d825101d9f71667a85f518dacab6ffbcebe70bc5676c9ae24e80cc9de4fa44e38aa38
data/ARCHITECTURE.md CHANGED
@@ -48,7 +48,7 @@
48
48
  │ Ports::{AuditLog,AuditSubscriber,Publisher,Clock, │
49
49
  │ Refresh::Lock,Refresh::Detached,BuildLock} │
50
50
  │ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport, │
51
- │ Builtin,ErrorLog}
51
+ Signature,Builtin,ErrorLog}
52
52
  │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
53
53
  └────────────────────────────────────────────────────────────┘
54
54
 
@@ -85,6 +85,8 @@ end
85
85
 
86
86
  Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get → Textus::Read::Get`, `:put → Textus::Write::Put`, etc. `Store#put` / `Store#get` / `Store#as(role).<verb>(...)` instantiate the use case on `(container:, call:)` and invoke `#call`. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
87
87
 
88
+ The instantiate-and-call step itself has one home: `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` (ADR 0026). `RoleScope` builds the `Call` (request state) and delegates the dispatch to `Dispatcher.invoke`; the convention for invoking a uniform-shape use case lives next to the table that maps the verbs, not re-spelled in the caller. `Store`'s own verb loop is separate — it extracts the `role:` keyword and forwards to `as(role)`, a role-selection job distinct from invocation.
89
+
88
90
  `boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
89
91
  are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
90
92
  `Textus::Doctor` report-builder libraries (`build(container:, ...)`). They are
@@ -134,6 +136,8 @@ Application use cases access ports only through `Container` fields — never thr
134
136
 
135
137
  The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
136
138
 
139
+ Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` (which builds its own `Reader.from`) and `Reader.from(container:)` (ADR 0026). Write use cases call `Writer.from` rather than reconstructing the object graph by hand, so a change to the Writer's dependencies is a one-line edit in one place.
140
+
137
141
  ## Manifest carving
138
142
 
139
143
  Manifest carving means slicing the parsed manifest YAML into four purpose-specific sub-objects. Each consumer sees only the fields it needs; none reach into the full raw document.
@@ -144,7 +148,7 @@ Manifest carving means slicing the parsed manifest YAML into four purpose-specif
144
148
  |---|---|---|
145
149
  | `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_mapping`. Structural data only — no behaviour beyond accessors and key validation. |
146
150
  | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
147
- | `policy` | `Manifest::Policy` | Zone/role authority — `zone_writers`, `zone_kinds`, `permission_for`, `role_kind`, `roles_with_kind`. Derived from a `Data` snapshot; no filesystem I/O. |
151
+ | `policy` | `Manifest::Policy` | Zone/role authority — `zone_writers`, `zone_kinds`, `permission_for`, `role_kind`, `roles_with_kind`, `propose_zone_for(role)`. Derived from a `Data` snapshot; no filesystem I/O. `propose_zone_for` owns the "first writable zone whose name contains `review`" convention used by `MCP::Server` (ADR 0027). |
148
152
  | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` returns a `RuleSet(refresh, handler_allowlist, promote, retention)` by evaluating all `match:` blocks against the key. |
149
153
 
150
154
  Rationale: cleaner test seams — a use case that only needs key resolution constructs a `Manifest::Resolver` from a stub `Data`; one that only needs rule lookup constructs a `Manifest::Rules` directly. No consumer is forced to build the full manifest to exercise one sub-view.
@@ -214,6 +218,8 @@ Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit curso
214
218
 
215
219
  ## Hooks::EventBus event catalog
216
220
 
221
+ `Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027).
222
+
217
223
  RPC (single handler, declares `caps:`):
218
224
  - `resolve_intake(caps:, config:, args:)` — intake fetch handler.
219
225
  - `transform_rows(caps:, rows:, config:)` — row transform for intakes.
data/CHANGELOG.md CHANGED
@@ -9,6 +9,62 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.30.0 — 2026-05-29
13
+
14
+ Explicit zone kind (strict) and entry retention (ADR 0028, moves 1 & 4). No wire format (`textus/3`) change.
15
+
16
+ ### Changed (BREAKING)
17
+
18
+ - Zone `kind:` is now **required** on every zone (`origin | quarantine | queue | derived`); a manifest with a kind-less zone is rejected at load. The kind is authoritative: a zone is `derived` only if it declares `kind: derived`, and proposals route only to the zone declaring `kind: queue`. The previous writers→kind inference, the `"review"`-name proposal fallback, and boot's arbitrary-zone propose default were removed. No `textus/3` wire-format change; existing manifests must add `kind:` to every zone.
19
+
20
+ ### Added
21
+
22
+ - `Manifest::Policy#declared_kind`, `#queue_zone`, `#derived_zone?`. `propose_zone_for` now resolves through the declared `queue` zone exclusively.
23
+ - `retention:` rule block (`expire_after`, `archive_after`) parsed into `Domain::Policy::Retention`. New `textus retain --as=ROLE` sweep expires (deletes) or archives leaves past their window — `expire_after` deletes, `archive_after` copies to `.textus/archive/` then deletes; age is the leaf's mtime. `--prefix`/`--zone` narrow the sweep; rows whose zone the role can't write surface as failures. Retention appears in `textus rule explain`.
24
+ - `Textus::Domain::Duration.seconds` — shared duration parser (`30s`/`90m`/`12h`/`30d`/bare seconds), now also backing `Refresh#ttl_seconds`.
25
+
26
+ ### Internal
27
+
28
+ - `Manifest::Entry::Base#in_generator_zone?` and `boot` derived/proposal detection route through `Policy#derived_zone?` / `#propose_zone_for`; all `"review"` substring matches and the `Policy#zone_kinds` inference method are removed.
29
+ - Dead `Policy#zone_kinds` method removed.
30
+
31
+ ## 0.29.2 — 2026-05-29
32
+
33
+ Hook-registry convergence and MCP transport de-leak (ADR 0027). Every change is additive or internal — no wire format (`textus/3`) or manifest-schema change, no public class renamed or removed.
34
+
35
+ ### Added
36
+
37
+ - `Textus::Hooks::Signature` — single home of callable keyword-introspection (`accepts_keyrest?`, `declared_keys`, `missing`, `filter`), shared by both `EventBus` and `RpcRegistry`.
38
+ - `Manifest::Policy#propose_zone_for(role)` — owns the "first writable zone whose name contains `review`" convention; `MCP::Server#handle_initialize` delegates to it instead of scanning `manifest.data.zones` inline.
39
+
40
+ ### Internal
41
+
42
+ - `EventBus` and `RpcRegistry` both delegate callable introspection to `Hooks::Signature`; both `shape_check!` copies and the hand-rolled `filter_kwargs`/`invoke` derivations are deleted.
43
+ - Removed the `store:`→`caps:` legacy shim from `RpcRegistry`: a handler declaring `store:` (instead of `caps:`) is now rejected at registration time with an honest message, not at invoke time. Stale in-repo RPC hook fixtures and the `textus init` scaffold example are migrated to `caps:`.
44
+ - `MCP::Server#handle_initialize` no longer iterates `manifest.data.zones`; it calls `policy.propose_zone_for(proposer)`. No zone-selection logic remains in the JSON-RPC transport handler.
45
+ - `MCP::Session` converted from a hand-rolled immutable class to `Data.define(:role, :cursor, :propose_zone, :manifest_etag)`, matching the house convention used by all other value objects.
46
+
47
+ ### Behavior change (non-breaking in practice)
48
+
49
+ - RPC handlers declaring `store:` previously registered successfully and failed only at first invocation (with a misleading message). They now fail at registration time with a message naming the correct kwarg (`caps:`). No handler using `store:` was valid before; only the timing and clarity of the error change.
50
+
51
+ ## 0.29.1 — 2026-05-29
52
+
53
+ Construction-side cleanup of the use-case layer (ADR 0026). Every change is additive or internal — no public class renamed or removed, wire format (`textus/3`) and CLI unchanged.
54
+
55
+ ### Added
56
+
57
+ - `Envelope::IO::Writer.from(container:, call:)` and `Envelope::IO::Reader.from(container:)` — named constructors that build the envelope IO collaborators from a `Container`. `Writer.new`/`Reader.new` are unchanged.
58
+ - `Write::IntakeFetch.invoke(rpc:, handler:, config:, args:, label:, timeout:)` — the transport-side "invoke a `:resolve_intake` handler under a timeout" kernel; now the canonical home of `FETCH_TIMEOUT_SECONDS`.
59
+ - `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` — single home for the uniform use-case invocation protocol.
60
+
61
+ ### Internal
62
+
63
+ - `Write::{Put,Delete,Mv,RefreshWorker}` no longer hand-wire `Envelope::IO::Writer`/`Reader`; they call `Writer.from`. Removed ~60 lines of byte-identical construction boilerplate.
64
+ - `cli/verb/put.rb` (`--fetch`) and `cli/verb/hook_run.rb` no longer inline `Timeout.timeout { store.rpc.invoke(:resolve_intake, …) }`; both route through `Write::IntakeFetch`. No intake-fetch mechanics remain under `lib/textus/cli/`.
65
+ - `RoleScope`'s verb loop delegates the instantiate-and-call step to `Dispatcher.invoke`; it still builds the `Call`. `Store`'s role-selecting verb loop is unchanged.
66
+ - `RefreshWorker::FETCH_TIMEOUT_SECONDS` is now an alias of `IntakeFetch::FETCH_TIMEOUT_SECONDS`.
67
+
12
68
  ## 0.29.0 — 2026-05-29
13
69
 
14
70
  A domain-purity pass that routes all filesystem and wall-clock I/O through injected ports. Breaking changes are Ruby-API only; the wire format (`textus/3`) and CLI are unchanged.
data/README.md CHANGED
@@ -21,7 +21,7 @@ Without coordination, they overwrite each other and nothing remembers why. textu
21
21
 
22
22
  ```
23
23
  identity/ human only — who you are, what you decide, how you sound
24
- working/ human + agent + runner — day-to-day catalog
24
+ working/ human only — day-to-day catalog (agents propose via review/, runners feed via intake/)
25
25
  intake/ runner only — declared external inputs
26
26
  review/ agent + human — proposals waiting on a human accept
27
27
  output/ builder only — computed, published artifacts
@@ -37,7 +37,7 @@ That's the load-bearing claim: **coordination is a protocol invariant, not a lib
37
37
  gem install textus
38
38
  textus init # creates .textus/ with zones + schemas
39
39
  # agent proposes a change to review/
40
- echo '{"_meta":{"name":"oncall","proposal":{"target_key":"working.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
40
+ printf '%s' '{"_meta":{"name":"oncall","proposal":{"target_key":"working.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
41
41
  | textus put review.notes.oncall --as=agent --stdin
42
42
  # you accept it — textus promotes to working/ and audits the move
43
43
  textus accept review.notes.oncall --as=human
@@ -99,15 +99,15 @@ You get `.textus/` with all five zone directories, baseline schemas, an empty au
99
99
  output/ # builder only — computed outputs
100
100
  ```
101
101
 
102
- Manifest `path:` fields are relative to `.textus/zones/`. So `working.network.org.jane` lives at `.textus/zones/working/network/org/jane.md`.
102
+ Manifest `path:` fields are relative to `.textus/zones/`. So `working.notes.org.jane` lives at `.textus/zones/working/notes/org/jane.md`.
103
103
 
104
104
  Read and write:
105
105
 
106
106
  ```sh
107
- textus get working.network.org.jane
107
+ textus get working.notes.org.jane
108
108
  textus list --zone=working
109
- echo '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
110
- | textus put working.network.org.bob --as=human --stdin
109
+ printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
110
+ | textus put working.notes.bob --as=human --stdin
111
111
  textus freshness --zone=output # per-entry fresh/stale/never_refreshed/no_policy
112
112
  textus rule list # show every rule block
113
113
  textus audit --limit=20 # query the audit log
@@ -124,10 +124,10 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
124
124
  - **Build and publish in one pass.** `Textus::Write::Publish` materializes generator-zone entries and copies nested leaves to their `publish_each` targets. The `textus build` CLI verb dispatches to it; the wire envelope is unchanged.
125
125
  - **Typed envelopes.** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
126
126
  - **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
127
- - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key normalize --dry-run|--write` rewrites existing stores with illegal segments deterministically.
127
+ - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus doctor` flags any illegal segments with a rename hint; `textus key mv old.key new.key` renames in place (uid survives).
128
128
  - **`textus boot`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table, and an `agent_quickstart` block (read/write verbs, writable zones, propose zone, latest audit seq).
129
129
  - **`textus pulse [--since=N]`.** Per-turn heartbeat for agents: changed entries since cursor N, stale keys, pending review proposals, and a doctor summary. Cursor is a monotonic seq stamped on every audit row; rotation keeps the last 5 files (configurable via `audit:` in the manifest) and raises `CursorExpired` when the requested cursor has fallen off disk.
130
- - **`textus doctor`.** Health check across 9 categories: missing schemas/templates, broken hooks, illegal nested keys, sentinel drift, audit log readability, unowned schema fields, schema violations, and missing manifest files. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
130
+ - **`textus doctor`.** Health check across 15 checks — among them: missing schemas/templates, broken hooks, illegal nested keys, sentinel drift, audit log readability, unowned schema fields, schema violations, and missing manifest files. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
131
131
  - **Actionable hints on every error.** `UnknownKey` carries ranked "did you mean" suggestions. `WriteForbidden` names the role that *would* be allowed. `BadFrontmatter` tells you exactly what to rename. Printed to stderr alongside the JSON envelope on stdout.
132
132
  - **Compute.** Derived entries declare `compute: { kind: projection, ... }` (declarative rows + template) or `compute: { kind: external, ... }` (build runner produces the file; textus tracks sources for staleness). Inside projection computes, `transform:` names the row-shaping hook.
133
133
 
@@ -205,7 +205,7 @@ See [`docs/agent-integration.md`](docs/agent-integration.md) for the agent boot
205
205
  bundle exec rspec
206
206
  ```
207
207
 
208
- ~880 examples; includes conformance fixtures A–I from SPEC §12.
208
+ ~920 examples; includes conformance fixtures A–I from SPEC §12.
209
209
 
210
210
  ## Code quality
211
211
 
data/SPEC.md CHANGED
@@ -106,14 +106,19 @@ version: textus/3
106
106
 
107
107
  zones:
108
108
  - name: identity
109
+ kind: origin
109
110
  write_policy: [human]
110
111
  - name: working
112
+ kind: origin
111
113
  write_policy: [human, agent, runner]
112
114
  - name: intake
115
+ kind: quarantine
113
116
  write_policy: [runner]
114
117
  - name: review
118
+ kind: queue
115
119
  write_policy: [agent, human]
116
120
  - name: output
121
+ kind: derived
117
122
  write_policy: [builder]
118
123
 
119
124
  entries:
@@ -201,16 +206,27 @@ A leaf at `working.skills.writing.voice-writer` (authored at `.textus/zones/work
201
206
 
202
207
  Each zone declares which **roles** may write to it via `write_policy:` in the manifest. An optional `read_policy:` (default `[all]`) gates reads. Writes are gated; reads are unrestricted by default.
203
208
 
204
- | Zone | `write_policy` | Use case |
205
- |---|---|---|
206
- | `identity` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
207
- | `working` | `[human, agent, runner]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
208
- | `intake` | `[runner]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or agents directly. |
209
- | `review` | `[agent, human]` | Agent-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
210
- | `output` | `[builder]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
209
+ | Zone | `kind` | `write_policy` | Use case |
210
+ |---|---|---|---|
211
+ | `identity` | `origin` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
212
+ | `working` | `origin` | `[human, agent, runner]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
213
+ | `intake` | `quarantine` | `[runner]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or agents directly. |
214
+ | `review` | `queue` | `[agent, human]` | Agent-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
215
+ | `output` | `derived` | `[builder]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
211
216
 
212
217
  A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role is not in the target zone's `write_policy` list, the write returns `write_forbidden`.
213
218
 
219
+ Every zone MUST declare a `kind:` describing its role in the data-flow graph.
220
+ The vocabulary is closed: `origin` (authored truth), `quarantine` (external
221
+ bytes pending validation), `queue` (proposals awaiting promotion), `derived`
222
+ (computed from other zones). A manifest MUST declare at most one `queue` zone,
223
+ and a zone's `kind:` MUST agree with its writers (`derived` ⇒ a `generator`
224
+ writer, `queue` ⇒ a `proposer`, `quarantine` ⇒ a `runner`; `origin` is
225
+ unconstrained). Coordination is keyed off the declared kind: a zone is derived
226
+ only if it declares `kind: derived`, and proposals route to the declared
227
+ `queue` zone — there is no name-based fallback. A manifest with a kind-less
228
+ zone is rejected at load.
229
+
214
230
  ### 5.1 Role resolution
215
231
 
216
232
  The effective role for any CLI invocation is resolved in this order; the first match wins:
@@ -606,7 +622,15 @@ rules:
606
622
  | `refresh` | `{ ttl, on_stale, sync_budget_ms }` | Freshness budget for intake entries. `on_stale` is `warn` (default), `sync`, or `timed_sync`. |
607
623
  | `intake_handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
608
624
  | `promotion` | `{ requires: [...] }` | Predicates a `review` entry must satisfy before `textus accept` will promote it. Built-in predicates: `schema_valid` (entry passes schema validation) and `human_accept` (the accepting role must be `human`). Additional predicates may be registered via `:validate` hooks. Enforced — `textus accept` refuses if any predicate fails. |
609
- | `retention` | (reserved) | Slot reserved for future retention policy (cap by age / count). Implementations parse it but otherwise ignore. |
625
+ | `retention` | `{ expire_after:, archive_after: }` | Pruning policy for matched leaves. Duration strings: `30s`, `90m`, `12h`, `30d`, or bare integer seconds. `textus retain --as=ROLE` sweeps matched leaves: `expire_after` is checked first, so a leaf older than `expire_after` is deleted (and audited); otherwise a leaf older than `archive_after` is copied to `<store>/archive/<relative-path>` and then deleted. Age is measured from the leaf file's modification time. The `--as` role must be allowed to write the matched zone. |
626
+
627
+ Both retention windows are optional, and `expire_after` is evaluated before
628
+ `archive_after` — so when both apply, a leaf past the (longer) `expire_after`
629
+ window is deleted rather than archived. The usual configuration is therefore
630
+ `archive_after < expire_after` (archive a leaf, then delete it once older).
631
+ `textus retain --as=ROLE` runs the sweep; `--prefix` and `--zone` narrow it, and
632
+ any leaf whose zone the `--as` role cannot write is reported as a failure rather
633
+ than aborting the run.
610
634
 
611
635
  **Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
612
636
 
data/lib/textus/boot.rb CHANGED
@@ -128,7 +128,7 @@ module Textus
128
128
  acc << zname if agent_role && writers.include?(agent_role)
129
129
  end
130
130
 
131
- propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
131
+ propose_zone = manifest.policy.propose_zone_for(agent_role)
132
132
 
133
133
  {
134
134
  "read_verbs" => %w[boot get list audit pulse freshness doctor],
@@ -169,6 +169,8 @@ module Textus
169
169
  def self.zones_for(manifest)
170
170
  manifest.data.zones.map do |name, writers|
171
171
  row = { "name" => name, "writers" => Array(writers) }
172
+ kind = manifest.policy.declared_kind(name)
173
+ row["kind"] = kind.to_s if kind
172
174
  purpose = ZONE_PURPOSES[name]
173
175
  row["purpose"] = purpose if purpose
174
176
  row
@@ -177,7 +179,7 @@ module Textus
177
179
 
178
180
  def self.entries_for(manifest)
179
181
  manifest.data.entries.map do |e|
180
- derived = manifest.policy.zone_kinds(e.zone).include?(:generator)
182
+ derived = manifest.policy.derived_zone?(e.zone)
181
183
  {
182
184
  "key" => e.key,
183
185
  "zone" => e.zone,
@@ -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
@@ -17,19 +17,10 @@ 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" => {
@@ -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
@@ -18,7 +18,7 @@ module Textus
18
18
  end
19
19
  row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
20
20
  row["promotion"] = { "requires" => b.promote.requires } if b.promote
21
- row["retention"] = b.retention if b.retention
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
@@ -94,9 +97,9 @@ module Textus
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
@@ -13,6 +13,7 @@ module Textus
13
13
  publish: Textus::Write::Publish,
14
14
  refresh: Textus::Write::RefreshWorker,
15
15
  refresh_all: Textus::Write::RefreshAll,
16
+ retention_sweep: Textus::Write::RetentionSweep,
16
17
 
17
18
  # Read
18
19
  get: Textus::Read::Get,
@@ -33,6 +34,7 @@ module Textus
33
34
  validate_all: Textus::Read::ValidateAll,
34
35
  doctor: Textus::Read::Doctor,
35
36
  boot: Textus::Read::Boot,
37
+ retainable: Textus::Read::Retainable,
36
38
 
37
39
  # Maintenance
38
40
  migrate: Textus::Maintenance::Migrate,
@@ -45,5 +47,11 @@ module Textus
45
47
  def self.fetch(verb)
46
48
  VERBS.fetch(verb.to_sym) { raise UsageError.new("unknown verb: #{verb.inspect}") }
47
49
  end
50
+
51
+ # Single home for the uniform use-case invocation protocol (ADR 0023):
52
+ # look up the verb, construct on (container:, call:), and invoke #call.
53
+ def self.invoke(verb, container:, call:, args: [], kwargs: {})
54
+ fetch(verb).new(container: container, call: call).call(*args, **kwargs)
55
+ end
48
56
  end
49
57
  end
@@ -28,11 +28,14 @@ module Textus
28
28
  def manifest = @container.manifest
29
29
  def rpc = @container.rpc
30
30
 
31
- # Dispatch a verb through the static Dispatcher table.
32
- def dispatch(verb, *, **)
33
- klass = Textus::Dispatcher.fetch(verb)
34
- call_value = Textus::Call.build(role: Textus::Role::DEFAULT)
35
- klass.new(container: @container, call: call_value).call(*, **)
31
+ # Dispatch a verb through the single use-case invocation seam (ADR 0026).
32
+ def dispatch(verb, *args, **kwargs)
33
+ Textus::Dispatcher.invoke(
34
+ verb,
35
+ container: @container,
36
+ call: Textus::Call.build(role: Textus::Role::DEFAULT),
37
+ args: args, kwargs: kwargs
38
+ )
36
39
  end
37
40
  end
38
41
  end
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ module Domain
3
+ # Parses a duration value into whole seconds. Accepts a bare integer (or
4
+ # integer-string) of seconds, or `<n><unit>` with unit s/m/h/d. Returns
5
+ # nil for nil or any unparseable value.
6
+ module Duration
7
+ UNIT_SECONDS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
8
+
9
+ def self.seconds(value)
10
+ return nil if value.nil?
11
+
12
+ str = value.to_s.strip
13
+ return str.to_i if str.match?(/\A\d+\z/)
14
+
15
+ m = str.match(/\A(\d+)\s*([smhd])\z/)
16
+ return nil unless m
17
+
18
+ m[1].to_i * UNIT_SECONDS.fetch(m[2])
19
+ end
20
+ end
21
+ end
22
+ end
@@ -21,21 +21,7 @@ module Textus
21
21
  end
22
22
 
23
23
  def ttl_seconds
24
- return nil if @ttl.nil?
25
-
26
- str = @ttl.to_s.strip
27
- return str.to_i if str.match?(/\A\d+\z/)
28
-
29
- m = str.match(/\A(\d+)\s*([smhd])\z/)
30
- return nil unless m
31
-
32
- n = m[1].to_i
33
- case m[2]
34
- when "s" then n
35
- when "m" then n * 60
36
- when "h" then n * 3600
37
- when "d" then n * 86_400
38
- end
24
+ Textus::Domain::Duration.seconds(@ttl)
39
25
  end
40
26
 
41
27
  def to_freshness_policy
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ # Lifetime policy for queue/quarantine leaves. Both windows are optional
5
+ # durations (see Domain::Duration). `expire_after` deletes; `archive_after`
6
+ # moves the leaf aside. When both are set, expire wins once its (longer)
7
+ # window is exceeded.
8
+ class Retention
9
+ attr_reader :expire_after, :archive_after
10
+
11
+ def initialize(expire_after: nil, archive_after: nil)
12
+ @expire_after = Textus::Domain::Duration.seconds(expire_after)
13
+ @archive_after = Textus::Domain::Duration.seconds(archive_after)
14
+ end
15
+
16
+ # :expire | :archive | nil for a leaf of the given age (seconds).
17
+ def action_for(age_seconds)
18
+ return :expire if @expire_after && age_seconds > @expire_after
19
+ return :archive if @archive_after && age_seconds > @archive_after
20
+
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Domain
3
+ # Reports leaves whose age (now - file mtime) exceeds a retention window.
4
+ # Each row is { "key", "path", "action" => "expire"|"archive", "age_seconds" }.
5
+ class Retention
6
+ def initialize(manifest:, file_stat:, clock:)
7
+ @manifest = manifest
8
+ @file_stat = file_stat
9
+ @clock = clock
10
+ end
11
+
12
+ def call(prefix: nil, zone: nil)
13
+ @manifest.data.entries
14
+ .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
15
+ .flat_map { |m| rows_for(m) }
16
+ end
17
+
18
+ private
19
+
20
+ def rows_for(mentry)
21
+ policy = @manifest.rules.for(mentry.key).retention
22
+ return [] if policy.nil?
23
+
24
+ @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
25
+ path = row[:path]
26
+ next unless @file_stat.exists?(path)
27
+
28
+ age = (@clock.now - @file_stat.mtime(path)).to_i
29
+ action = policy.action_for(age)
30
+ next if action.nil?
31
+
32
+ { "key" => row[:key], "path" => path, "action" => action.to_s, "age_seconds" => age }
33
+ end
34
+ end
35
+
36
+ def entry_matches?(mentry, prefix:, zone:)
37
+ return false if zone && mentry.zone != zone
38
+ return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
39
+
40
+ true
41
+ end
42
+ end
43
+ end
44
+ end
@@ -8,6 +8,10 @@ module Textus
8
8
  #
9
9
  # No audit, no events, no permission checks — those live one layer up.
10
10
  class Reader
11
+ def self.from(container:)
12
+ new(file_store: container.file_store, manifest: container.manifest)
13
+ end
14
+
11
15
  def initialize(file_store:, manifest:)
12
16
  @file_store = file_store
13
17
  @manifest = manifest
@@ -14,6 +14,14 @@ module Textus
14
14
  class Writer
15
15
  Payload = Data.define(:meta, :body, :content)
16
16
 
17
+ def self.from(container:, call:)
18
+ new(
19
+ file_store: container.file_store, manifest: container.manifest,
20
+ schemas: container.schemas, audit_log: container.audit_log,
21
+ call: call, reader: Reader.from(container: container)
22
+ )
23
+ end
24
+
17
25
  def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
18
26
  @file_store = file_store
19
27
  @manifest = manifest