textus 0.40.0 → 0.41.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -1
  3. data/SPEC.md +16 -2
  4. data/docs/architecture/README.md +256 -0
  5. data/docs/reference/conventions.md +148 -0
  6. data/lib/textus/cli/verb/hook_run.rb +3 -1
  7. data/lib/textus/cli/verb/put.rb +1 -1
  8. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +48 -0
  9. data/lib/textus/doctor.rb +1 -0
  10. data/lib/textus/manifest/entry/base.rb +12 -20
  11. data/lib/textus/manifest/entry/nested.rb +8 -89
  12. data/lib/textus/manifest/entry/publish/each.rb +83 -0
  13. data/lib/textus/manifest/entry/publish/each_dir.rb +74 -0
  14. data/lib/textus/manifest/entry/publish/each_file.rb +29 -0
  15. data/lib/textus/manifest/entry/publish/mode.rb +39 -0
  16. data/lib/textus/manifest/entry/publish/none.rb +14 -0
  17. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +72 -0
  18. data/lib/textus/manifest/entry/publish/template.rb +22 -0
  19. data/lib/textus/manifest/entry/publish/to_paths.rb +27 -0
  20. data/lib/textus/manifest/entry/publish/tree.rb +54 -0
  21. data/lib/textus/manifest/entry/publish.rb +45 -0
  22. data/lib/textus/manifest/entry/validators/publish.rb +26 -0
  23. data/lib/textus/manifest/entry/validators.rb +1 -1
  24. data/lib/textus/manifest/schema.rb +1 -1
  25. data/lib/textus/ports/publisher.rb +14 -2
  26. data/lib/textus/version.rb +1 -1
  27. data/lib/textus/write/fetch_events.rb +42 -0
  28. data/lib/textus/write/fetch_orchestrator.rb +2 -3
  29. data/lib/textus/write/fetch_worker.rb +13 -22
  30. data/lib/textus/write/intake_fetch.rb +8 -6
  31. metadata +16 -2
  32. data/lib/textus/manifest/entry/validators/publish_each.rb +0 -79
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96c684d8a670835fb687a321abb85838ec831e95e8672b7f03453266e2224b65
4
- data.tar.gz: d9332836814f977824413a7bc379666cfd830f182d2c44a9ed5a71d1e7336513
3
+ metadata.gz: 4991eadd244e9ced0531683c7a40f27b88b825e23e8d0bd5ad5be7f91716e4c9
4
+ data.tar.gz: d3e8d18c1e360455aef19bd7168b0ffb4516c54d0004253f34af78940752120d
5
5
  SHA512:
6
- metadata.gz: cbfdd6527a26f2e8fb97cdde2cc99c5df4d36839af1f426bf52156335187d4b48ecbe45af1b271031861bc225d100676522082da6f74c171555bd5cf7bd0b227
7
- data.tar.gz: 38ac5fff2e9209c8bbbc15745a1ddbd840736ddcc23363c6a61099926b79807a9ccb49630a98b69f6ed41cf2a15d3272bd586f6bc749ea381e4d24158eeb597a
6
+ metadata.gz: f0f63a06265e280a8424476dff4941dfc0de016f68a9530ed53799429115416fea50f9f9479266baecd8d34c96e3bece97e1d97ab35446dd0dcb4cb2c7f61771
7
+ data.tar.gz: 4cbfee016952dcc19312097018b18b63b8515d59786fddafc81d4a696737cbd3bd32f1d3dd45beae6d8660495c631defe70d324344c274ce02d505a3285ae8df
data/CHANGELOG.md CHANGED
@@ -9,7 +9,19 @@ 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
- ## Unreleased
12
+ ## 0.41.0 — 2026-06-02 — `publish_tree` subtree mirror + content-identical publish adoption ([ADR 0047](docs/architecture/decisions/0047-publish-tree-keyless-subtree-mirror.md), [0050](docs/architecture/decisions/0050-native-authoring-and-content-identical-adoption.md))
13
+
14
+ No `textus/3` wire-format change — every change here is repo-local publish behaviour or internal re-layering. Headlines: a key-less `publish_tree:` subtree mirror (ADR 0047), and publish now *adopts* a byte-identical pre-existing target instead of refusing (ADR 0050), so an artifact tree already on disk onboards without a manual delete. Internals were re-cut along the way (fetch subsystem, ADR 0048; publish modes as a sum type, ADR 0049) with no contract change.
15
+
16
+ ### Added
17
+
18
+ - **`publish_tree:`** — a key-less, path-driven subtree mirror for `nested` entries: copies a whole stored directory to one target dir (layout preserved, per-file sentinels, `ignore`-filtered, whole-target prune). Unblocks publishing the sibling tree of a leaf whose index file is *derived* (issue #132 item #4). Additive; no protocol change. New `doctor` check `publish.tree_index_overlap`. See [ADR 0047](docs/architecture/decisions/0047-publish-tree-keyless-subtree-mirror.md).
19
+
20
+ ### Changed
21
+
22
+ - **Fetch subsystem re-layered so its three concerns each have one home ([ADR 0048](docs/architecture/decisions/0048-fetch-subsystem-three-concerns.md)).** Internal refactor, no wire or hook-contract change: the before-etag now routes through the `FileStore` port (a guard spec keeps `read/`+`write/` from regressing); a single `IntakeFetch` kernel owns "invoke the intake handler under a deadline" and always passes a uniform `caps: <Container>` to `:resolve_intake`; the fetch lifecycle event vocabulary moves into one `FetchEvents` seam shared by `FetchWorker` and `FetchOrchestrator`. Public verbs, hook events, and the `textus/3` wire contract are unchanged.
23
+ - **Publish modes re-cut into a resolved sum type ([ADR 0049](docs/architecture/decisions/0049-publish-modes-as-sum-type.md)).** Internal refactor, no manifest key, wire, event, or `doctor` change: each entry now resolves once to one `Manifest::Entry::Publish::*` mode (`None` / `ToPaths` / `EachFile` / `EachDir` / `Tree`) that owns its publish algorithm, replacing the nil-cascade selector on `Nested`. Mode exclusivity is structural — one `UsageError` naming the conflicting keys, in place of the four scattered pairwise guards across two validators. `EachDir` and `Tree` share one `SubtreeMirror` (one walk, one prune) whose `ignore`-in-prune difference (ADR 0047 D4) is now the explicit `prune_honors_ignore:` parameter. The three manifest keys and every published-leaf / `:file_published` shape are unchanged.
24
+ - **Publish adopts a byte-identical pre-existing target instead of refusing ([ADR 0050](docs/architecture/decisions/0050-native-authoring-and-content-identical-adoption.md)).** The clobber guard now fires only when an unmanaged target's content **differs** from the source (or it's an unmanaged symlink); an identical target is *adopted* — its sentinel is written, the file is untouched — so an artifact tree already on disk (e.g. an Agent Skill authored in its native shape, issue #132) onboards to textus without a manual delete. Narrows `SPEC.md` §491; reuses the sha already stored in the sentinel; no new manifest key, CLI flag, hook event, or build-envelope field, and no protocol change.
13
25
 
14
26
  ## 0.40.0 — 2026-06-02 — `publish_each` owns multi-file leaf subtrees ([ADR 0046](docs/architecture/decisions/0046-publish-leaf-subtrees.md))
15
27
 
data/SPEC.md CHANGED
@@ -274,6 +274,18 @@ Validation at manifest load: any unknown variable raises `UsageError`; a compute
274
274
 
275
275
  A leaf at `working.skills.voice-writer` (a directory `.textus/zones/working/skills/voice-writer/` containing `SKILL.md`, `commands.md`, and `references/*`) publishes its whole subtree under `skills/voice-writer/`, preserving layout.
276
276
 
277
+ **Subtree mirror (`publish_tree:`).** A nested manifest entry MAY declare `publish_tree:` to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). Unlike `publish_each:`, it is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every build the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). `publish_tree:` is mutually exclusive with `publish_to:` and `publish_each:`, and incompatible with `index_filename:`. When a `publish_tree:` target directory overlaps a `derived` entry's `publish_to:` (e.g. a derived `SKILL.md` written into the mirrored dir), the `publish_tree:` entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
278
+
279
+ ```yaml
280
+ - key: working.skills
281
+ path: working/skills
282
+ zone: working
283
+ schema: skill
284
+ nested: true
285
+ publish_tree: "skills"
286
+ ignore: ["*.tmp", ".DS_Store"]
287
+ ```
288
+
277
289
  **`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When `textus build` materializes the entry, it merges the `textus boot` envelope (§9) into the projection data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside its projected rows. The flag is rejected at manifest load on (a) non-derived entries or (b) derived entries without a `template:` — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
278
290
 
279
291
  **Lookup rule:** to resolve a key, find the entry with the longest `key:` prefix that matches. If that entry has `nested: true`, the remaining segments map to subdirectories under its `path`. Otherwise the key must equal an entry exactly. The resolved filesystem path is `<.textus root>/zones/<entry.path>[/<remaining>...].md` — implementations MUST prepend `zones/` to the manifest `path:` when constructing the filesystem location.
@@ -476,10 +488,12 @@ publish_to:
476
488
 
477
489
  When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
478
490
 
479
- A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing or marked as managed. Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
491
+ A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
480
492
 
481
493
  **Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The publish unit follows the entry's `index_filename` (ADR 0046): a file-leaf entry byte-copies one file per leaf; a directory-leaf entry (with `index_filename`) byte-copies the leaf's whole subtree — one row and one sentinel **per file** — applying the entry's `ignore:` filter, and prunes textus-managed files under the target that the current source no longer produces (never touching unmanaged files). The build envelope grows a `published_leaves` array — one row per published 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.
482
494
 
495
+ **Subtree mirror.** A nested entry MAY declare `publish_tree:` instead of `publish_to:` or `publish_each:` (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>/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. `publish_tree:` is mutually exclusive with `publish_to:` and `publish_each:`, and incompatible with `index_filename:`. When a `publish_tree:` target overlaps a `derived` entry's `publish_to:` (e.g. a derived `SKILL.md` written into the mirrored dir), the `publish_tree:` entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
496
+
483
497
  ### 5.4 Intake (declared, fetched via registered intake handler)
484
498
 
485
499
  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 stale`). The declaration is data only:
@@ -998,7 +1012,7 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
998
1012
 
999
1013
  ## 10.2 `textus doctor`
1000
1014
 
1001
- `textus doctor` returns a health-check envelope: `{ "protocol": "textus/3", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `protocol_version`, `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `hooks`, `intake_registration`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `rule_ambiguity`, `handler_allowlist`, `fetch_locks`, `proposal_targets`. Additional registered `:validate` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
1015
+ `textus doctor` returns a health-check envelope: `{ "protocol": "textus/3", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `protocol_version`, `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `hooks`, `intake_registration`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `rule_ambiguity`, `handler_allowlist`, `fetch_locks`, `proposal_targets`, `publish.tree_index_overlap`. Additional registered `:validate` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
1002
1016
 
1003
1017
  ## 11. Versioning
1004
1018
 
@@ -0,0 +1,256 @@
1
+ # Textus architecture
2
+
3
+ > **Explanation** · for contributors · **read this first** for orientation before SPEC
4
+ > **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/fetch paths) · **reviewed** 2026-05 (v0.35)
5
+
6
+ ```mermaid
7
+ flowchart TD
8
+ interface["Interface — CLI verbs · MCP gate (JSON-RPC)"]
9
+ application["Application — Call · Container · Dispatcher · RoleScope<br/>read/ · write/ · maintenance/ use cases · envelope IO"]
10
+ domain["Domain — Permission · Freshness · Staleness<br/>Policy (Guard · GuardFactory · BaseGuards · Evaluation · Fetch · Matcher · Predicates)"]
11
+ infra["Infrastructure — Store · FileStore · Manifest · Schemas<br/>Ports · Hooks · Entry format strategies"]
12
+ interface --> application
13
+ application --> domain
14
+ application --> infra
15
+ domain -.->|implemented by| infra
16
+ ```
17
+
18
+ *Dependency rule: arrows point down.* Domain performs no direct `File`/`Dir`/`Time.now` I/O — all disk and clock access is routed through injected ports; pure path math is allowed. Application imports Domain + Ports. Use cases are plain classes on `(container:, call:)`. Verbs are looked up in the static `Dispatcher::VERBS` table.
19
+
20
+ ### What lives in each layer
21
+
22
+ **Interface**
23
+
24
+ ```
25
+ CLI verbs: store.<verb>(..., role:)
26
+ store.as(role).<verb>(...) # (put/get/fetch/…)
27
+
28
+ MCP gate: textus mcp serve — same use cases, JSON-RPC.
29
+ ```
30
+
31
+ **Application**
32
+
33
+ ```
34
+ Call (slim Data: role, correlation_id, now,
35
+ dry_run — request state only)
36
+ Container (single record — wired ports + manifest)
37
+ Dispatcher (static VERBS table: verb → use-case)
38
+ RoleScope (Store#as(role) — forwards verb calls)
39
+
40
+ read/{get,get_or_fetch,list,where,uid,schema_envelope,
41
+ deps,rdeps,published,stale,validate_all,boot,doctor,
42
+ freshness,audit,blame,policy_explain,pulse}.rb
43
+ write/{put,delete,mv,accept,reject,publish,
44
+ materializer,intake_fetch,retention_sweep,
45
+ fetch_worker,fetch_orchestrator,fetch_all}
46
+ maintenance/{migrate,key_mv_prefix,key_delete_prefix,
47
+ zone_mv,rule_lint}.rb
48
+ envelope/io/{reader,writer}.rb (split: parse vs persist)
49
+ projection.rb
50
+ ```
51
+
52
+ **Domain**
53
+
54
+ ```
55
+ Permission (write predicate per zone)
56
+ Freshness::{Policy,Verdict,Evaluator}
57
+ Staleness (Generator/Intake checks)
58
+ Action Outcome Sentinel
59
+ Policy::{Guard,GuardFactory,BaseGuards,Evaluation,Fetch,Matcher,HandlerAllowlist,
60
+ Predicates::{ZoneWritableBy,SchemaValid,AuthorHeld,TargetIsCanon,EtagMatch,FreshWithin}}
61
+ ```
62
+
63
+ **Infrastructure**
64
+
65
+ ```
66
+ Store (composition root — wires ports,
67
+ vends a Container + dispatches verbs)
68
+ Storage::FileStore (bytes-only port: read/write/delete/
69
+ exists?/etag)
70
+ Manifest (Data, Resolver, Policy, Rules)
71
+ Schemas (eager-load cache)
72
+ Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
73
+ Fetch::Lock,Fetch::Detached,BuildLock}
74
+ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
75
+ Signature,Builtin,ErrorLog}
76
+ Entry::{Markdown,Json,Yaml,Text} (format strategies)
77
+ ```
78
+
79
+ ## How a verb becomes a method
80
+
81
+ Each application use case is a plain class under `lib/textus/{read,write,maintenance}/`. The shape is uniform:
82
+
83
+ ```ruby
84
+ module Textus
85
+ module Read
86
+ class Get
87
+ def initialize(container:, call:)
88
+ @container = container
89
+ @call = call
90
+ end
91
+
92
+ def call(key)
93
+ ...
94
+ end
95
+ end
96
+ end
97
+ end
98
+ ```
99
+
100
+ Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get → Textus::Read::Get`, `:put → Textus::Write::Put`, etc. `Store#put` / `Store#get` / `Store#as(role).<verb>(...)` instantiate the use case on `(container:, call:)` and invoke `#call`. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
101
+
102
+ The instantiate-and-call step itself has one home: `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` (ADR 0026). `RoleScope` builds the `Call` (request state) and delegates the dispatch to `Dispatcher.invoke`; the convention for invoking a uniform-shape use case lives next to the table that maps the verbs, not re-spelled in the caller. `Store`'s own verb loop is separate — it extracts the `role:` keyword and forwards to `as(role)`, a role-selection job distinct from invocation.
103
+
104
+ `boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
105
+ are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
106
+ `Textus::Doctor` report-building libraries (`build(container:, ...)`). They are
107
+ reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
108
+
109
+ Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
110
+
111
+ - `Write::FetchOrchestrator` — composes `FetchWorker` with the freshness `Action` returned by `Domain::Freshness`.
112
+ - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
113
+
114
+ ## Container
115
+
116
+ Use cases never see the raw `Store`. `Textus::Container` is a single record holding the wired collaborators:
117
+
118
+ ```ruby
119
+ Container = Data.define(
120
+ :manifest, :file_store, :schemas, :root,
121
+ :audit_log, :events, :rpc
122
+ )
123
+ ```
124
+
125
+ The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: <Container>` — field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
126
+
127
+ ## Ports
128
+
129
+ Ports are infrastructure adapters with an interface defined by the domain. Each port is independently replaceable — swap the implementation for tests or alternative runtimes without touching application or domain code.
130
+
131
+ | Class | Role |
132
+ |---|---|
133
+ | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. No knowledge of envelopes or schemas. |
134
+ | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
135
+ | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
136
+ | `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel so the next publish can confirm the target is managed. |
137
+ | `Ports::Fetch::Lock` | Non-blocking `flock`-backed lock per key — prevents concurrent fetch workers from racing on the same entry. |
138
+ | `Ports::Fetch::Detached` | Spawns a background thread for async fetch; the caller receives a `fetch_backgrounded` event instead of blocking. |
139
+ | `Ports::BuildLock` | Process-exclusive `flock` guard over the materializer build pipeline. Raises `BuildInProgress` if a build is already running. |
140
+
141
+ Application use cases access ports only through `Container` fields — never through the raw `Store`.
142
+
143
+ ### EnvelopeIO
144
+
145
+ `Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
146
+
147
+ **Reader** (`lib/textus/envelope/io/reader.rb`) — resolves a key through `manifest.resolver`, reads bytes via `FileStore`, delegates parsing to the format strategy (`Entry.for_format`), and returns an `Envelope`. No audit, no events, no permission checks. Also used by `Writer` for the existing-uid lookup on `put`.
148
+
149
+ **Writer** (`lib/textus/envelope/io/writer.rb`) — owns the full write pipeline: serialize → schema-validate → etag-check → `FileStore#write` → `AuditLog#append`. The class comment states the invariant directly: every public method's final action is `@audit_log.append(...)`. If the audit append fails, the caller sees the underlying error — the byte write already happened, but the pipeline contract treats audit as the commit step. No permission check, no event firing — those stay in the calling use case (`Write::Put`, `Write::Delete`, `Write::Mv`).
150
+
151
+ The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
152
+
153
+ Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` (which builds its own `Reader.from`) and `Reader.from(container:)` (ADR 0026). Write use cases call `Writer.from` rather than reconstructing the object graph by hand, so a change to the Writer's dependencies is a one-line edit in one place.
154
+
155
+ ## Manifest carving
156
+
157
+ Manifest carving means slicing the parsed manifest YAML into four purpose-specific sub-objects. Each consumer sees only the fields it needs; none reach into the full raw document.
158
+
159
+ `Manifest` itself is a `Data.define` struct — a composition record with four named members:
160
+
161
+ | Member | Class | Responsibility |
162
+ |---|---|---|
163
+ | `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_caps` (role name → capability set). Structural data only — no behaviour beyond accessors and key validation. |
164
+ | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
165
+ | `policy` | `Manifest::Policy` | Zone/capability authority — `verb_for_zone` (zone-kind → required verb), `roles_with_capability(verb)`, `zone_writers` (derived: roles holding the verb the zone's kind requires), `permission_for`, `declared_kind`, `proposer_role`, `propose_zone_for(role)`. Write authority is derived from capabilities × zone-kind (ADR 0030); no filesystem I/O. `propose_zone_for` returns the single `kind: queue` zone when the role can write it (ADR 0027). |
166
+ | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` returns a `RuleSet(fetch, handler_allowlist, guard, retention)` by evaluating all `match:` blocks against the key. |
167
+
168
+ Rationale: cleaner test seams — a use case that only needs key resolution constructs a `Manifest::Resolver` from a stub `Data`; one that only needs rule lookup constructs a `Manifest::Rules` directly. No consumer is forced to build the full manifest to exercise one sub-view.
169
+
170
+ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Manifest::Data` constructs `Policy` internally during `initialize`; the others are assembled by the loader and handed in as named arguments.
171
+
172
+ ## Read path (`store.get(key)`)
173
+
174
+ 1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
175
+ 2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`.
176
+ 3. `Read::Get#call` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope.
177
+ 4. Looks up the fetch policy via `container.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
178
+ 5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `fetching: false`.
179
+
180
+ `store.get_or_fetch(key)` composes `Read::Get` with `Write::FetchOrchestrator` to optionally fetch on stale.
181
+
182
+ ## Write path (`store.put(key, ...)`)
183
+
184
+ 1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
185
+ 2. `Write::Put#call` validates the key, resolves the manifest entry, builds `GuardFactory.for(:put, key)` and calls `Guard#check!(eval)` (topology is predicate #0, `zone_writable_by`) — raises `WriteForbidden` if the topology gate denies, `GuardFailed` if any other predicate fails.
186
+ 3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
187
+ 4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
188
+
189
+ `Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
190
+
191
+ `Write::Mv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
192
+
193
+ ## Fetch path (`store.fetch(key)`)
194
+
195
+ 1. CLI `Verb::Fetch` calls `store.fetch(key, role: "automation")`.
196
+ 2. `Write::FetchWorker#run(key)`:
197
+ - Resolves the manifest entry, looks up the intake handler via `container.rpc.callable(:resolve_intake, mentry.handler)`.
198
+ - Publishes `:fetch_started` with the hook context.
199
+ - Invokes the handler under a 30s thread-join deadline.
200
+ - On any error: publishes `:fetch_failed`, then re-raises.
201
+ - On success: builds `GuardFactory.for(:fetch, key)` and calls `Guard#check!`, then persists via `Envelope::IO::Writer#write` directly (no `Put` round-trip); publishes `:entry_fetched` unless etag is unchanged.
202
+ 3. `store.fetch_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `FetchWorker#run` per entry; returns `{ fetched:, failed:, skipped: }`.
203
+
204
+ ## Hook payload contract
205
+
206
+ Pub-sub hooks (`:entry_put`, `:entry_fetched`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
207
+
208
+ RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
209
+
210
+ ## Agent surface (boot + pulse + MCP)
211
+
212
+ Agents and plugins talk to a textus store through three layers:
213
+
214
+ ```
215
+ soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
216
+ ```
217
+
218
+ Two transports, one façade:
219
+
220
+ - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
221
+ - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, manifest_etag) is server-side.
222
+
223
+ Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
224
+
225
+ The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
226
+
227
+ 1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
228
+ 2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
229
+ 3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema`, `rules`.
230
+
231
+ Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
232
+
233
+ ## Hooks event catalog
234
+
235
+ `Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027).
236
+
237
+ RPC (single handler, declares `caps:`):
238
+ - `resolve_intake(caps:, config:, args:)` — intake fetch handler.
239
+ - `transform_rows(caps:, rows:, config:)` — row transform for intakes.
240
+ - `validate(caps:)` — custom doctor validator.
241
+
242
+ Pub-sub (0..N handlers, declare `ctx:`):
243
+ - `entry_put(ctx:, key:, envelope:)`
244
+ - `entry_deleted(ctx:, key:)`
245
+ - `entry_fetched(ctx:, key:, envelope:, change:)`
246
+ - `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
247
+ - `build_completed(ctx:, key:, envelope:, sources:)`
248
+ - `proposal_accepted(ctx:, key:, target_key:)`
249
+ - `proposal_rejected(ctx:, key:, target_key:)`
250
+ - `file_published(ctx:, key:, envelope:, source:, target:)`
251
+ - `store_loaded(ctx:)`
252
+ - `fetch_started(ctx:, key:, mode:)`
253
+ - `fetch_failed(ctx:, key:, error_class:, error_message:)`
254
+ - `fetch_backgrounded(ctx:, key:, started_at:, budget_ms:)`
255
+
256
+ Authoritative source: `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
@@ -0,0 +1,148 @@
1
+ # Conventions
2
+
3
+ > **Reference** · for integrators · **read when** you're shaping a `.textus/` tree and want the idiomatic choices
4
+ > **SSoT for** idiomatic key naming, schema design, and automation integration · **reviewed** 2026-05 (v0.35)
5
+
6
+ Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and integrating with build automation. The spec ([`../../SPEC.md`](../../SPEC.md)) defines what's enforceable; this document captures what's *idiomatic*.
7
+
8
+ ## Key naming
9
+
10
+ - **Segments are lowercase, kebab- or snake-case.** The grammar `^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$` is the hard limit. Prefer `acme-dashboard` over `acmedashboard` when there's a natural word break.
11
+ - **Lead with the zone in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The zone prefix makes it obvious from the key alone whether a write will be accepted.
12
+ - **Mirror the directory structure.** If `working.projects.acme.dashboard` resolves to `working/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
13
+ - **Don't pluralise the leaf.** `working.network.org.jane`, not `working.network.org.janes`. Pluralise the container, not the entry.
14
+
15
+ ## Zone layout
16
+
17
+ Recommended top-level layout — the spec allows alternatives, but this is what tooling will default to:
18
+
19
+ ```
20
+ .textus/
21
+ manifest.yaml
22
+ schemas/ # YAML schema definitions
23
+ zones/
24
+ knowledge/ # authored truth: identity, voice, decisions — author-holders write
25
+ notebook/ # agent's own durable lane (workspace) — keep-holders write
26
+ feeds/ # automation-fed external inputs (fetch)
27
+ proposals/ # AI proposals awaiting accept (propose)
28
+ artifacts/ # generated by build automation — never edit by hand
29
+ ```
30
+
31
+ Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
32
+
33
+ ## Schema design
34
+
35
+ - **One schema per entry type, not per directory.** `person.yaml`, `project.yaml`, `decision.yaml` — applied across multiple subtrees if the shape matches.
36
+ - **Required = "this entry is meaningless without it."** Everything else is `optional`. Resist the urge to mark organisational metadata (like `tags`) required.
37
+ - **Prefer `enum` over free-text** for low-cardinality fields (relationship type, status, severity). Agents are far better at picking from a list than at producing exact strings.
38
+ - **Cap string lengths** with `max:` where the field has a natural bound (names, summaries). Skip for prose body — bodies are not schema-validated, only frontmatter is.
39
+
40
+ ## Owner strings
41
+
42
+ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it to label *who's expected to write here*:
43
+
44
+ - `textus:network` — humans curate
45
+ - `agent:planner` — a specific named agent
46
+ - `build:catalog-skills` — a specific build job
47
+
48
+ Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
49
+
50
+ ## Derived entries
51
+
52
+ A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
53
+
54
+ **`compute: { kind: projection }`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
55
+
56
+ ```yaml
57
+ - key: artifacts.catalogs.people
58
+ path: artifacts/catalogs/people.md
59
+ zone: artifacts
60
+ schema: null
61
+ owner: build:catalog-people
62
+ compute:
63
+ kind: projection
64
+ select: knowledge.network.org # prefix or list of prefixes
65
+ pluck: [name, relationship, org]
66
+ sort_by: name
67
+ template: people.mustache # under .textus/templates/
68
+ publish_to: [docs/people.md] # optional repo-relative byte-copy targets
69
+ ```
70
+
71
+ **`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
72
+
73
+ ```yaml
74
+ - key: artifacts.catalogs.skills
75
+ path: artifacts/catalogs/skills.md
76
+ zone: artifacts
77
+ owner: build:catalog-skills
78
+ compute:
79
+ kind: external
80
+ command: "rake catalog:skills" # informational; the automation invokes it
81
+ sources: [knowledge.projects, knowledge.network]
82
+ ```
83
+
84
+ The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
85
+
86
+ Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Transforms (`compute.transform:`) and per-leaf publishing (`publish_each:`) are also covered there.
87
+
88
+ ## Intake and freshness
89
+
90
+ External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; fetch is on demand:
91
+
92
+ ```sh
93
+ textus fetch feeds.notion.roadmap --as=automation
94
+ textus fetch stale --zone=feeds --as=automation # everything past its TTL
95
+ ```
96
+
97
+ Freshness budgets live in the top-level `rules:` block, matched by glob:
98
+
99
+ ```yaml
100
+ rules:
101
+ - match: feeds.notion.**
102
+ fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
103
+ ```
104
+
105
+ A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
106
+
107
+ ```sh
108
+ textus fetch stale --zone=intake --as=automation # in cron / CI
109
+ ```
110
+
111
+ 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.
112
+
113
+ ### Read vs. fetch
114
+
115
+ There are two read operations, and the difference matters in custom code:
116
+
117
+ | Operation | Triggers fetch? | Use for |
118
+ |-----------|-----------------|---------|
119
+ | `ops.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
120
+ | `ops.get_or_fetch` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
121
+
122
+ Build always uses the pure path; injecting fetch into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_fetch` only when you genuinely want side effects on read.
123
+
124
+ ## Body content
125
+
126
+ - **Bodies are Markdown.** Headings, lists, code fences — whatever a human or agent finds useful.
127
+ - **The schema does not validate the body.** If a field belongs in structured data, put it in frontmatter, not the body.
128
+ - **Keep entries short.** If a project entry hits 500 lines, it probably wants to be split into sub-entries (e.g. `working.projects.acme.dashboard` + `working.projects.acme.api`) rather than one mega-document.
129
+
130
+ ## Concurrency
131
+
132
+ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treats etag-less writes as last-writer-wins on purpose (single-writer scripts, fresh-file creation), but anything resembling a daemon or a long-running agent should round-trip the etag.
133
+
134
+ ## Application layering
135
+
136
+ The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](../architecture/decisions/0018-manifest-carving.md), [ADR 0017](../architecture/decisions/0017-envelope-io-split.md), [ADR 0022](../architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](../architecture/decisions/0023-uniform-use-case-shape.md).
137
+
138
+ - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
139
+ - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
140
+ - **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
141
+
142
+ The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
143
+
144
+ ## Pairing with other tools
145
+
146
+ - **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
147
+ - **Vector stores**: index `body` content into a vector store if you want fuzzy retrieval. `frontmatter` stays in textus as the source of truth for deterministic facts.
148
+ - **CI**: run `textus freshness` (or `textus list` + schema validation) in CI to catch drift between derived entries and their sources.
@@ -26,11 +26,13 @@ module Textus
26
26
  end
27
27
  end
28
28
 
29
+ # Validate --as resolves to a declared role (raises InvalidRole); hook
30
+ # run has no role-scoped authority itself, so the result is discarded.
29
31
  Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
32
 
31
33
  begin
32
34
  Textus::Write::IntakeFetch.invoke(
33
- rpc: store.rpc, handler: name, config: {}, args: args, label: "hook run",
35
+ caps: store.container, handler: name, config: {}, args: args, label: "hook run",
34
36
  )
35
37
  rescue Textus::Error
36
38
  raise
@@ -18,7 +18,7 @@ module Textus
18
18
  payload =
19
19
  if fetch_name
20
20
  result = Textus::Write::IntakeFetch.invoke(
21
- rpc: store.rpc, handler: fetch_name,
21
+ caps: store.container, handler: fetch_name,
22
22
  config: { "bytes" => raw }, args: {}, label: "fetch"
23
23
  )
24
24
  basename = key.split(".").last
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # ADR 0047 Decision 4. A publish_tree entry prunes its WHOLE target dir on
5
+ # every build. If a derived entry's publish_to writes a file into that same
6
+ # dir, the tree's prune will delete it unless the tree `ignore`s that
7
+ # filename. Warn so the author adds the ignore before prune eats the index.
8
+ class PublishTreeIndexOverlap < Check
9
+ def call
10
+ entries = manifest.data.entries
11
+ trees = entries.select { |e| e.nested? && e.publish_tree }
12
+ return [] if trees.empty?
13
+
14
+ derived_targets = entries.flat_map do |e|
15
+ Array(e.publish_to).map { |rel| [e, rel] }
16
+ end
17
+
18
+ trees.flat_map do |tree|
19
+ target_prefix = "#{tree.publish_tree.chomp("/")}/"
20
+ derived_targets.filter_map do |(derived, rel)|
21
+ next nil unless rel.start_with?(target_prefix)
22
+
23
+ rel_to_target = rel.delete_prefix(target_prefix)
24
+ next nil if tree.ignored?(rel_to_target)
25
+
26
+ issue(tree, derived, rel, rel_to_target)
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def issue(tree, derived, rel, rel_to_target)
34
+ basename = File.basename(rel_to_target)
35
+ {
36
+ "code" => "publish.tree_index_overlap",
37
+ "level" => "warning",
38
+ "subject" => tree.key,
39
+ "message" => "publish_tree '#{tree.publish_tree}' overlaps derived entry " \
40
+ "'#{derived.key}' publish_to '#{rel}'; the tree's prune will delete it on rebuild",
41
+ "fix" => "add a glob covering '#{rel_to_target}' to entry '#{tree.key}' ignore " \
42
+ "(e.g. ignore: [\"**/#{basename}\"])",
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
data/lib/textus/doctor.rb CHANGED
@@ -25,6 +25,7 @@ module Textus
25
25
  Check::HandlerAllowlist,
26
26
  Check::FetchLocks,
27
27
  Check::OrphanedPublishTargets,
28
+ Check::PublishTreeIndexOverlap,
28
29
  Check::ProposalTargets,
29
30
  ].freeze
30
31
 
@@ -44,6 +44,7 @@ module Textus
44
44
  def inject_boot = false # rubocop:disable Naming/PredicateMethod
45
45
  def events = {}
46
46
  def publish_each = nil
47
+ def publish_tree = nil
47
48
  def index_filename = nil
48
49
  def ignore = []
49
50
 
@@ -78,27 +79,18 @@ module Textus
78
79
  end
79
80
  end
80
81
 
81
- # Subclasses override to customize publish behavior.
82
- # Default: copy the stored file to each publish_to target.
83
- # Returns: { kind: :built|:leaves, value: ... } to be accumulated by
84
- # Publish#call, or nil to skip.
85
- def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
86
- return nil if Array(publish_to).empty?
87
-
88
- source_path = pctx.manifest.resolver.resolve(@key).path
89
- envelope = pctx.reader.call(@key)
90
-
91
- publish_to.each do |rel|
92
- target_abs = File.join(pctx.repo_root, rel)
93
- Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
94
- pctx.emit(:file_published,
95
- key: @key,
96
- envelope: envelope,
97
- source: source_path,
98
- target: target_abs)
99
- end
82
+ # ADR 0049: an entry resolves, once, to one Publish::* mode that owns its
83
+ # publish algorithm. A plain entry publishes via ToPaths (publish_to) or
84
+ # None; Nested resolves among the key/path-driven modes. Derived
85
+ # overrides publish_via to materialize first.
86
+ def publish_mode
87
+ @publish_mode ||= Publish.resolve(self)
88
+ end
100
89
 
101
- { kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
90
+ # Returns: { kind: :built|:leaves, value: ... } to be accumulated by
91
+ # Write::Publish, or nil to skip.
92
+ def publish_via(pctx, prefix: nil)
93
+ publish_mode.publish(pctx, prefix: prefix)
102
94
  end
103
95
  end
104
96
  end