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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +8 -2
- data/CHANGELOG.md +56 -0
- data/README.md +9 -9
- data/SPEC.md +32 -8
- data/lib/textus/boot.rb +4 -2
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/put.rb +4 -13
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +1 -1
- data/lib/textus/cli.rb +19 -16
- data/lib/textus/dispatcher.rb +8 -0
- data/lib/textus/doctor/check.rb +8 -5
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/policy/refresh.rb +1 -15
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/envelope/io/reader.rb +4 -0
- data/lib/textus/envelope/io/writer.rb +8 -0
- data/lib/textus/hooks/event_bus.rb +8 -20
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +7 -6
- data/lib/textus/manifest/data.rb +5 -1
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/policy.rb +34 -7
- data/lib/textus/manifest/rules.rb +10 -1
- data/lib/textus/manifest/schema.rb +54 -4
- data/lib/textus/manifest.rb +3 -2
- data/lib/textus/mcp/server.rb +1 -9
- data/lib/textus/mcp/session.rb +7 -23
- data/lib/textus/read/policy_explain.rb +5 -0
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/role_scope.rb +3 -2
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/delete.rb +1 -15
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/mv.rb +2 -12
- data/lib/textus/write/put.rb +1 -15
- data/lib/textus/write/refresh_worker.rb +2 -16
- data/lib/textus/write/retention_sweep.rb +55 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d84508fac499044df50d5fa793000fbd2249732c6b867f1b0f88535bfab4f083
|
|
4
|
+
data.tar.gz: 5069218d7c3c1a8360f4507bb99f94951e92a7d345ba4fb44ee72819fb4739e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
107
|
+
textus get working.notes.org.jane
|
|
108
108
|
textus list --zone=working
|
|
109
|
-
|
|
110
|
-
| textus put working.
|
|
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
|
|
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
|
|
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
|
-
~
|
|
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` |
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
33
|
-
store.rpc
|
|
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
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -17,19 +17,10 @@ module Textus
|
|
|
17
17
|
raw = @stdin.read
|
|
18
18
|
payload =
|
|
19
19
|
if fetch_name
|
|
20
|
-
result =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
98
|
-
textus rule {list
|
|
99
|
-
textus schema {
|
|
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
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -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
|
data/lib/textus/doctor/check.rb
CHANGED
|
@@ -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
|
|
32
|
-
def dispatch(verb,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|