textus 0.47.0 → 0.49.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 +39 -0
- data/README.md +9 -7
- data/SPEC.md +40 -46
- data/docs/reference/conventions.md +11 -10
- data/lib/textus/boot.rb +2 -2
- data/lib/textus/builder/pipeline.rb +18 -13
- data/lib/textus/cli/runner.rb +5 -4
- data/lib/textus/dispatcher.rb +3 -8
- data/lib/textus/doctor/check/generator_drift.rb +28 -0
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
- data/lib/textus/doctor.rb +2 -0
- data/lib/textus/domain/lifecycle.rb +83 -0
- data/lib/textus/domain/policy/base_guards.rb +2 -2
- data/lib/textus/domain/policy/lifecycle.rb +35 -0
- data/lib/textus/domain/staleness.rb +6 -3
- data/lib/textus/envelope/io/writer.rb +2 -2
- data/lib/textus/hooks/context.rb +1 -1
- data/lib/textus/init.rb +4 -4
- data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
- data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
- data/lib/textus/maintenance/tend.rb +110 -0
- data/lib/textus/manifest/entry/derived.rb +6 -1
- data/lib/textus/manifest/entry/parser.rb +1 -1
- data/lib/textus/manifest/rules.rb +11 -23
- data/lib/textus/manifest/schema.rb +3 -18
- data/lib/textus/ports/audit_log.rb +1 -1
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +5 -1
- data/lib/textus/read/freshness.rb +29 -22
- data/lib/textus/read/get.rb +47 -32
- data/lib/textus/read/pulse.rb +1 -1
- data/lib/textus/read/rule_explain.rb +10 -16
- data/lib/textus/read/rule_list.rb +5 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +8 -12
- data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
- data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
- data/lib/textus/write/materializer.rb +5 -2
- data/lib/textus/write/reject.rb +1 -1
- metadata +8 -15
- data/lib/textus/cli/group/fetch.rb +0 -20
- data/lib/textus/cli/verb/fetch.rb +0 -14
- data/lib/textus/cli/verb/fetch_all.rb +0 -20
- data/lib/textus/domain/policy/fetch.rb +0 -37
- data/lib/textus/domain/policy/retention.rb +0 -26
- data/lib/textus/domain/retention.rb +0 -44
- data/lib/textus/domain/staleness/intake_check.rb +0 -54
- data/lib/textus/maintenance/migrate.rb +0 -65
- data/lib/textus/read/retainable.rb +0 -17
- data/lib/textus/read/stale.rb +0 -17
- data/lib/textus/write/fetch_all.rb +0 -53
- data/lib/textus/write/retention_sweep.rb +0 -64
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29a370d0ed895357e5a2e70d5f9b41542b0a4e5036472aa6f728f7a0bc459838
|
|
4
|
+
data.tar.gz: 87bf64e383226fad74ca8534fca9f8b8de707f1e01bc910c05ad1266e5de032a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3f2279c8660c754b64defe04aeac05e198095ef056b7a4aee534d3bd290baac7bf13d206999872a82c6bc61f723771f852bbfde2594fbaa17f80a19082df877
|
|
7
|
+
data.tar.gz: e11ce9e704b3ea33ef94012d8425624dd3391152869d1ecb6b00e5422042a50d4c3c75612227ddd61f9ed8c10720e6f254c818e1492056a2ca3d00751500554b
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,45 @@ 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.49.0 — 2026-06-04 — Normalize the key-verb family + remove `migrate` (ADR 0082)
|
|
13
|
+
|
|
14
|
+
The single-key mutation verbs gain the `key_` family stem, and the `migrate` orchestrator is removed.
|
|
15
|
+
|
|
16
|
+
### Changed (breaking)
|
|
17
|
+
|
|
18
|
+
- **`mv` → `key_mv` and `delete` → `key_delete`**, renamed everywhere the token is load-bearing: the verb / MCP-tool id, the guard transition symbol, the manifest `guard:` transition key, and the audit-log verb string. The single-key verbs now share the `key_` stem with their bulk cousins `key_mv_prefix`/`key_delete_prefix`; `zone_mv` is unchanged. **The CLI is unchanged** — `key mv`, `key delete`, `key mv-prefix`, `key delete-prefix` keep their spelling. **Migration:** manifests with `guard: { mv: … }` / `guard: { delete: … }` blocks must rename those keys to `key_mv` / `key_delete`; MCP clients pinned to the `mv`/`delete` tool ids must update; audit rows written before this release keep their `mv`/`delete` verb strings (readers must accept both).
|
|
19
|
+
|
|
20
|
+
### Removed (breaking)
|
|
21
|
+
|
|
22
|
+
- **The `migrate` verb (YAML-plan orchestrator).** It was non-transactional and added a second input format for no capability the primitives lack — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable, each with `--dry-run`. Removed from the CLI and the MCP catalog.
|
|
23
|
+
|
|
24
|
+
## 0.48.0 — 2026-06-04 — Unified `lifecycle` policy + docs become canon (ADR 0079, 0081)
|
|
25
|
+
|
|
26
|
+
Staleness and retention collapse into one age policy, and the upkeep verb surface shrinks 8→3.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **`docs/` is now textus canon (ADR 0081).** Every committed doc is authored under `.textus/zones/knowledge/` and published back to `docs/` by `textus build` (the same projection mechanism as `CLAUDE.md`/`AGENTS.md`); `docs/` is now a committed, sentinel-managed mirror. Adopt-in-place (ADR 0050) made the migration a zero-diff republish. `docs/assets/` (branding binaries) moved to repo-root `assets/`; `docs/plans/` (gitignored) is unchanged.
|
|
31
|
+
- **One `lifecycle: { ttl, on_expire }` rule slot** replaces the separate `fetch:` (intake freshness) and `retention:` (leaf pruning) slots. `on_expire` is `refresh` (re-pull intake), `warn` (flag on read), `drop` (delete), or `archive` (copy aside then delete). An action's *destructiveness* decides where it runs: non-destructive `refresh`/`warn` are applied lazily on `get`; destructive `drop`/`archive` run only on the `tend` sweep. A read never deletes.
|
|
32
|
+
- **`tend` is now the destructive-only sweep** — it drops/archives expired entries and refreshes cold intake, superseding the composite body of ADR 0078. Result keys are `dropped`/`archived`/`refreshed`/`failed` (apply) and `would_drop`/`would_archive`/`would_refresh` (`--dry-run`).
|
|
33
|
+
- **`freshness` reports the unified verdict** (`fresh`/`expired`/`no_policy`) and the matched `on_expire` action; `pulse`'s `stale` list now reflects expired entries.
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
37
|
+
- **The `stale`, `retainable`, `fetch`, `fetch_all`, and `retain` verbs.** Read-through refresh is `get` (lazy); destructive pruning is `tend`; `FetchWorker` remains as the internal executor for `get`/`tend`. Generator/build drift (dependency-based, not age-based) is now the `textus doctor` `generator_drift` check.
|
|
38
|
+
- **The `fetch:`/`retention:` rule slots and the per-rule `fetch_timeout_seconds` override** (accepted loss; the constant ceiling applies to every intake).
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **`doctor` checks `lifecycle.action_invalid`** (`refresh` only on intake; `drop`/`archive` only on stored entries) and **`generator_drift`** (the surviving home for build-drift detection).
|
|
43
|
+
|
|
44
|
+
## 0.47.1 — 2026-06-04 — External entries are a non-build path
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`build` no longer materializes `external` derived entries.** External entries are generated by an out-of-band runner — textus only tracks their staleness. Previously they flowed through `Derived#publish_via` → `Pipeline.run` and hit a catch-all branch that emitted an empty payload and re-stamped the volatile `generated_at` (contrary to ADR 0070), which for an entry with publish targets would **clobber the runner's artifact with an empty render**. External is now skipped in `Derived#publish_via`, and `Pipeline.run` raises loudly if a non-projection source reaches it (only projection-derived entries are buildable).
|
|
49
|
+
- **`compute.command` is now parsed for `external` entries.** The parser read `compute["runner"]`, a key the manifest schema forbids (`COMPUTE_KEYS` allows `command`), so `External#command` was always `nil`. The data field is renamed `runner` → `command` and now reads `compute["command"]`.
|
|
50
|
+
|
|
12
51
|
## 0.47.0 — 2026-06-04 — Close the agent loop over MCP
|
|
13
52
|
|
|
14
53
|
The edit→accept→rebuild loop now closes over a single MCP transport: `build` is surfaced to MCP, `init --with-agent` scaffolds a connectable agent setup, and connection-lifecycle hardening (whole-contract drift fingerprint + a connect-time event carrying the resolved role) underpins it.
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<picture>
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" srcset="
|
|
4
|
-
<img src="
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/branding/wordmark-dark.png">
|
|
4
|
+
<img src="assets/branding/wordmark.png" alt="textus" width="360">
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
@@ -184,7 +184,7 @@ textus get knowledge.notes.org.jane
|
|
|
184
184
|
textus list --zone=knowledge
|
|
185
185
|
printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
|
|
186
186
|
| textus put knowledge.notes.bob --as=human --stdin
|
|
187
|
-
textus freshness --zone=artifacts # per-entry fresh/
|
|
187
|
+
textus freshness --zone=artifacts # per-entry fresh/expired/no_policy + on_expire action
|
|
188
188
|
textus rule list # show every rule block
|
|
189
189
|
textus audit --limit=20 # query the audit log
|
|
190
190
|
```
|
|
@@ -263,12 +263,14 @@ Textus.hook do |reg|
|
|
|
263
263
|
end
|
|
264
264
|
```
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
Stale intake entries refresh lazily on read: a `textus get KEY` whose matched
|
|
267
|
+
`lifecycle` rule says `on_expire: refresh` re-pulls the source in-process before
|
|
268
|
+
returning. To find what is due, list the expired entries:
|
|
267
269
|
|
|
268
270
|
```sh
|
|
269
|
-
textus
|
|
270
|
-
#
|
|
271
|
-
textus
|
|
271
|
+
textus freshness --zone=feeds --output=json # rows with status: "expired"
|
|
272
|
+
# then read each one (read-through refresh per its lifecycle rule):
|
|
273
|
+
textus get feeds.calendar.events --as=automation
|
|
272
274
|
```
|
|
273
275
|
|
|
274
276
|
See SPEC.md §5.10 for the full hook contract.
|
data/SPEC.md
CHANGED
|
@@ -148,7 +148,7 @@ Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `hooks/`) live dire
|
|
|
148
148
|
|
|
149
149
|
Zone directories under `zones/` are conventional; their write semantics are derived from the zone's declared `kind:` (and the capabilities roles hold), not the directory name.
|
|
150
150
|
|
|
151
|
-
`.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `
|
|
151
|
+
`.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `key_delete`, `accept`, and `build`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
|
|
152
152
|
|
|
153
153
|
### 3.1 Store location precedence
|
|
154
154
|
|
|
@@ -208,7 +208,7 @@ entries:
|
|
|
208
208
|
|
|
209
209
|
rules:
|
|
210
210
|
- match: feeds.**
|
|
211
|
-
|
|
211
|
+
lifecycle: { ttl: 6h, on_expire: warn }
|
|
212
212
|
|
|
213
213
|
audit:
|
|
214
214
|
max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
|
|
@@ -454,9 +454,9 @@ A sentinel is written for each published file at `<store_root>/.run/sentinels/<t
|
|
|
454
454
|
|
|
455
455
|
**Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `to:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/.run/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The build envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused. When a `publish.tree` target overlaps a `derived` entry's `publish.to` (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
|
|
456
456
|
|
|
457
|
-
### 5.4 Intake (declared,
|
|
457
|
+
### 5.4 Intake (declared, refreshed via registered intake handler)
|
|
458
458
|
|
|
459
|
-
Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when
|
|
459
|
+
Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when a read-through `textus get KEY` encounters a stale entry whose `lifecycle` rule says `on_expire: refresh`. The declaration is data only:
|
|
460
460
|
|
|
461
461
|
```yaml
|
|
462
462
|
- key: feeds.calendar.events
|
|
@@ -468,23 +468,22 @@ Intake entries declare an external source by naming an **intake handler** — a
|
|
|
468
468
|
|
|
469
469
|
rules:
|
|
470
470
|
- match: feeds.calendar.**
|
|
471
|
-
|
|
471
|
+
lifecycle:
|
|
472
472
|
ttl: 6h
|
|
473
|
-
|
|
474
|
-
|
|
473
|
+
on_expire: refresh # refresh | warn | drop | archive
|
|
474
|
+
budget_ms: 500 # bound the in-process refresh (default: 500)
|
|
475
475
|
```
|
|
476
476
|
|
|
477
|
-
`handler` names a registered `:resolve_intake` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the handler. The freshness budget (`ttl`, `
|
|
477
|
+
`handler` names a registered `:resolve_intake` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the handler. The freshness budget (`ttl`, `on_expire`, `budget_ms`) lives in a top-level **`rules:`** block matched by key glob (§5.11).
|
|
478
478
|
|
|
479
|
-
#### `
|
|
479
|
+
#### `on_expire:` semantics
|
|
480
480
|
|
|
481
|
-
`
|
|
481
|
+
`on_expire:` declares what happens when `get` encounters an expired (past-TTL) intake entry. `get` is **read-through on every surface** (CLI, Ruby, MCP): it returns the freshest obtainable envelope, refreshing on an expired verdict per the entry's `lifecycle` rule and degrading to a pure on-disk read for keys with no lifecycle rule (ADR 0062). The value lives on the matching policy block, not on the entry. For intake entries the only valid actions are `refresh` and `warn` (`drop`/`archive` apply to stored entries and are enforced by `doctor` via `lifecycle.action_invalid`).
|
|
482
482
|
|
|
483
483
|
| Value | Behaviour |
|
|
484
484
|
|---|---|
|
|
485
485
|
| `warn` (default) | Return the entry immediately with `stale: true`, `stale_reason:` populated, and `fetching: false`. No blocking. |
|
|
486
|
-
| `
|
|
487
|
-
| `timed_sync` | Like `sync`, but with a `sync_budget_ms` deadline (default 500 ms). If the handler finishes within the budget the fresh envelope is returned. If it does not finish in time, return the stale envelope (with `stale: true`, `fetching: true`) and let the fetch complete in the background. Fires `:fetch_backgrounded` when the deadline is exceeded. |
|
|
486
|
+
| `refresh` | Block the `get` call, run the intake handler in-process under a `budget_ms` deadline (default 500 ms), write the result, and return the fresh envelope. If the handler does not finish in time, return the stale envelope (with `stale: true`, `fetching: true`) and let the refresh complete in the background. Fires `:fetch_backgrounded` when the deadline is exceeded. |
|
|
488
487
|
|
|
489
488
|
> **Note:** `list`/`where` paths do **not** annotate freshness — only `get` does.
|
|
490
489
|
|
|
@@ -496,10 +495,10 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
|
|
|
496
495
|
|
|
497
496
|
**Built-in intake handlers.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured `_meta`/body. Built-ins do not perform I/O themselves — the caller (or an outer hook) is responsible for supplying bytes.
|
|
498
497
|
|
|
499
|
-
**
|
|
498
|
+
**Refresh paths.** Two are supported:
|
|
500
499
|
|
|
501
|
-
1. **In-process** — `textus
|
|
502
|
-
2. **External automation** — a cron job or agent harness reads `textus
|
|
500
|
+
1. **In-process** — a read-through `textus get KEY --as=automation` on a stale entry whose rule says `on_expire: refresh` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `fetch` (`automation` by default).
|
|
501
|
+
2. **External automation** — a cron job or agent harness reads `textus freshness --zone=intake --output=json`, fetches sources reported `expired` out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`.
|
|
503
502
|
|
|
504
503
|
Both paths share the same write gate, audit-log entry, and `:entry_fetched` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
|
|
505
504
|
|
|
@@ -536,9 +535,9 @@ Schema (one JSON object per line, no interior whitespace):
|
|
|
536
535
|
|
|
537
536
|
`seq` is a monotonic integer counter, auto-incremented on each append. It is the foundation for cursor-based queries: `textus audit --seq-since=N` returns only rows with `seq > N`, and `textus pulse --since=N` builds its `changed` array from the same cursor. When an agent's cursor falls below the oldest available seq (due to log rotation), the operation raises `CursorExpired`.
|
|
538
537
|
|
|
539
|
-
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `
|
|
538
|
+
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `key_delete`, `accept`, `compute`, `key_mv`, ...; rows written before ADR 0082 used `delete`/`mv` — readers must accept both). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
|
|
540
539
|
|
|
541
|
-
For `
|
|
540
|
+
For `key_mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
|
|
542
541
|
|
|
543
542
|
**Rotation.** After every successful append the implementation checks whether `audit.log` exceeds `max_size` bytes (checked inside the held `flock`, so the check sees the post-write size). If it does, the active log is rotated:
|
|
544
543
|
|
|
@@ -688,10 +687,10 @@ A manifest MAY declare a top-level `rules:` block — a list of rule blocks matc
|
|
|
688
687
|
```yaml
|
|
689
688
|
rules:
|
|
690
689
|
- match: feeds.**
|
|
691
|
-
|
|
690
|
+
lifecycle: { ttl: 6h, on_expire: warn }
|
|
692
691
|
|
|
693
692
|
- match: feeds.calendar.**
|
|
694
|
-
|
|
693
|
+
lifecycle: { ttl: 30m, on_expire: refresh, budget_ms: 800 }
|
|
695
694
|
intake_handler_allowlist: [ical-events]
|
|
696
695
|
|
|
697
696
|
- match: proposals.**
|
|
@@ -703,24 +702,21 @@ rules:
|
|
|
703
702
|
|
|
704
703
|
| Slot | Type | Meaning |
|
|
705
704
|
|---|---|---|
|
|
706
|
-
| `
|
|
705
|
+
| `lifecycle` | `{ ttl, on_expire, budget_ms? }` | Unified age policy (ADR 0079). `on_expire` is `refresh` (re-pull intake), `warn` (flag on read), `drop` (delete), or `archive` (copy to `<store>/archive/<relative-path>` then delete). Non-destructive actions (`refresh`/`warn`) are applied lazily on `get`; destructive actions (`drop`/`archive`) only on the `tend` sweep. `refresh` is valid only for intake entries; `drop`/`archive` only for stored entries (`doctor` `lifecycle.action_invalid` enforces). Age is measured from `_meta.last_fetched_at` (intake) when present, else the leaf file's modification time. `budget_ms` (optional) bounds a `refresh` to a deadline, returning the stale envelope and refreshing in the background when exceeded. |
|
|
707
706
|
| `intake_handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
|
|
708
|
-
| `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `
|
|
709
|
-
| `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. |
|
|
707
|
+
| `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `key_delete`, `key_mv`, `accept`, `reject`, `fetch`). Predicate names are drawn from the closed vocabulary (`zone_writable_by`, `schema_valid`, `author_held`, `target_is_canon`, `etag_match`, `fresh_within`); parameterized predicates use `{ name: param }` form, e.g. `{ fresh_within: "1h" }`. Enforced — the transition refuses (`guard_failed`) if any predicate fails; the topology refusal keeps the `write_forbidden` code. |
|
|
710
708
|
|
|
711
|
-
|
|
712
|
-
`
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
`
|
|
716
|
-
any leaf whose zone the `--as` role cannot write is reported as a failure rather
|
|
717
|
-
than aborting the run.
|
|
709
|
+
The `lifecycle:` slot unifies the former `fetch:` (intake freshness) and
|
|
710
|
+
`retention:` (leaf pruning) slots into one age policy (ADR 0079). Generator/build
|
|
711
|
+
drift — a derived entry whose sources changed since its `generated.at` — is
|
|
712
|
+
dependency-based, not age-based, and is reported by the `textus doctor`
|
|
713
|
+
`generator_drift` check rather than this slot.
|
|
718
714
|
|
|
719
715
|
**Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
|
|
720
716
|
|
|
721
|
-
**Resolution.** For each key textus computes a `RuleSet {
|
|
717
|
+
**Resolution.** For each key textus computes a `RuleSet { handler_allowlist, guard, lifecycle }` by walking every block whose `match` matches the key, ranked by specificity. **Per slot, the most specific block wins.** Two blocks of equal specificity that match the same key and fill the same slot is a manifest error reported by `textus doctor` (`rule_ambiguity`).
|
|
722
718
|
|
|
723
|
-
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{
|
|
719
|
+
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{lifecycle, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
|
|
724
720
|
|
|
725
721
|
### 5.12 Storage formats
|
|
726
722
|
|
|
@@ -826,9 +822,9 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
826
822
|
- `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
|
|
827
823
|
- `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
|
|
828
824
|
- `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
|
|
829
|
-
- `stale` is `true` when the entry's TTL has elapsed and the data has not yet been
|
|
825
|
+
- `stale` is `true` when the entry's TTL has elapsed and the data has not yet been refreshed; `false` otherwise. Only populated for entries matched by a `lifecycle:` rule slot (typically `feeds` / quarantine zone); always `false` elsewhere.
|
|
830
826
|
- `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_fetched"`), or `null` when `stale` is `false`.
|
|
831
|
-
- `fetching` is `true` when
|
|
827
|
+
- `fetching` is `true` when an `on_expire: refresh` background refresh is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
|
|
832
828
|
|
|
833
829
|
> **Note:** `list`/`where` envelopes do **not** include `stale`, `stale_reason`, or `fetching` — freshness annotation is only provided by `get`.
|
|
834
830
|
|
|
@@ -867,7 +863,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
867
863
|
|---|---|---|
|
|
868
864
|
| `list [--prefix=K] [--zone=Z]` | read | any |
|
|
869
865
|
| `where K` | read | any |
|
|
870
|
-
| `get K [--no-fetch]` | read (read-through by default:
|
|
866
|
+
| `get K [--no-fetch]` | read (read-through by default: refresh-on-stale per the entry's `lifecycle` rule when `on_expire: refresh`, degrades to a pure read; `--no-fetch` / `{fetch:false}` for an explicit pure on-disk read) | any |
|
|
871
867
|
| `schema show K` | read | any |
|
|
872
868
|
| `freshness [--prefix=K] [--zone=Z]` | read | any |
|
|
873
869
|
| `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
|
|
@@ -883,10 +879,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
883
879
|
| `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
|
|
884
880
|
| `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
|
|
885
881
|
| `key delete K --if-etag=E --as=R` | write | per zone |
|
|
886
|
-
| `fetch KEY --as=automation` | write | `fetch`-holder (typically `automation`) |
|
|
887
|
-
| `fetch all [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
|
|
888
882
|
| `build [--prefix=K] [--dry-run]` | write | `build`-holder (typically `automation`) |
|
|
889
|
-
| `
|
|
883
|
+
| `tend [--prefix=K] [--zone=Z] [--dry-run] --as=ROLE` | write | per zone (role must write the matched zone) |
|
|
890
884
|
| `accept K --as=human` | write | `author`-holder (typically `human`) |
|
|
891
885
|
| `reject K --as=human` | write | `author`-holder (typically `human`) |
|
|
892
886
|
| `init` | write | `human` |
|
|
@@ -900,7 +894,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
900
894
|
{
|
|
901
895
|
"agent_quickstart": {
|
|
902
896
|
"read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
|
|
903
|
-
"write_verbs": ["accept", "
|
|
897
|
+
"write_verbs": ["accept", "key_delete", "key_mv", "propose", "put", "reject"],
|
|
904
898
|
"writable_zones": ["proposals"],
|
|
905
899
|
"propose_zone": "proposals",
|
|
906
900
|
"latest_seq": 1842
|
|
@@ -910,7 +904,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
910
904
|
|
|
911
905
|
`read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its freshness/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`freshness`/`doctor` (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
|
|
912
906
|
|
|
913
|
-
The agent's MCP write surface includes the single-key `
|
|
907
|
+
The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `build` is also on MCP (ADR 0076): it is caller-agnostic and self-elevating — it always runs as the manifest's `build`-capable actor regardless of the calling role, grants no authority over content (build is a pure, idempotent function of already-accepted canon, ADR 0070), and is serialized by a shared single-writer lock across all transports so a concurrent CLI or background build cannot collide with an MCP-triggered one.
|
|
914
908
|
|
|
915
909
|
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
916
910
|
|
|
@@ -939,7 +933,7 @@ The agent's MCP write surface includes the single-key `delete` and `mv` tools al
|
|
|
939
933
|
"if_etag": "sha256:8f3c…" }
|
|
940
934
|
```
|
|
941
935
|
|
|
942
|
-
`if_etag` is optional on both `put` and `
|
|
936
|
+
`if_etag` is optional on both `put` and `key_delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted, the write is unconditional (last-writer-wins).
|
|
943
937
|
|
|
944
938
|
**`textus freshness` output shape:**
|
|
945
939
|
|
|
@@ -952,14 +946,14 @@ The agent's MCP write surface includes the single-key `delete` and `mv` tools al
|
|
|
952
946
|
"last_fetched_at": "2026-05-21T13:21:17Z",
|
|
953
947
|
"age_seconds": 65000,
|
|
954
948
|
"ttl_seconds": 43200,
|
|
955
|
-
"
|
|
956
|
-
"status": "
|
|
949
|
+
"on_expire": "warn",
|
|
950
|
+
"status": "expired",
|
|
957
951
|
"next_due_at": "2026-05-22T01:21:17Z" }
|
|
958
952
|
]
|
|
959
953
|
}
|
|
960
954
|
```
|
|
961
955
|
|
|
962
|
-
Each row reports one entry's verdict (`fresh`, `
|
|
956
|
+
Each row reports one entry's verdict (`fresh`, `expired`, or `no_policy`) plus the matched rule's `on_expire` action, against its matched `lifecycle:` rule. `textus build` consumes its own staleness signal and executes derived entries' projections under a `build`-holding role (`automation` by default); `--dry-run` prints the plan without executing.
|
|
963
957
|
|
|
964
958
|
`textus accept K --as=human` promotes a pending entry into its target zone: it copies the patch body into the target key, deletes the pending entry, and writes one audit line per side (§audit). Only a role holding the `author` capability (the trust anchor — `human` by default) may invoke `accept`.
|
|
965
959
|
|
|
@@ -1008,7 +1002,7 @@ Given a manifest entry where `key: identity.self` lives in the `identity` zone (
|
|
|
1008
1002
|
Given the `person` schema and a `put` whose frontmatter omits `relationship`, the result is the error envelope with `code: "schema_violation"`, `details.missing: ["relationship"]`, and exit code 1.
|
|
1009
1003
|
|
|
1010
1004
|
**Fixture D — Staleness detection:**
|
|
1011
|
-
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes,
|
|
1005
|
+
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, lifecycle: { ttl: 1h, on_expire: warn } }]` block and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus freshness --output=json` includes a row for `intake.notes` with `status: "expired"`. Calling `textus freshness` does NOT trigger a refresh.
|
|
1012
1006
|
|
|
1013
1007
|
**Fixture E — Projection build:**
|
|
1014
1008
|
Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: projection }` clause selects fields from `working.projects` entries, `textus build derived.catalogs.skills` materializes the derived entry on disk with frontmatter and body matching the projected shape. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so rebuilding with unchanged sources reproduces it byte-for-byte and writes nothing.
|
|
@@ -1020,7 +1014,7 @@ Given a derived entry with a `template` clause referencing a `.mustache` file an
|
|
|
1020
1014
|
Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
|
|
1021
1015
|
|
|
1022
1016
|
**Fixture H — Audit log format:**
|
|
1023
|
-
Every successful write verb (`put`, `
|
|
1017
|
+
Every successful write verb (`put`, `key_delete`, `build`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
|
|
1024
1018
|
|
|
1025
1019
|
**Fixture I — Pending → accept:**
|
|
1026
1020
|
Given a proposal entry `proposals.knowledge.self.patch` proposing a change to `knowledge.identity.self`, `textus accept proposals.knowledge.self.patch --as=human` copies the patch body into the target key, deletes the proposal entry, and appends two audit lines (one for the target write, one for the proposals delete) in that order.
|
|
@@ -1066,7 +1060,7 @@ A `textus/3` implementation MUST:
|
|
|
1066
1060
|
- [ ] Refuse writes whose resolved role lacks the capability the target zone-kind requires with `write_forbidden`.
|
|
1067
1061
|
- [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
|
|
1068
1062
|
- [ ] Use the error codes in §8 and the exit-code table.
|
|
1069
|
-
- [ ] Implement `textus freshness` per §5.1 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|
|
|
1063
|
+
- [ ] Implement `textus freshness` per §5.1 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|expired|no_policy` (plus the `on_expire` action) without invoking any refresh.
|
|
1070
1064
|
- [ ] Pass the conformance fixtures A–I in §12.
|
|
1071
1065
|
|
|
1072
1066
|
A `textus/3` implementation MAY:
|
|
@@ -88,38 +88,39 @@ Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../
|
|
|
88
88
|
|
|
89
89
|
## Intake and freshness
|
|
90
90
|
|
|
91
|
-
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler;
|
|
91
|
+
External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; refresh is on demand via a read-through `get`:
|
|
92
92
|
|
|
93
93
|
```sh
|
|
94
|
-
textus
|
|
95
|
-
textus
|
|
94
|
+
textus get feeds.notion.roadmap --as=automation # refreshes if stale
|
|
95
|
+
textus freshness --zone=feeds --output=json # which entries are expired
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
Lifecycle budgets live in the top-level `rules:` block, matched by glob:
|
|
99
99
|
|
|
100
100
|
```yaml
|
|
101
101
|
rules:
|
|
102
102
|
- match: feeds.notion.**
|
|
103
|
-
|
|
103
|
+
lifecycle: { ttl: 6h, on_expire: refresh } # refresh | warn | drop | archive
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
A typical scheduled
|
|
106
|
+
A typical scheduled integration reads each expired feed (a read-through `get`
|
|
107
|
+
refreshes it in-process):
|
|
107
108
|
|
|
108
109
|
```sh
|
|
109
|
-
textus
|
|
110
|
+
textus get feeds.notion.roadmap --as=automation # in cron / CI
|
|
110
111
|
```
|
|
111
112
|
|
|
112
113
|
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.
|
|
113
114
|
|
|
114
|
-
### Read vs.
|
|
115
|
+
### Read vs. refresh
|
|
115
116
|
|
|
116
117
|
There is one public read operation (ADR 0062):
|
|
117
118
|
|
|
118
119
|
| Operation | Behaviour | Use for |
|
|
119
120
|
|-----------|-----------|---------|
|
|
120
|
-
| `ops.get` | Read-through by default —
|
|
121
|
+
| `ops.get` | Read-through by default — refreshes on stale per the entry's `lifecycle` rule when `on_expire: refresh`; degrades to a pure on-disk read when the key has no lifecycle rule. Pass `fetch: false` (CLI `--no-fetch`, MCP `{fetch:false}`) for an explicit pure read | all callers, including interactive reads, dashboards, and scripts that want the freshest obtainable envelope |
|
|
121
122
|
|
|
122
|
-
Build pipelines and other internal callers that must never trigger a
|
|
123
|
+
Build pipelines and other internal callers that must never trigger a refresh (materializer, projection, schema tooling, accept/reject/publish, uid, validator) construct `Read::Get` directly with the method default `fetch: false` — a pure, orchestrator-free read. They bypass the verb-dispatch injection that sets `fetch: true`, so they always get pure reads without any extra argument.
|
|
123
124
|
|
|
124
125
|
## Body content
|
|
125
126
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -87,8 +87,8 @@ module Textus
|
|
|
87
87
|
{ "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
|
|
88
88
|
{ "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
|
|
89
89
|
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_tree fan out copies" },
|
|
90
|
-
{ "name" => "
|
|
91
|
-
{ "name" => "freshness", "summary" => "per-entry
|
|
90
|
+
{ "name" => "tend" },
|
|
91
|
+
{ "name" => "freshness", "summary" => "per-entry lifecycle report (status, age, ttl, on_expire action)" },
|
|
92
92
|
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
93
93
|
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
94
94
|
{ "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
|
-
require "time"
|
|
3
2
|
|
|
4
3
|
module Textus
|
|
5
4
|
module Builder
|
|
@@ -42,19 +41,25 @@ module Textus
|
|
|
42
41
|
end
|
|
43
42
|
|
|
44
43
|
def self.run(mentry:, deps:)
|
|
45
|
-
# 1. Load sources + project + reduce
|
|
44
|
+
# 1. Load sources + project + reduce. Only projection-derived entries are
|
|
45
|
+
# buildable in-process; External entries are generated out-of-band and are
|
|
46
|
+
# filtered out upstream (Derived#publish_via), so reaching here with a
|
|
47
|
+
# non-projection source is a wiring bug — fail loudly rather than emit an
|
|
48
|
+
# empty payload (and never re-stamp the volatile generated_at, ADR 0070).
|
|
49
|
+
unless mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
|
|
50
|
+
raise UsageError.new(
|
|
51
|
+
"builder: '#{mentry.key}' is not a projection-derived entry; only projections are buildable",
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
46
55
|
data =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
).run
|
|
55
|
-
else
|
|
56
|
-
{ "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
|
|
57
|
-
end
|
|
56
|
+
Textus::Projection.new(
|
|
57
|
+
reader: deps.reader,
|
|
58
|
+
spec: mentry.source.to_h.transform_keys(&:to_s),
|
|
59
|
+
lister: deps.lister,
|
|
60
|
+
rpc: deps.rpc,
|
|
61
|
+
transform_context: deps.transform_context,
|
|
62
|
+
).run
|
|
58
63
|
data = data.merge("boot" => deps.inject_boot.call) if mentry.inject_boot && deps.inject_boot
|
|
59
64
|
|
|
60
65
|
# 2. Render
|
data/lib/textus/cli/runner.rb
CHANGED
|
@@ -137,10 +137,11 @@ module Textus
|
|
|
137
137
|
BEHAVIORAL_HATCHES = %i[get put build].freeze
|
|
138
138
|
|
|
139
139
|
# Contract verbs whose CLI is a plain `< Verb` command, not a projection at
|
|
140
|
-
# all —
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
|
|
140
|
+
# all — composite reports assembled outside the contract:
|
|
141
|
+
# boot, doctor — composite reports
|
|
142
|
+
# (fetch/fetch_all were removed in ADR 0079: FetchWorker is now internal,
|
|
143
|
+
# driven by get's read-through orchestrator and the tend sweep.)
|
|
144
|
+
NON_PROJECTED_CLI = %i[boot doctor].freeze
|
|
144
145
|
|
|
145
146
|
# The installer skips generation for either category.
|
|
146
147
|
HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -7,14 +7,11 @@ module Textus
|
|
|
7
7
|
# Write
|
|
8
8
|
put: Textus::Write::Put,
|
|
9
9
|
propose: Textus::Write::Propose,
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
key_delete: Textus::Write::KeyDelete,
|
|
11
|
+
key_mv: Textus::Write::KeyMv,
|
|
12
12
|
accept: Textus::Write::Accept,
|
|
13
13
|
reject: Textus::Write::Reject,
|
|
14
14
|
build: Textus::Write::Build,
|
|
15
|
-
fetch: Textus::Write::FetchWorker,
|
|
16
|
-
fetch_all: Textus::Write::FetchAll,
|
|
17
|
-
retain: Textus::Write::RetentionSweep,
|
|
18
15
|
|
|
19
16
|
# Read
|
|
20
17
|
get: Textus::Read::Get,
|
|
@@ -24,7 +21,6 @@ module Textus
|
|
|
24
21
|
blame: Textus::Read::Blame,
|
|
25
22
|
audit: Textus::Read::Audit,
|
|
26
23
|
freshness: Textus::Read::Freshness,
|
|
27
|
-
stale: Textus::Read::Stale,
|
|
28
24
|
deps: Textus::Read::Deps,
|
|
29
25
|
rdeps: Textus::Read::Rdeps,
|
|
30
26
|
pulse: Textus::Read::Pulse,
|
|
@@ -35,14 +31,13 @@ module Textus
|
|
|
35
31
|
validate_all: Textus::Read::ValidateAll,
|
|
36
32
|
doctor: Textus::Read::Doctor,
|
|
37
33
|
boot: Textus::Read::Boot,
|
|
38
|
-
retainable: Textus::Read::Retainable,
|
|
39
34
|
capabilities: Textus::Read::Capabilities,
|
|
40
35
|
|
|
41
36
|
# Maintenance
|
|
42
|
-
migrate: Textus::Maintenance::Migrate,
|
|
43
37
|
zone_mv: Textus::Maintenance::ZoneMv,
|
|
44
38
|
key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
|
|
45
39
|
key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
|
|
40
|
+
tend: Textus::Maintenance::Tend,
|
|
46
41
|
rule_lint: Textus::Maintenance::RuleLint,
|
|
47
42
|
}.freeze
|
|
48
43
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# ADR 0079: generator/build drift — a derived/external entry whose sources
|
|
5
|
+
# changed since its generated.at. Dependency-based (not age-based), so it
|
|
6
|
+
# stays OUT of the lifecycle/freshness unification and lives here as a
|
|
7
|
+
# health signal. This is the surviving home for what the removed `stale`
|
|
8
|
+
# verb reported.
|
|
9
|
+
class GeneratorDrift < Check
|
|
10
|
+
def call
|
|
11
|
+
gen = Textus::Domain::Staleness::GeneratorCheck.new(
|
|
12
|
+
manifest: manifest,
|
|
13
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
14
|
+
)
|
|
15
|
+
manifest.data.entries.flat_map { |m| gen.rows_for(m) }.map do |row|
|
|
16
|
+
{
|
|
17
|
+
"code" => "generator_drift",
|
|
18
|
+
"level" => "warning",
|
|
19
|
+
"subject" => row["key"],
|
|
20
|
+
"message" => row["reason"],
|
|
21
|
+
"fix" => "rebuild the entry: `textus build`",
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# ADR 0079: refresh is valid only for intake entries; drop/archive are
|
|
5
|
+
# invalid for intake entries (they would re-fetch, not prune).
|
|
6
|
+
class LifecycleActionInvalid < Check
|
|
7
|
+
def call
|
|
8
|
+
manifest.data.entries.filter_map do |mentry|
|
|
9
|
+
policy = manifest.rules.for(mentry.key).lifecycle
|
|
10
|
+
next if policy.nil?
|
|
11
|
+
|
|
12
|
+
intake = mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
13
|
+
bad = (policy.on_expire == :refresh && !intake) || (policy.destructive? && intake)
|
|
14
|
+
next unless bad
|
|
15
|
+
|
|
16
|
+
issue_for(mentry, policy, intake)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def issue_for(mentry, policy, intake)
|
|
23
|
+
{
|
|
24
|
+
"code" => "lifecycle.action_invalid",
|
|
25
|
+
"level" => "error",
|
|
26
|
+
"subject" => mentry.key,
|
|
27
|
+
"message" => "on_expire: #{policy.on_expire} is not valid for a " \
|
|
28
|
+
"#{intake ? "intake" : "stored"} entry",
|
|
29
|
+
"fix" => if intake
|
|
30
|
+
"use on_expire: refresh|warn for intake entries"
|
|
31
|
+
else
|
|
32
|
+
"use on_expire: drop|archive|warn for stored entries"
|
|
33
|
+
end,
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
# guard). 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 = %i[
|
|
9
|
+
SLOTS = %i[lifecycle handler_allowlist guard].freeze
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
12
|
out = []
|
data/lib/textus/doctor.rb
CHANGED