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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +9 -7
  4. data/SPEC.md +40 -46
  5. data/docs/reference/conventions.md +11 -10
  6. data/lib/textus/boot.rb +2 -2
  7. data/lib/textus/builder/pipeline.rb +18 -13
  8. data/lib/textus/cli/runner.rb +5 -4
  9. data/lib/textus/dispatcher.rb +3 -8
  10. data/lib/textus/doctor/check/generator_drift.rb +28 -0
  11. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
  12. data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
  13. data/lib/textus/doctor.rb +2 -0
  14. data/lib/textus/domain/lifecycle.rb +83 -0
  15. data/lib/textus/domain/policy/base_guards.rb +2 -2
  16. data/lib/textus/domain/policy/lifecycle.rb +35 -0
  17. data/lib/textus/domain/staleness.rb +6 -3
  18. data/lib/textus/envelope/io/writer.rb +2 -2
  19. data/lib/textus/hooks/context.rb +1 -1
  20. data/lib/textus/init.rb +4 -4
  21. data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
  22. data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
  23. data/lib/textus/maintenance/tend.rb +110 -0
  24. data/lib/textus/manifest/entry/derived.rb +6 -1
  25. data/lib/textus/manifest/entry/parser.rb +1 -1
  26. data/lib/textus/manifest/rules.rb +11 -23
  27. data/lib/textus/manifest/schema.rb +3 -18
  28. data/lib/textus/ports/audit_log.rb +1 -1
  29. data/lib/textus/ports/audit_subscriber.rb +1 -1
  30. data/lib/textus/ports/fetch/detached.rb +5 -1
  31. data/lib/textus/read/freshness.rb +29 -22
  32. data/lib/textus/read/get.rb +47 -32
  33. data/lib/textus/read/pulse.rb +1 -1
  34. data/lib/textus/read/rule_explain.rb +10 -16
  35. data/lib/textus/read/rule_list.rb +5 -7
  36. data/lib/textus/version.rb +1 -1
  37. data/lib/textus/write/accept.rb +1 -1
  38. data/lib/textus/write/fetch_worker.rb +8 -12
  39. data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
  40. data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
  41. data/lib/textus/write/materializer.rb +5 -2
  42. data/lib/textus/write/reject.rb +1 -1
  43. metadata +8 -15
  44. data/lib/textus/cli/group/fetch.rb +0 -20
  45. data/lib/textus/cli/verb/fetch.rb +0 -14
  46. data/lib/textus/cli/verb/fetch_all.rb +0 -20
  47. data/lib/textus/domain/policy/fetch.rb +0 -37
  48. data/lib/textus/domain/policy/retention.rb +0 -26
  49. data/lib/textus/domain/retention.rb +0 -44
  50. data/lib/textus/domain/staleness/intake_check.rb +0 -54
  51. data/lib/textus/maintenance/migrate.rb +0 -65
  52. data/lib/textus/read/retainable.rb +0 -17
  53. data/lib/textus/read/stale.rb +0 -17
  54. data/lib/textus/write/fetch_all.rb +0 -53
  55. data/lib/textus/write/retention_sweep.rb +0 -64
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c87fe067b0988d187beac617c4540e1563d0f006a08d6090b2b483c699555d46
4
- data.tar.gz: 4f0503119ebf3eeff28d7a16aee8dc8c62673e6b41912e85bc3453e596075e65
3
+ metadata.gz: 29a370d0ed895357e5a2e70d5f9b41542b0a4e5036472aa6f728f7a0bc459838
4
+ data.tar.gz: 87bf64e383226fad74ca8534fca9f8b8de707f1e01bc910c05ad1266e5de032a
5
5
  SHA512:
6
- metadata.gz: fde04b0339e798e545c98699462afaf2767a772f1e76811c4f8833b561b69d02b172e4879cf17ffaea1dd51f1930846007abf4523a801ccfb5525c53e1365b7f
7
- data.tar.gz: 10a4343781cb2251ba8f91121c0a89b1fe211702e7c5057693fce27357eee55c3abe6bfe7a88009ffc89f5fcc49ad6c44fcf258f532f5ac9d69d960857e833c6
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="docs/assets/branding/wordmark-dark.png">
4
- <img src="docs/assets/branding/wordmark.png" alt="textus" width="360">
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/stale/never_fetched/no_policy
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
- To keep a batch of stale intake entries current in one shot:
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 fetch all --prefix=feeds --zone=feeds --as=automation
270
- # or just fetch everything past its TTL in the feeds zone:
271
- textus fetch all --zone=feeds --as=automation
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`, `delete`, `accept`, and `build`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
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
- fetch: { ttl: 6h, on_stale: warn }
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, fetched via registered intake handler)
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 explicitly invoked by `textus fetch KEY --as=automation` (or by `textus fetch all`). The declaration is data only:
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
- fetch:
471
+ lifecycle:
472
472
  ttl: 6h
473
- on_stale: warn # warn | sync | timed_sync (default: warn)
474
- sync_budget_ms: 500 # only used when on_stale: timed_sync (default: 500)
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`, `on_stale`, `sync_budget_ms`) lives in a top-level **`rules:`** block matched by key glob (§5.11).
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
- #### `on_stale:` semantics
479
+ #### `on_expire:` semantics
480
480
 
481
- `on_stale:` declares what happens when `get` encounters a stale intake entry. `get` is **read-through on every surface** (CLI, Ruby, MCP): it returns the freshest obtainable envelope, fetching on a stale verdict per the entry's fetch rule and degrading to a pure on-disk read for keys with no fetch rule (ADR 0062). The value lives on the matching policy block, not on the entry. Vocabulary: `warn | sync | timed_sync`.
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
- | `sync` | Block the `get` call, run the intake handler in-process, write the fetched result, then return the fresh envelope. The caller waits. |
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
- **Fetch paths.** Two are supported:
498
+ **Refresh paths.** Two are supported:
500
499
 
501
- 1. **In-process** — `textus fetch KEY --as=automation` 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).
502
- 2. **External automation** — a cron job or agent harness reads `textus list --zone=intake --stale --output=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`. The CLI verb `textus fetch all [--prefix=K] [--zone=Z]` drives this loop in one shot.
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`, `delete`, `accept`, `compute`, `mv`, ...). `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).
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 `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.
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
- fetch: { ttl: 6h, on_stale: warn }
690
+ lifecycle: { ttl: 6h, on_expire: warn }
692
691
 
693
692
  - match: feeds.calendar.**
694
- fetch: { ttl: 30m, on_stale: timed_sync, sync_budget_ms: 800 }
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
- | `fetch` | `{ ttl, on_stale, sync_budget_ms, fetch_timeout_seconds }` | Freshness budget for intake entries. `on_stale` is `warn` (default), `sync`, or `timed_sync`. |
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`, `delete`, `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. |
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
- Both retention windows are optional, and `expire_after` is evaluated before
712
- `archive_after` so when both apply, a leaf past the (longer) `expire_after`
713
- window is deleted rather than archived. The usual configuration is therefore
714
- `archive_after < expire_after` (archive a leaf, then delete it once older).
715
- `textus retain --as=ROLE` runs the sweep; `--prefix` and `--zone` narrow it, and
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 { fetch, intake_handler_allowlist, guard, retention }` 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`).
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 `{fetch, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
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 fetched; `false` otherwise. Only populated for entries matched by a `fetch:` rule slot (typically `feeds` / quarantine zone); always `false` elsewhere.
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 a `timed_sync` background fetch is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
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: fetch-on-stale per the entry's fetch rule, degrades to a pure read; `--no-fetch` / `{fetch:false}` for an explicit pure on-disk read) | any |
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
- | `retain [--prefix=K] [--zone=Z] --as=ROLE` | write | per zone (role must write the matched zone) |
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", "delete", "fetch", "fetch_all", "mv", "propose", "put", "reject"],
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 `delete` and `mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment). 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 `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.
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 `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).
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
- "on_stale": "warn",
956
- "status": "stale",
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`, `stale`, `never_fetched`, or `no_policy`) against its matched `fetch:` 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.
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, fetch: { ttl: 1h } }]` 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: "stale"`. Calling `textus freshness` does NOT trigger a fetch.
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`, `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.
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|stale|never_fetched|no_policy` without invoking any fetch.
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; fetch is on demand:
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 fetch feeds.notion.roadmap --as=automation
95
- textus fetch all --zone=feeds --as=automation # everything past its TTL
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
- Freshness budgets live in the top-level `rules:` block, matched by glob:
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
- fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
103
+ lifecycle: { ttl: 6h, on_expire: refresh } # refresh | warn | drop | archive
104
104
  ```
105
105
 
106
- A typical scheduled-fetch integration shells the `fetch all` sweep itself:
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 fetch all --zone=feeds --as=automation # in cron / CI
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. fetch
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 — fetches on stale per the entry's fetch rule; degrades to a pure on-disk read when the key has no fetch 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
+ | `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 fetch (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
+ 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" => "fetch" },
91
- { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
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
- if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
48
- Textus::Projection.new(
49
- reader: deps.reader,
50
- spec: mentry.source.to_h.transform_keys(&:to_s),
51
- lister: deps.lister,
52
- rpc: deps.rpc,
53
- transform_context: deps.transform_context,
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
@@ -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 — worker verbs and composite reports assembled outside the contract:
141
- # fetch, fetch_allbackground intake workers (not request/response)
142
- # boot, doctor — composite reports
143
- NON_PROJECTED_CLI = %i[fetch fetch_all boot doctor].freeze
140
+ # all — composite reports assembled outside the contract:
141
+ # boot, doctorcomposite 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
@@ -7,14 +7,11 @@ module Textus
7
7
  # Write
8
8
  put: Textus::Write::Put,
9
9
  propose: Textus::Write::Propose,
10
- delete: Textus::Write::Delete,
11
- mv: Textus::Write::Mv,
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[fetch handler_allowlist guard].freeze
9
+ SLOTS = %i[lifecycle handler_allowlist guard].freeze
10
10
 
11
11
  def call
12
12
  out = []
data/lib/textus/doctor.rb CHANGED
@@ -27,6 +27,8 @@ module Textus
27
27
  Check::OrphanedPublishTargets,
28
28
  Check::PublishTreeIndexOverlap,
29
29
  Check::ProposalTargets,
30
+ Check::LifecycleActionInvalid,
31
+ Check::GeneratorDrift,
30
32
  ].freeze
31
33
 
32
34
  ALL_CHECKS = CHECKS.map(&:name_key).freeze