textus 0.50.0 → 0.51.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/CHANGELOG.md +26 -0
- data/README.md +41 -43
- data/SPEC.md +174 -176
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +31 -26
- data/lib/textus/boot.rb +13 -17
- data/lib/textus/call.rb +1 -1
- data/lib/textus/cli/runner.rb +15 -10
- data/lib/textus/cli/verb/get.rb +1 -3
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/put.rb +4 -20
- data/lib/textus/cli.rb +1 -3
- data/lib/textus/dispatcher.rb +1 -3
- data/lib/textus/doctor/check/generator_drift.rb +4 -3
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +13 -11
- data/lib/textus/doctor.rb +0 -2
- data/lib/textus/domain/freshness/evaluator.rb +150 -14
- data/lib/textus/domain/freshness/verdict.rb +28 -6
- data/lib/textus/domain/freshness.rb +4 -33
- data/lib/textus/domain/policy/base_guards.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
- data/lib/textus/domain/policy/publish_target.rb +34 -0
- data/lib/textus/domain/policy/retention.rb +29 -0
- data/lib/textus/domain/policy/source.rb +79 -0
- data/lib/textus/domain/retention/sweep.rb +57 -0
- data/lib/textus/domain/retention.rb +11 -0
- data/lib/textus/errors.rb +4 -4
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/catalog.rb +8 -7
- data/lib/textus/hooks/context.rb +5 -10
- data/lib/textus/init/templates/machine_intake.rb +4 -4
- data/lib/textus/init.rb +47 -47
- data/lib/textus/key/matching.rb +24 -0
- data/lib/textus/maintenance/reconcile.rb +160 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +2 -2
- data/lib/textus/manifest/entry/base.rb +28 -9
- data/lib/textus/manifest/entry/nested.rb +3 -4
- data/lib/textus/manifest/entry/parser.rb +25 -21
- data/lib/textus/manifest/entry/produced.rb +56 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
- data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
- data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
- data/lib/textus/manifest/entry/validators/publish.rb +3 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy.rb +16 -4
- data/lib/textus/manifest/resolver.rb +10 -4
- data/lib/textus/manifest/rules.rb +37 -36
- data/lib/textus/manifest/schema/keys.rb +98 -0
- data/lib/textus/manifest/schema/validator.rb +324 -0
- data/lib/textus/manifest/schema/vocabulary.rb +24 -0
- data/lib/textus/manifest/schema.rb +27 -247
- data/lib/textus/manifest.rb +5 -3
- data/lib/textus/mcp/server.rb +1 -1
- data/lib/textus/ports/audit_log.rb +6 -0
- data/lib/textus/ports/build_lock.rb +6 -0
- data/lib/textus/ports/clock.rb +4 -3
- data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
- data/lib/textus/ports/publisher.rb +11 -7
- data/lib/textus/produce/acquire/handler.rb +29 -0
- data/lib/textus/produce/acquire/intake.rb +130 -0
- data/lib/textus/produce/acquire/projection.rb +127 -0
- data/lib/textus/produce/acquire/serializer/json.rb +31 -0
- data/lib/textus/produce/acquire/serializer/text.rb +16 -0
- data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
- data/lib/textus/produce/acquire/serializer.rb +17 -0
- data/lib/textus/produce/engine.rb +143 -0
- data/lib/textus/produce/events.rb +36 -0
- data/lib/textus/produce/render.rb +23 -0
- data/lib/textus/projection.rb +17 -6
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +61 -31
- data/lib/textus/read/get.rb +20 -102
- data/lib/textus/read/rdeps.rb +3 -3
- data/lib/textus/read/rule_explain.rb +41 -23
- data/lib/textus/read/rule_list.rb +25 -8
- data/lib/textus/read/validate_all.rb +14 -0
- data/lib/textus/role.rb +2 -1
- data/lib/textus/schemas.rb +8 -0
- data/lib/textus/store.rb +1 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/put.rb +1 -1
- metadata +23 -30
- data/lib/textus/builder/pipeline.rb +0 -88
- data/lib/textus/builder/renderer/json.rb +0 -45
- data/lib/textus/builder/renderer/markdown.rb +0 -24
- data/lib/textus/builder/renderer/text.rb +0 -14
- data/lib/textus/builder/renderer/yaml.rb +0 -45
- data/lib/textus/builder/renderer.rb +0 -17
- data/lib/textus/cli/verb/boot.rb +0 -14
- data/lib/textus/cli/verb/build.rb +0 -15
- data/lib/textus/doctor/check/fetch_locks.rb +0 -49
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
- data/lib/textus/domain/freshness/policy.rb +0 -18
- data/lib/textus/domain/lifecycle.rb +0 -83
- data/lib/textus/domain/outcome.rb +0 -10
- data/lib/textus/domain/policy/lifecycle.rb +0 -35
- data/lib/textus/domain/staleness/generator_check.rb +0 -109
- data/lib/textus/domain/staleness.rb +0 -29
- data/lib/textus/maintenance/tend.rb +0 -110
- data/lib/textus/manifest/entry/derived.rb +0 -67
- data/lib/textus/manifest/entry/intake.rb +0 -31
- data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
- data/lib/textus/mcp/tools.rb +0 -14
- data/lib/textus/ports/fetch/detached.rb +0 -52
- data/lib/textus/ports/fetch/lock.rb +0 -44
- data/lib/textus/write/build.rb +0 -90
- data/lib/textus/write/fetch_events.rb +0 -42
- data/lib/textus/write/fetch_orchestrator.rb +0 -101
- data/lib/textus/write/fetch_worker.rb +0 -127
- data/lib/textus/write/intake_fetch.rb +0 -25
- data/lib/textus/write/materializer.rb +0 -51
data/docs/architecture/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Textus architecture
|
|
2
2
|
|
|
3
3
|
> **Explanation** · for contributors · **read this first** for orientation before SPEC
|
|
4
|
-
> **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/
|
|
4
|
+
> **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/produce paths) · **reviewed** 2026-06 (v0.45)
|
|
5
5
|
|
|
6
6
|
```mermaid
|
|
7
7
|
flowchart TD
|
|
8
8
|
interface["Interface — CLI verbs · MCP gate (JSON-RPC)"]
|
|
9
|
-
application["Application — Call · Container · Dispatcher · RoleScope<br/>read/ · write/ · maintenance/ use cases · envelope IO"]
|
|
10
|
-
domain["Domain — Permission · Freshness
|
|
9
|
+
application["Application — Call · Container · Dispatcher · RoleScope<br/>read/ · write/ · maintenance/ · produce/ use cases · envelope IO"]
|
|
10
|
+
domain["Domain — Permission · Freshness<br/>Policy (Guard · GuardFactory · BaseGuards · Evaluation · Fetch · Matcher · Predicates)"]
|
|
11
11
|
infra["Infrastructure — Store · FileStore · Manifest · Schemas<br/>Ports · Hooks · Entry format strategies"]
|
|
12
12
|
interface --> application
|
|
13
13
|
application --> domain
|
|
@@ -38,12 +38,11 @@ MCP/Ruby, `view(:cli)` for the operator envelope); declarative `source:`/
|
|
|
38
38
|
wrap the single dispatch site (`RoleScope#dispatch_bound`) for stateful verbs;
|
|
39
39
|
and `cli_default:` declares a CLI default that diverges from the agent default.
|
|
40
40
|
`CLI::Runner` generates a command per `:cli` contract, dispatching `contract.verb`
|
|
41
|
-
by construction. Only verbs with genuine *behavior* — `put` (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
reconciliation specs make name/dispatch/facet drift unrepresentable.
|
|
41
|
+
by construction. Only verbs with genuine *behavior* — `put` (entry persistence),
|
|
42
|
+
`get` (UnknownKey + resolver suggestions, CLI-only), and `doctor` (not yet
|
|
43
|
+
generatable) — stay hand-authored, plus commands with no dispatcher verb (`init`,
|
|
44
|
+
`hook`, `mcp serve`, `schema diff/init`). `boot` is auto-generated from its
|
|
45
|
+
contract. Total reconciliation specs make name/dispatch/facet drift unrepresentable.
|
|
47
46
|
|
|
48
47
|
**Application**
|
|
49
48
|
|
|
@@ -55,13 +54,13 @@ Dispatcher (static VERBS table: verb → use-case)
|
|
|
55
54
|
RoleScope (Store#as(role) — forwards verb calls)
|
|
56
55
|
|
|
57
56
|
read/{get,list,where,uid,schema_envelope,
|
|
58
|
-
deps,rdeps,published,
|
|
57
|
+
deps,rdeps,published,validate_all,boot,doctor,
|
|
59
58
|
freshness,audit,blame,rule_explain,rule_list,pulse}.rb
|
|
60
|
-
write/{put,
|
|
61
|
-
|
|
62
|
-
fetch_worker,fetch_orchestrator,fetch_all}
|
|
63
|
-
maintenance/{migrate,key_mv_prefix,key_delete_prefix,
|
|
59
|
+
write/{put,key_delete,key_mv,accept,reject,propose}.rb
|
|
60
|
+
maintenance/{reconcile,key_mv_prefix,key_delete_prefix,
|
|
64
61
|
zone_mv,rule_lint}.rb
|
|
62
|
+
produce/{engine,events,render,
|
|
63
|
+
acquire/{intake,handler,projection,serializer}}.rb
|
|
65
64
|
envelope/io/{reader,writer}.rb (split: parse vs persist)
|
|
66
65
|
projection.rb
|
|
67
66
|
```
|
|
@@ -70,8 +69,7 @@ projection.rb
|
|
|
70
69
|
|
|
71
70
|
```
|
|
72
71
|
Permission (write predicate per zone)
|
|
73
|
-
Freshness::{
|
|
74
|
-
Staleness (Generator/Intake checks)
|
|
72
|
+
Freshness::{Verdict,Evaluator}
|
|
75
73
|
Action Outcome Sentinel
|
|
76
74
|
Policy::{Guard,GuardFactory,BaseGuards,Evaluation,Fetch,Matcher,HandlerAllowlist,
|
|
77
75
|
Predicates::{ZoneWritableBy,SchemaValid,AuthorHeld,TargetIsCanon,EtagMatch,FreshWithin}}
|
|
@@ -87,7 +85,7 @@ Storage::FileStore (bytes-only port: read/write/delete/
|
|
|
87
85
|
Manifest (Data, Resolver, Policy, Rules)
|
|
88
86
|
Schemas (eager-load cache)
|
|
89
87
|
Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
|
|
90
|
-
|
|
88
|
+
BuildLock,ProduceOnWriteSubscriber,SentinelStore}
|
|
91
89
|
Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
|
|
92
90
|
Signature,Builtin,ErrorLog}
|
|
93
91
|
Entry::{Markdown,Json,Yaml,Text} (format strategies)
|
|
@@ -125,7 +123,7 @@ reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
|
|
|
125
123
|
|
|
126
124
|
Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
|
|
127
125
|
|
|
128
|
-
- `
|
|
126
|
+
- `Produce::Engine` — runs `reconcile`'s produce pipeline; composes `Acquire::Intake` (external pull via handler) with `Produce::Render` (template-driven publish) per entry. `AsyncRunner` (nested in `Engine`) enqueues reactive re-produce on `entry_written` events and drains before the process exits.
|
|
129
127
|
- `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
|
|
130
128
|
|
|
131
129
|
## Container
|
|
@@ -139,7 +137,7 @@ Container = Data.define(
|
|
|
139
137
|
)
|
|
140
138
|
```
|
|
141
139
|
|
|
142
|
-
The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:
|
|
140
|
+
The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_handler`, `:transform_rows`, `:validate`) receive `caps: <Container>` — field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
|
|
143
141
|
|
|
144
142
|
## Ports
|
|
145
143
|
|
|
@@ -151,9 +149,9 @@ Ports are infrastructure adapters with an interface defined by the domain. Each
|
|
|
151
149
|
| `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
|
|
152
150
|
| `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
|
|
153
151
|
| `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel so the next publish can confirm the target is managed. |
|
|
154
|
-
| `Ports::
|
|
155
|
-
| `Ports::
|
|
156
|
-
| `Ports::
|
|
152
|
+
| `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline. Raises `BuildInProgress` if a build is already running. |
|
|
153
|
+
| `Ports::ProduceOnWriteSubscriber` | Pub-sub listener on `entry_written`; enqueues keys into `Produce::Engine::AsyncRunner` for reactive re-produce after any write. |
|
|
154
|
+
| `Ports::SentinelStore` | Reads and writes the per-target sentinel file that `Publisher` uses to detect unmanaged overwrites. |
|
|
157
155
|
|
|
158
156
|
Application use cases access ports only through `Container` fields — never through the raw `Store`.
|
|
159
157
|
|
|
@@ -188,43 +186,49 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
|
|
|
188
186
|
|
|
189
187
|
## Read path (`store.get(key)`)
|
|
190
188
|
|
|
191
|
-
`Read::Get` is the single public read verb
|
|
189
|
+
`Read::Get` is the single public read verb. It is a **pure read** (ADR 0089): it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict — it NEVER ingests and NEVER mutates. The read-through that once refreshed a stale entry in-process (ADR 0062) is removed; quarantine freshness is system-pushed via `reconcile` (scheduled sweep) and `hook run` (event push).
|
|
192
190
|
|
|
193
191
|
1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
|
|
194
|
-
2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The
|
|
195
|
-
3. `Read::Get#call(key
|
|
196
|
-
4. If `fetch: true` and the verdict is stale and the entry's fetch rule demands action, `Read::Get` hands off to `Write::FetchOrchestrator` (built lazily — a pure `fetch: false` call never touches the orchestrator). The orchestrator executes the fetch policy's `Action` (`sync`, `timed_sync`, `detached`, …) and returns an `Outcome`.
|
|
197
|
-
5. The outcome is mapped back to an envelope: `Fetched` → fresh envelope from the write; `Detached` → original envelope with `fetching: true`; `Failed` → original envelope with `fetch_error` set; `Skipped` → original envelope unchanged.
|
|
192
|
+
2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The verb takes only `key` — there is no `fetch` flag on any surface.
|
|
193
|
+
3. `Read::Get#call(key)` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no `upkeep` rule, the envelope is annotated fresh. A stale entry with `upkeep: { ttl:, action: refresh }` is returned **stale** — the read does not refresh it; the next `reconcile` does.
|
|
198
194
|
|
|
199
|
-
|
|
195
|
+
Because the read is always pure, every caller — interactive reads, dashboards, and the direct in-process callers (accept/reject/publish, materializer, uid, validate_all/validator, schema/tools, hooks/context) — gets the same orchestrator-free, side-effect-free read. The prior read-through path (`get_or_fetch`, then the `fetch:`-flagged `Read::Get`, ADR 0062) and its `Write::FetchOrchestrator` are gone (ADR 0089).
|
|
200
196
|
|
|
201
197
|
## Write path (`store.put(key, ...)`)
|
|
202
198
|
|
|
203
199
|
1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
|
|
204
200
|
2. `Write::Put#call` validates the key, resolves the manifest entry, builds `GuardFactory.for(:put, key)` and calls `Guard#check!(eval)` (topology is predicate #0, `zone_writable_by`) — raises `WriteForbidden` if the topology gate denies, `GuardFailed` if any other predicate fails.
|
|
205
201
|
3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
|
|
206
|
-
4. Publishes `:
|
|
202
|
+
4. Publishes `:entry_written` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
|
|
207
203
|
|
|
208
|
-
`Write::{
|
|
204
|
+
`Write::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
|
|
209
205
|
|
|
210
|
-
`Write::
|
|
206
|
+
`Write::KeyMv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
|
|
211
207
|
|
|
212
|
-
##
|
|
208
|
+
## Produce path (`reconcile` + reactive `entry_written`)
|
|
213
209
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
210
|
+
The produce pipeline handles two concerns — **acquire** (pull live data via an intake handler) and **render** (template-driven artifact publish) — unified under `Produce::Engine`.
|
|
211
|
+
|
|
212
|
+
`Produce::Engine.converge(container:, call:, keys:)` is the entry point for both `reconcile` (scheduled batch) and the reactive `AsyncRunner` (triggered on `entry_written` by `Ports::ProduceOnWriteSubscriber`).
|
|
213
|
+
|
|
214
|
+
For each key, `Engine#produce_one`:
|
|
215
|
+
|
|
216
|
+
1. **Acquire phase** — `Produce::Acquire::Intake#run(key)`:
|
|
217
|
+
- Resolves the manifest entry; looks up the intake handler via `container.rpc.callable(:resolve_handler, mentry.handler)`.
|
|
218
|
+
- Publishes `:entry_fetch_started` via `Produce::Events`.
|
|
219
|
+
- Invokes the handler under a timeout deadline.
|
|
220
|
+
- On error: publishes `:entry_fetch_failed`, re-raises.
|
|
221
|
+
- On success: normalises the handler result via its own `normalize_action_result` (keyed on the entry's format), checks guard, persists via `Envelope::IO::Writer`, publishes `:entry_fetched` unless the etag is unchanged.
|
|
222
|
+
- `Acquire::Handler` resolves and invokes the RPC callable under the timeout deadline. (The sibling **projection** sub-path — `from: project` entries — instead runs `Acquire::Projection`, which renders data files through `Acquire::Serializer::{Json,Yaml,Text}` before persisting.)
|
|
223
|
+
2. **Render phase** — `entry.publish_via(context)` calls `Produce::Render#bytes_for(target:, data:, boot:)` to expand the Mustache template and copy the result to the publish target via `Ports::Publisher`. Returns `nil` if no publish is configured (skipped).
|
|
224
|
+
|
|
225
|
+
`AsyncRunner` (nested in `Engine`) enqueues reactive produce when `entry_written` fires, then drains before the process exits via an `at_exit` hook. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
|
|
222
226
|
|
|
223
227
|
## Hook payload contract
|
|
224
228
|
|
|
225
|
-
Pub-sub hooks (`:
|
|
229
|
+
Pub-sub hooks (`:entry_written`, `:entry_fetched`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
|
|
226
230
|
|
|
227
|
-
RPC hooks (`:
|
|
231
|
+
RPC hooks (`:resolve_handler`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
|
|
228
232
|
|
|
229
233
|
## Agent surface (boot + pulse + MCP)
|
|
230
234
|
|
|
@@ -49,78 +49,83 @@ Tooling around `git blame` or audit logs may filter on owner; the gem itself onl
|
|
|
49
49
|
|
|
50
50
|
## Derived entries
|
|
51
51
|
|
|
52
|
-
A derived entry declares a `
|
|
52
|
+
A derived entry declares a `source:` block with a `from:` discriminator (ADR 0093/0094). A `source:` acquires **data** — it never renders; rendering is a publish concern (below). Two `from:` values for derived entries:
|
|
53
53
|
|
|
54
|
-
**`
|
|
54
|
+
**`source: { from: project }`** — textus computes the entry's data on `textus reconcile` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
|
|
55
55
|
|
|
56
56
|
```yaml
|
|
57
|
-
- key: artifacts.
|
|
58
|
-
path: artifacts/
|
|
57
|
+
- key: artifacts.derived.people
|
|
58
|
+
path: artifacts/derived/people.md
|
|
59
59
|
zone: artifacts
|
|
60
60
|
schema: null
|
|
61
61
|
owner: automation:catalog-people
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
source:
|
|
63
|
+
from: project
|
|
64
64
|
select: knowledge.network.org # prefix or list of prefixes
|
|
65
65
|
pluck: [name, relationship, org]
|
|
66
66
|
sort_by: name
|
|
67
|
-
|
|
67
|
+
format: json # the stored form is data
|
|
68
68
|
publish:
|
|
69
|
-
to:
|
|
69
|
+
- { to: docs/people.md, template: people.mustache } # render the data through a template
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
**`
|
|
72
|
+
**`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `reconcile` (default: `automation`).
|
|
73
73
|
|
|
74
74
|
```yaml
|
|
75
|
-
- key: artifacts.
|
|
76
|
-
path: artifacts/
|
|
75
|
+
- key: artifacts.derived.skills
|
|
76
|
+
path: artifacts/derived/skills.md
|
|
77
77
|
zone: artifacts
|
|
78
78
|
owner: automation:catalog-skills
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
source:
|
|
80
|
+
from: command
|
|
81
81
|
command: "rake catalog:skills" # informational; the automation invokes it
|
|
82
82
|
sources: [knowledge.projects, knowledge.network]
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `
|
|
85
|
+
The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `source.sources` — same list, recorded twice so a diff proves what was consumed.
|
|
86
86
|
|
|
87
|
-
Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md).
|
|
87
|
+
Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Data transforms (`source.transform:`) and publishing (a `publish:` list of `{ to, template? }` / `{ tree }` targets — render or copy the data) are covered in §5.2–§5.3.
|
|
88
88
|
|
|
89
89
|
## Intake and freshness
|
|
90
90
|
|
|
91
|
-
External inputs land via `:
|
|
91
|
+
External inputs land via `:resolve_handler` hooks, not shell commands. Each intake entry declares `source: { from: handler, handler: <name>, ttl: <dur> }`; re-pull is system-pushed via `reconcile` (scheduled sweep) and `hook run` (event push) — a `get` never refreshes (ADR 0089):
|
|
92
92
|
|
|
93
93
|
```sh
|
|
94
|
-
textus get feeds.notion.roadmap --as=automation # refreshes if stale
|
|
95
94
|
textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
|
|
95
|
+
textus reconcile --as=automation # re-pulls every intake entry past its source.ttl
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
The re-pull cadence is the entry's own `source.ttl`. Age-based garbage collection is the orthogonal `retention:` rule slot in the top-level `rules:` block, matched by glob (`{ ttl, action: drop | archive }`); the two compose — re-pull hourly *and* archive at 90 days:
|
|
99
99
|
|
|
100
100
|
```yaml
|
|
101
|
+
- key: artifacts.feeds.notion.events
|
|
102
|
+
kind: produced # produce-method (intake) read from source.from: handler
|
|
103
|
+
zone: artifacts
|
|
104
|
+
source: { from: handler, handler: notion, config: { ... }, ttl: 6h }
|
|
105
|
+
|
|
101
106
|
rules:
|
|
102
|
-
- match: feeds.notion.**
|
|
103
|
-
|
|
107
|
+
- match: artifacts.feeds.notion.**
|
|
108
|
+
retention: { ttl: 90d, action: archive }
|
|
104
109
|
```
|
|
105
110
|
|
|
106
|
-
A typical scheduled integration
|
|
107
|
-
|
|
111
|
+
A typical scheduled integration runs `reconcile` on a cron to re-pull every
|
|
112
|
+
expired feed:
|
|
108
113
|
|
|
109
114
|
```sh
|
|
110
|
-
textus
|
|
115
|
+
textus reconcile --as=automation # in cron / CI — re-pulls all stale feeds
|
|
111
116
|
```
|
|
112
117
|
|
|
113
118
|
See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
|
|
114
119
|
|
|
115
120
|
### Read vs. refresh
|
|
116
121
|
|
|
117
|
-
There is one public read operation (ADR
|
|
122
|
+
There is one public read operation, and it is pure (ADR 0089):
|
|
118
123
|
|
|
119
124
|
| Operation | Behaviour | Use for |
|
|
120
125
|
|-----------|-----------|---------|
|
|
121
|
-
| `ops.get` |
|
|
126
|
+
| `ops.get` | A pure on-disk read annotated with a freshness verdict — it NEVER ingests, regardless of the entry's `action`. A stale `refresh` entry reads back stale until the next `reconcile`. | every caller — interactive reads, dashboards, scripts, and internal pipelines (materializer, projection, schema tooling, accept/reject/publish, uid, validator) |
|
|
122
127
|
|
|
123
|
-
|
|
128
|
+
Refreshing a stale entry is `reconcile`'s job (or a `hook run` event), never a read's — so no caller can accidentally trigger network I/O by reading.
|
|
124
129
|
|
|
125
130
|
## Body content
|
|
126
131
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -26,14 +26,11 @@ module Textus
|
|
|
26
26
|
"propose changes by writing #{manifest.policy.queue_zone}.* entries with --as=#{name} " \
|
|
27
27
|
"and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
|
|
28
28
|
end,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
derived = zone_label(manifest, :derived, "derived")
|
|
35
|
-
"'textus build' computes #{derived} entries from projections; " \
|
|
36
|
-
"#{derived} files are never hand-edited"
|
|
29
|
+
reconcile: lambda do |_name, manifest|
|
|
30
|
+
machine = zone_label(manifest, :machine, "machine")
|
|
31
|
+
"'textus reconcile' materializes derived #{machine} entries from their sources and " \
|
|
32
|
+
"refreshes stale intake #{machine} entries from their declared source; " \
|
|
33
|
+
"derived files are never hand-edited (reactive on canon writes, or a full pass on demand)"
|
|
37
34
|
end,
|
|
38
35
|
}.freeze
|
|
39
36
|
|
|
@@ -88,8 +85,7 @@ module Textus
|
|
|
88
85
|
{ "name" => "propose" },
|
|
89
86
|
{ "name" => "accept" },
|
|
90
87
|
{ "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
|
|
91
|
-
{ "name" => "
|
|
92
|
-
{ "name" => "tend" },
|
|
88
|
+
{ "name" => "reconcile" },
|
|
93
89
|
{ "name" => "audit" },
|
|
94
90
|
{ "name" => "blame" },
|
|
95
91
|
{ "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
|
|
@@ -146,11 +142,11 @@ module Textus
|
|
|
146
142
|
|
|
147
143
|
# Recipes reference verbs, not a transport's CLI strings (ADR 0056): every
|
|
148
144
|
# step names a verb the agent can call (each transport frames it — CLI as
|
|
149
|
-
# `textus get KEY`, MCP as the `get` tool) or is a plain
|
|
145
|
+
# `textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This
|
|
150
146
|
# keeps shell lines out of the surface an MCP agent reads.
|
|
151
147
|
def self.recipes(manifest)
|
|
152
148
|
queue = manifest.policy.queue_zone
|
|
153
|
-
feeds = zone_label(manifest, :
|
|
149
|
+
feeds = zone_label(manifest, :machine, "the machine zone")
|
|
154
150
|
{
|
|
155
151
|
"read" => {
|
|
156
152
|
"purpose" => "find and read an entry",
|
|
@@ -176,11 +172,11 @@ module Textus
|
|
|
176
172
|
"accept #{queue}.KEY — promotes the proposal into its target zone",
|
|
177
173
|
],
|
|
178
174
|
},
|
|
179
|
-
"
|
|
180
|
-
"purpose" => "
|
|
175
|
+
"reconcile" => {
|
|
176
|
+
"purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
|
|
181
177
|
"steps" => [
|
|
182
178
|
"pulse — its `stale` list names entries past their ttl",
|
|
183
|
-
"
|
|
179
|
+
"reconcile (zone: #{feeds}) — re-pull the stale entries",
|
|
184
180
|
],
|
|
185
181
|
},
|
|
186
182
|
}
|
|
@@ -240,7 +236,7 @@ module Textus
|
|
|
240
236
|
|
|
241
237
|
def self.entries_for(manifest)
|
|
242
238
|
manifest.data.entries.map do |e|
|
|
243
|
-
derived =
|
|
239
|
+
derived = e.derived?
|
|
244
240
|
{
|
|
245
241
|
"key" => e.key,
|
|
246
242
|
"zone" => e.zone,
|
|
@@ -249,7 +245,7 @@ module Textus
|
|
|
249
245
|
"owner" => e.owner,
|
|
250
246
|
"format" => e.format,
|
|
251
247
|
"derived" => derived,
|
|
252
|
-
"intake" => e.
|
|
248
|
+
"intake" => e.intake?,
|
|
253
249
|
"publish_to" => Array(e.publish_to),
|
|
254
250
|
}
|
|
255
251
|
end
|
data/lib/textus/call.rb
CHANGED
data/lib/textus/cli/runner.rb
CHANGED
|
@@ -130,18 +130,23 @@ module Textus
|
|
|
130
130
|
# — behavior the generic projection cannot express (ADR 0068/0069):
|
|
131
131
|
# get — raises UnknownKey with resolver suggestions (a CLI-only
|
|
132
132
|
# affordance; the agent surface deliberately returns nil)
|
|
133
|
-
# put —
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
BEHAVIORAL_HATCHES = %i[get put build].freeze
|
|
133
|
+
# put — reads the entry JSON from --stdin (ADR 0089: just stores bytes,
|
|
134
|
+
# no --fetch transform)
|
|
135
|
+
# (build removed in ADR 0087: materialization is system-pushed via reconcile)
|
|
136
|
+
BEHAVIORAL_HATCHES = %i[get put].freeze
|
|
138
137
|
|
|
139
138
|
# Contract verbs whose CLI is a plain `< Verb` command, not a projection at
|
|
140
|
-
# all — composite reports assembled outside the contract
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
|
|
139
|
+
# all — composite reports assembled outside the contract.
|
|
140
|
+
# (boot removed: its contract carries surfaces :cli + the :lean arg, so the
|
|
141
|
+
# generic projection now generates it; the hand-authored CLI::Verb::Boot is
|
|
142
|
+
# deleted in ADR 0101.)
|
|
143
|
+
# (doctor retained: hand-authored to preserve --check=NAME flag spelling and
|
|
144
|
+
# the exit_code: res["ok"] ? 0 : 1 behavior — two things the generic
|
|
145
|
+
# projection cannot yet express; kept in ADR 0101 pending a future pass.)
|
|
146
|
+
# (fetch/fetch_all were removed in ADR 0079: Produce::Acquire::Intake is now internal,
|
|
147
|
+
# driven by the reconcile sweep and hook run — ADR 0089 removed the
|
|
148
|
+
# read-through that once also drove it.)
|
|
149
|
+
NON_PROJECTED_CLI = %i[doctor].freeze
|
|
145
150
|
|
|
146
151
|
# The installer skips generation for either category.
|
|
147
152
|
HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -4,12 +4,10 @@ module Textus
|
|
|
4
4
|
class Get < Runner::Base
|
|
5
5
|
self.spec = Textus::Read::Get.contract
|
|
6
6
|
option :as_flag, "--as=ROLE"
|
|
7
|
-
option :no_fetch, "--no-fetch"
|
|
8
7
|
|
|
9
8
|
def invoke(store)
|
|
10
9
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
11
|
-
|
|
12
|
-
result = session_for(store).get(key, **kw)
|
|
10
|
+
result = session_for(store).get(key)
|
|
13
11
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
|
|
14
12
|
|
|
15
13
|
emit(result.to_h_for_wire)
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
32
32
|
|
|
33
33
|
begin
|
|
34
|
-
Textus::
|
|
34
|
+
Textus::Produce::Acquire::Handler.invoke(
|
|
35
35
|
caps: store.container, handler: name, config: {}, args: args, label: "hook run",
|
|
36
36
|
)
|
|
37
37
|
rescue Textus::Error
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -6,7 +6,6 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
option :as_flag, "--as=ROLE"
|
|
8
8
|
option :use_stdin, "--stdin"
|
|
9
|
-
option :fetch_name, "--fetch=NAME"
|
|
10
9
|
|
|
11
10
|
def invoke(store)
|
|
12
11
|
key = positional.shift or raise UsageError.new("put requires a key")
|
|
@@ -14,25 +13,10 @@ module Textus
|
|
|
14
13
|
|
|
15
14
|
role = resolved_role(store)
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
caps: store.container, handler: fetch_name,
|
|
22
|
-
config: { "bytes" => raw }, args: {}, label: "fetch"
|
|
23
|
-
)
|
|
24
|
-
basename = key.split(".").last
|
|
25
|
-
{
|
|
26
|
-
"_meta" => {
|
|
27
|
-
"name" => basename,
|
|
28
|
-
"last_fetched_at" => Time.now.utc.iso8601,
|
|
29
|
-
"fetched_with" => fetch_name,
|
|
30
|
-
}.merge(result[:_meta] || result["_meta"] || {}),
|
|
31
|
-
"body" => result[:body] || result["body"] || "",
|
|
32
|
-
}
|
|
33
|
-
else
|
|
34
|
-
JSON.parse(raw)
|
|
35
|
-
end
|
|
16
|
+
# put only stores the stdin JSON (ADR 0089): no transform-on-write.
|
|
17
|
+
# Ingest (running a handler over bytes) is system-pushed via reconcile
|
|
18
|
+
# and hook run, never a put flag.
|
|
19
|
+
payload = JSON.parse(@stdin.read)
|
|
36
20
|
|
|
37
21
|
meta = payload["_meta"] || {}
|
|
38
22
|
body = payload["body"] || ""
|
data/lib/textus/cli.rb
CHANGED
|
@@ -122,9 +122,7 @@ module Textus
|
|
|
122
122
|
textus list [--prefix=KEY] [--zone=Z]
|
|
123
123
|
textus where KEY
|
|
124
124
|
textus get KEY
|
|
125
|
-
textus put KEY --stdin
|
|
126
|
-
textus fetch KEY
|
|
127
|
-
textus fetch all [--prefix=KEY] [--zone=Z]
|
|
125
|
+
textus put KEY --stdin --as=ROLE
|
|
128
126
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
129
127
|
textus blame KEY [--limit=N]
|
|
130
128
|
textus doctor
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -11,8 +11,6 @@ module Textus
|
|
|
11
11
|
key_mv: Textus::Write::KeyMv,
|
|
12
12
|
accept: Textus::Write::Accept,
|
|
13
13
|
reject: Textus::Write::Reject,
|
|
14
|
-
build: Textus::Write::Build,
|
|
15
|
-
|
|
16
14
|
# Read
|
|
17
15
|
get: Textus::Read::Get,
|
|
18
16
|
list: Textus::Read::List,
|
|
@@ -37,7 +35,7 @@ module Textus
|
|
|
37
35
|
zone_mv: Textus::Maintenance::ZoneMv,
|
|
38
36
|
key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
|
|
39
37
|
key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
|
|
40
|
-
|
|
38
|
+
reconcile: Textus::Maintenance::Reconcile,
|
|
41
39
|
rule_lint: Textus::Maintenance::RuleLint,
|
|
42
40
|
}.freeze
|
|
43
41
|
|
|
@@ -8,17 +8,18 @@ module Textus
|
|
|
8
8
|
# verb reported.
|
|
9
9
|
class GeneratorDrift < Check
|
|
10
10
|
def call
|
|
11
|
-
gen = Textus::Domain::
|
|
11
|
+
gen = Textus::Domain::Freshness::Evaluator.new(
|
|
12
12
|
manifest: manifest,
|
|
13
13
|
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
14
|
+
clock: Textus::Ports::Clock.new,
|
|
14
15
|
)
|
|
15
|
-
manifest.data.entries.flat_map { |m| gen.
|
|
16
|
+
manifest.data.entries.flat_map { |m| gen.drift_rows(m) }.map do |row|
|
|
16
17
|
{
|
|
17
18
|
"code" => "generator_drift",
|
|
18
19
|
"level" => "warning",
|
|
19
20
|
"subject" => row["key"],
|
|
20
21
|
"message" => row["reason"],
|
|
21
|
-
"fix" => "
|
|
22
|
+
"fix" => "rematerialize the entry: `textus reconcile`",
|
|
22
23
|
}
|
|
23
24
|
end
|
|
24
25
|
end
|
|
@@ -6,15 +6,15 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call
|
|
8
8
|
declared = collect_declared_handlers
|
|
9
|
-
registered = rpc.names(:
|
|
9
|
+
registered = rpc.names(:resolve_handler).to_set
|
|
10
10
|
|
|
11
11
|
out = (declared - registered).map do |name|
|
|
12
12
|
{
|
|
13
13
|
"code" => "intake.handler_missing",
|
|
14
14
|
"level" => "error",
|
|
15
15
|
"subject" => name.to_s,
|
|
16
|
-
"message" => "manifest references intake handler '#{name}' but no
|
|
17
|
-
"fix" => "create .textus/hooks/#{name}.rb with `Textus.hook { |reg| reg.on(:
|
|
16
|
+
"message" => "manifest references intake handler '#{name}' but no resolve_handler hook for '#{name}' is registered",
|
|
17
|
+
"fix" => "create .textus/hooks/#{name}.rb with `Textus.hook { |reg| reg.on(:resolve_handler, :#{name}) { ... } }`",
|
|
18
18
|
}
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -23,7 +23,7 @@ module Textus
|
|
|
23
23
|
"code" => "intake.handler_orphan",
|
|
24
24
|
"level" => "warning",
|
|
25
25
|
"subject" => name.to_s,
|
|
26
|
-
"message" => "
|
|
26
|
+
"message" => "resolve_handler hook '#{name}' is registered but no manifest entry references it",
|
|
27
27
|
"fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
|
|
28
28
|
}
|
|
29
29
|
end
|
|
@@ -36,7 +36,7 @@ module Textus
|
|
|
36
36
|
def collect_declared_handlers
|
|
37
37
|
set = Set.new
|
|
38
38
|
manifest.data.entries.each do |mentry|
|
|
39
|
-
set << mentry.handler.to_sym if mentry.
|
|
39
|
+
set << mentry.handler.to_sym if mentry.intake?
|
|
40
40
|
end
|
|
41
41
|
set
|
|
42
42
|
end
|
|
@@ -2,11 +2,11 @@ module Textus
|
|
|
2
2
|
module Doctor
|
|
3
3
|
class Check
|
|
4
4
|
# Flags entries whose key is matched by two or more rule blocks of the
|
|
5
|
-
# SAME specificity in the same slot (
|
|
6
|
-
# guard). Ties are non-deterministic in the parser's pick step, so
|
|
5
|
+
# SAME specificity in the same slot (lifecycle / handler_allowlist /
|
|
6
|
+
# guard / materialize). Ties are non-deterministic in the parser's pick step, so
|
|
7
7
|
# they're a configuration smell — surface them.
|
|
8
8
|
class RuleAmbiguity < Check
|
|
9
|
-
SLOTS =
|
|
9
|
+
SLOTS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_ambiguity] }.keys.freeze
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
12
|
out = []
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
"level" => "warning",
|
|
32
32
|
"subject" => sentinel_path,
|
|
33
33
|
"message" => "sentinel is not valid JSON",
|
|
34
|
-
"fix" => "delete #{sentinel_path} and re-run 'textus
|
|
34
|
+
"fix" => "delete #{sentinel_path} and re-run 'textus reconcile' to regenerate",
|
|
35
35
|
}
|
|
36
36
|
end
|
|
37
37
|
|
|
@@ -51,7 +51,7 @@ module Textus
|
|
|
51
51
|
"level" => "warning",
|
|
52
52
|
"subject" => sentinel.target,
|
|
53
53
|
"message" => "published file at #{sentinel.target} was modified out-of-band",
|
|
54
|
-
"fix" => "re-run 'textus
|
|
54
|
+
"fix" => "re-run 'textus reconcile' to overwrite, or copy the manual edit back into the store source",
|
|
55
55
|
}
|
|
56
56
|
end
|
|
57
57
|
end
|