textus 0.49.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 +45 -0
- data/README.md +41 -43
- data/SPEC.md +176 -197
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +33 -28
- data/lib/textus/boot.rb +58 -47
- 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 -4
- 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 -8
- 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 +9 -2
- 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/boot.rb +4 -2
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +63 -29
- 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 -13
- 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 -65
- 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
|
|
95
|
-
textus
|
|
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
|
|
|
@@ -146,4 +151,4 @@ The user-facing CLI surface, the wire envelope shape, and the protocol version (
|
|
|
146
151
|
|
|
147
152
|
- **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
|
|
148
153
|
- **Vector stores**: index `body` content into a vector store if you want fuzzy retrieval. `frontmatter` stays in textus as the source of truth for deterministic facts.
|
|
149
|
-
- **CI**: run `textus
|
|
154
|
+
- **CI**: run `textus doctor` (the `generator_drift` check) or `textus pulse` (the `stale` list) in CI to catch drift between derived entries and their sources.
|
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
|
|
|
@@ -71,49 +68,49 @@ module Textus
|
|
|
71
68
|
},
|
|
72
69
|
}.freeze
|
|
73
70
|
|
|
74
|
-
# Curated agent-facing verb catalog.
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
71
|
+
# Curated agent-facing verb catalog. This declares which verbs the operator
|
|
72
|
+
# CLI surfaces and in what order — the editorial presentation. The summary of
|
|
73
|
+
# each verb is a fact, not presentation: it is derived from `contract.summary`
|
|
74
|
+
# at load time (ADR 0039). A literal "summary" survives here only for grouped
|
|
75
|
+
# CLI tokens (schema/key/rule/hook) that aggregate several sub-contracts and so
|
|
76
|
+
# have no single contract to derive from. CLI_VERBS itself is assigned in
|
|
77
|
+
# textus.rb after Zeitwerk eager_load so all contract files are present.
|
|
79
78
|
CURATED_CLI_VERBS = [
|
|
80
79
|
{ "name" => "boot" },
|
|
81
80
|
{ "name" => "list" },
|
|
82
81
|
{ "name" => "get" },
|
|
83
|
-
{ "name" => "where"
|
|
82
|
+
{ "name" => "where" },
|
|
84
83
|
{ "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
|
|
85
84
|
{ "name" => "put" },
|
|
86
85
|
{ "name" => "propose" },
|
|
87
|
-
{ "name" => "accept"
|
|
88
|
-
{ "name" => "key",
|
|
89
|
-
{ "name" => "
|
|
90
|
-
{ "name" => "
|
|
91
|
-
{ "name" => "
|
|
92
|
-
{ "name" => "
|
|
93
|
-
{ "name" => "
|
|
94
|
-
{ "name" => "
|
|
95
|
-
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
96
|
-
{ "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
86
|
+
{ "name" => "accept" },
|
|
87
|
+
{ "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
|
|
88
|
+
{ "name" => "reconcile" },
|
|
89
|
+
{ "name" => "audit" },
|
|
90
|
+
{ "name" => "blame" },
|
|
91
|
+
{ "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
|
|
92
|
+
{ "name" => "doctor" },
|
|
93
|
+
{ "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
97
94
|
{ "name" => "pulse" },
|
|
98
95
|
{ "name" => "capabilities" },
|
|
99
96
|
].freeze
|
|
100
97
|
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
|
|
98
|
+
# verb token => contract.summary, for every Dispatcher verb that carries a
|
|
99
|
+
# contract. The single source for a verb's one-line summary (ADR 0039).
|
|
100
|
+
def self.contract_summaries
|
|
101
|
+
Dispatcher::VERBS.values
|
|
102
|
+
.select { |k| k.respond_to?(:contract?) && k.contract? }
|
|
103
|
+
.to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
|
|
104
|
+
end
|
|
109
105
|
|
|
106
|
+
# Build the CLI verb catalog: each summary is derived from its contract when
|
|
107
|
+
# one exists, falling back to the curated editorial string for grouped tokens
|
|
108
|
+
# (schema/key/rule/hook). Called once from textus.rb after eager_load.
|
|
109
|
+
def self.build_cli_verbs
|
|
110
|
+
summaries = contract_summaries
|
|
110
111
|
CURATED_CLI_VERBS.map do |entry|
|
|
111
|
-
derived =
|
|
112
|
-
|
|
113
|
-
entry.merge("summary" => derived)
|
|
114
|
-
else
|
|
115
|
-
entry
|
|
116
|
-
end
|
|
112
|
+
derived = summaries[entry["name"]]
|
|
113
|
+
derived ? entry.merge("summary" => derived) : entry
|
|
117
114
|
end
|
|
118
115
|
end
|
|
119
116
|
|
|
@@ -130,7 +127,8 @@ module Textus
|
|
|
130
127
|
# Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
|
|
131
128
|
# agent's real read and write surface, named as verbs the agent calls —
|
|
132
129
|
# not CLI strings. read_verbs can neither advertise a verb the agent
|
|
133
|
-
# cannot call (audit/
|
|
130
|
+
# cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
|
|
131
|
+
# internal scan, ADR 0085) nor omit one it can
|
|
134
132
|
# (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
|
|
135
133
|
# framing (role is connection-resolved over MCP; there is no stdin).
|
|
136
134
|
# writable_zones / propose_zone below carry the agent's write authority.
|
|
@@ -144,11 +142,11 @@ module Textus
|
|
|
144
142
|
|
|
145
143
|
# Recipes reference verbs, not a transport's CLI strings (ADR 0056): every
|
|
146
144
|
# step names a verb the agent can call (each transport frames it — CLI as
|
|
147
|
-
# `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
|
|
148
146
|
# keeps shell lines out of the surface an MCP agent reads.
|
|
149
147
|
def self.recipes(manifest)
|
|
150
148
|
queue = manifest.policy.queue_zone
|
|
151
|
-
feeds = zone_label(manifest, :
|
|
149
|
+
feeds = zone_label(manifest, :machine, "the machine zone")
|
|
152
150
|
{
|
|
153
151
|
"read" => {
|
|
154
152
|
"purpose" => "find and read an entry",
|
|
@@ -174,11 +172,11 @@ module Textus
|
|
|
174
172
|
"accept #{queue}.KEY — promotes the proposal into its target zone",
|
|
175
173
|
],
|
|
176
174
|
},
|
|
177
|
-
"
|
|
178
|
-
"purpose" => "
|
|
175
|
+
"reconcile" => {
|
|
176
|
+
"purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
|
|
179
177
|
"steps" => [
|
|
180
178
|
"pulse — its `stale` list names entries past their ttl",
|
|
181
|
-
"
|
|
179
|
+
"reconcile (zone: #{feeds}) — re-pull the stale entries",
|
|
182
180
|
],
|
|
183
181
|
},
|
|
184
182
|
}
|
|
@@ -196,8 +194,20 @@ module Textus
|
|
|
196
194
|
)
|
|
197
195
|
end
|
|
198
196
|
|
|
199
|
-
def self.build(container:)
|
|
197
|
+
def self.build(container:, lean: false)
|
|
200
198
|
manifest = container.manifest
|
|
199
|
+
etag = Textus::Etag.for_contract(container.root)
|
|
200
|
+
|
|
201
|
+
if lean
|
|
202
|
+
return {
|
|
203
|
+
"protocol" => PROTOCOL_ID,
|
|
204
|
+
"store_root" => container.root,
|
|
205
|
+
"zones" => zones_for(manifest),
|
|
206
|
+
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
207
|
+
"contract_etag" => etag,
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
201
211
|
{
|
|
202
212
|
"protocol" => PROTOCOL_ID,
|
|
203
213
|
"store_root" => container.root,
|
|
@@ -208,6 +218,7 @@ module Textus
|
|
|
208
218
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
209
219
|
"agent_protocol" => agent_protocol(manifest),
|
|
210
220
|
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
221
|
+
"contract_etag" => etag,
|
|
211
222
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
|
|
212
223
|
}
|
|
213
224
|
end
|
|
@@ -225,7 +236,7 @@ module Textus
|
|
|
225
236
|
|
|
226
237
|
def self.entries_for(manifest)
|
|
227
238
|
manifest.data.entries.map do |e|
|
|
228
|
-
derived =
|
|
239
|
+
derived = e.derived?
|
|
229
240
|
{
|
|
230
241
|
"key" => e.key,
|
|
231
242
|
"zone" => e.zone,
|
|
@@ -234,7 +245,7 @@ module Textus
|
|
|
234
245
|
"owner" => e.owner,
|
|
235
246
|
"format" => e.format,
|
|
236
247
|
"derived" => derived,
|
|
237
|
-
"intake" => e.
|
|
248
|
+
"intake" => e.intake?,
|
|
238
249
|
"publish_to" => Array(e.publish_to),
|
|
239
250
|
}
|
|
240
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,10 +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 freshness [--prefix=KEY] [--zone=Z]
|
|
127
|
-
textus fetch KEY
|
|
128
|
-
textus fetch all [--prefix=KEY] [--zone=Z]
|
|
125
|
+
textus put KEY --stdin --as=ROLE
|
|
129
126
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
130
127
|
textus blame KEY [--limit=N]
|
|
131
128
|
textus doctor
|