Update and refine the implementation roadmap

This commit is contained in:
2026-05-30 20:25:59 -05:00
parent 47c9c5c0d1
commit 39548cefbe
5 changed files with 336 additions and 144 deletions

View File

@@ -396,11 +396,12 @@ Supported fields:
- `on_destination_newer` - `on_destination_newer`
- `on_conflict` - `on_conflict`
Recommended supported values: MVP supported values are intentionally limited by field:
- `skip` - `on_destination_same`: `skip` or `fail`
- `replace` - `on_destination_older`: `replace` or `fail`
- `fail` - `on_destination_newer`: `skip` or `fail`
- `on_conflict`: `fail`
Recommended MVP defaults: Recommended MVP defaults:
@@ -417,6 +418,7 @@ Safety rule:
- `replace` must never perform broad deletion against a destination root. - `replace` must never perform broad deletion against a destination root.
- `replace` may only operate within a resolved destination bundle path and should delete only files recorded in existing `.distributor.json.outputs` plus `.distributor.json` where practical. - `replace` may only operate within a resolved destination bundle path and should delete only files recorded in existing `.distributor.json.outputs` plus `.distributor.json` where practical.
- Unmanaged non-empty destination paths fail in the MVP. Force or unmanaged overwrite configuration is deferred. - Unmanaged non-empty destination paths fail in the MVP. Force or unmanaged overwrite configuration is deferred.
- Broader replacement values, including replacing newer destinations or conflicts, are deferred to a later explicit force-overwrite stage.
## Path Mapping ## Path Mapping
@@ -574,5 +576,5 @@ Future notification policy should require:
The following configuration ideas are intentionally outside the MVP: The following configuration ideas are intentionally outside the MVP:
- warning-only digest mismatch handling; - warning-only digest mismatch handling;
- unmanaged destination overwrite flags; - unmanaged destination overwrite flags until the explicit force-overwrite roadmap stage;
- force replacement of destinations with different source ids. - force replacement of destinations with different source ids until the explicit force-overwrite roadmap stage.

View File

@@ -13,6 +13,8 @@ The MVP contract has two durable files:
`manifest.json` is not copied to the destination as destination state. Instead, `.distributor.json` records the normalized source manifest, generated output metadata, and distributor-owned publication metadata. `manifest.json` is not copied to the destination as destination state. Instead, `.distributor.json` records the normalized source manifest, generated output metadata, and distributor-owned publication metadata.
All manifest and state timestamps should be serialized as RFC3339. Internal comparison should use parsed timestamp values, and distributor-written timestamps should be normalized to RFC3339 UTC.
## Terminology ## Terminology
- **Source root**: Configured root path for a pipeline source. - **Source root**: Configured root path for a pipeline source.
@@ -227,6 +229,12 @@ If no `.distributor.json` exists and the destination path is non-empty:
Local destination paths are empty when the destination bundle directory does not exist or exists with no entries. S3-compatible destination prefixes are empty when no objects exist below the destination bundle prefix, ignoring objects outside that exact prefix. Local destination paths are empty when the destination bundle directory does not exist or exists with no entries. S3-compatible destination prefixes are empty when no objects exist below the destination bundle prefix, ignoring objects outside that exact prefix.
### Pipeline or destination mismatch
If `.distributor.json` exists but its `pipeline_id` or `destination_id` differs from the current pipeline or destination config:
- Fail as a conflict.
### Same source manifest ### Same source manifest
If `.distributor.json` exists and `source.manifest` exactly matches the current normalized source manifest: If `.distributor.json` exists and `source.manifest` exactly matches the current normalized source manifest:

View File

@@ -9,6 +9,7 @@ The roadmap assumes the project includes these planning documents before impleme
- `docs/roadmap/packages.md` - `docs/roadmap/packages.md`
- `docs/roadmap/contracts.md` - `docs/roadmap/contracts.md`
- `docs/roadmap/config.md` - `docs/roadmap/config.md`
- `docs/roadmap/storage.md`
The MVP goal is a domain-agnostic bundle distributor that discovers source bundles, validates `manifest.json`, optionally transforms Markdown to HTML, publishes selected outputs to one or more destinations, and records destination state in `.distributor.json`. The MVP goal is a domain-agnostic bundle distributor that discovers source bundles, validates `manifest.json`, optionally transforms Markdown to HTML, publishes selected outputs to one or more destinations, and records destination state in `.distributor.json`.
@@ -169,16 +170,7 @@ internal/adapters/local/backend.go
internal/storage/fake/ internal/storage/fake/
``` ```
Define backend operations needed by the MVP, including: Implement the storage contract in `docs/roadmap/storage.md`, including backend-rooted logical paths, hybrid byte/stream IO, metadata, traversal, typed errors, managed deletion, and efficient destination emptiness helper behavior.
- read file;
- write file;
- test existence;
- list files or tree entries;
- read destination state file if present;
- create directories/prefixes as needed;
- delete explicit managed files safely;
- write files using temporary/staged writes where practical.
The fake backend should exist for unit tests of config, bundle, state, and publish logic without real local, SSH, or S3 IO. The fake backend should exist for unit tests of config, bundle, state, and publish logic without real local, SSH, or S3 IO.
@@ -197,13 +189,16 @@ The local backend must:
Add tests for: Add tests for:
- backend read/write/list/exists behavior; - path validation;
- safe path joining; - backend byte and stream read/write behavior;
- `Stat`, `Walk`, and materialized deterministic traversal helper behavior;
- traversal rejection; - traversal rejection;
- symlink rejection for source reads; - symlink entry reporting and source-read rejection;
- explicit-file deletion guard behavior; - staged write behavior where testable;
- local destination emptiness detection; - typed storage errors and helper predicates;
- fake backend behavior sufficient for core package tests. - managed deletion guard behavior;
- early-stop destination emptiness helper behavior;
- fake backend parity for core package tests.
### Completion Criteria ### Completion Criteria
@@ -319,6 +314,7 @@ Implement comparison outcomes:
- destination absent; - destination absent;
- destination unmanaged/non-empty; - destination unmanaged/non-empty;
- destination state pipeline or destination id mismatch;
- same source manifest; - same source manifest;
- same source id, destination older; - same source id, destination older;
- same source id, destination newer; - same source id, destination newer;
@@ -339,6 +335,8 @@ Test validation for:
- invalid output metadata; - invalid output metadata;
- malformed published timestamp. - malformed published timestamp.
Timestamps should parse RFC3339 input and distributor-written timestamps should normalize to RFC3339 UTC.
### Completion Criteria ### Completion Criteria
- Destination state can be parsed and validated independently. - Destination state can be parsed and validated independently.
@@ -485,7 +483,7 @@ Add tests for:
- Generated outputs are recorded in destination state. - Generated outputs are recorded in destination state.
- Dry-run reports transform outputs that would be generated. - Dry-run reports transform outputs that would be generated.
## Stage 8: No-Op Notification Stage and Pipeline Orchestration Polish ## Stage 8: No-Op Notification Stage, Pipeline Polish, and Local MVP Checkpoint
### Goal ### Goal
@@ -500,7 +498,7 @@ internal/notify/notify.go
internal/notify/noop.go internal/notify/noop.go
``` ```
Integrate a no-op notifier after successful publication/skip handling as appropriate. Integrate a no-op notifier after actual successful publication or replacement. Skipped destinations should not invoke the notifier in the MVP.
Clarify orchestration behavior when one destination fails. For MVP, fan-out should be deterministic and sequential. Continue planning and reporting later destinations where safe, but return non-zero if any destination fails. Clarify orchestration behavior when one destination fails. For MVP, fan-out should be deterministic and sequential. Continue planning and reporting later destinations where safe, but return non-zero if any destination fails.
@@ -526,9 +524,11 @@ Add tests for:
- The pipeline shape includes notification as an internal no-op stage. - The pipeline shape includes notification as an internal no-op stage.
- Run output is useful for unattended operation logs. - Run output is useful for unattended operation logs.
- Local MVP behavior remains passing. - Local MVP behavior remains passing and is ready for one real local producer pipeline.
## Stage 9: Native SSH/SFTP Backend Stages 1 through 8 define the local MVP checkpoint. Later stages extend the local MVP with remote backends, cross-backend hardening, user-facing documentation sync, and release readiness.
## Stage 9: Native SSH/SFTP Backend Roadmap Extension
### Goal ### Goal
@@ -585,7 +585,7 @@ If practical, add integration tests that can be skipped unless an SSH test endpo
- Normal tests do not depend on a live SSH server. - Normal tests do not depend on a live SSH server.
- At least local-to-SSH and SSH-to-local flows are documented or manually testable. - At least local-to-SSH and SSH-to-local flows are documented or manually testable.
## Stage 10: S3-Compatible Backend ## Stage 10: S3-Compatible Backend Roadmap Extension
### Goal ### Goal
@@ -614,14 +614,15 @@ credentials:
secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY secret_access_key_env: DISTRIBUTOR_S3_SECRET_ACCESS_KEY
``` ```
Implement storage operations over S3 object keys: Implement storage operations over S3 object keys through the common storage contract:
- read object; - `ReadFile` and `OpenReader`;
- write object; - `WriteFile` and `WriteFrom`;
- exists; - `Stat`;
- list prefix; - `Walk` using object-list pagination where available;
- delete managed prefix or listed managed files; - `HasAny` with early stop for destination emptiness checks;
- read/write `.distributor.json`. - `DeleteManagedBundle`;
- read/write `.distributor.json` through normal storage operations.
Set reasonable content types where available: Set reasonable content types where available:
@@ -656,7 +657,7 @@ If practical, add integration tests gated by environment variables or a local S3
- S3 source and destination flows are supported through the common pipeline path. - S3 source and destination flows are supported through the common pipeline path.
- Normal tests do not require live S3. - Normal tests do not require live S3.
## Stage 11: Cross-Backend End-to-End Coverage and Hardening ## Stage 11: Cross-Backend End-to-End Coverage and Hardening Roadmap Extension
### Goal ### Goal
@@ -785,6 +786,61 @@ Do not add new product features in this stage.
- Destructive replacement cannot occur outside managed destination bundle paths. - Destructive replacement cannot occur outside managed destination bundle paths.
- Final docs accurately reflect the application. - Final docs accurately reflect the application.
## Stage 14: Explicit Force Overwrite Roadmap Extension
### Goal
Introduce explicit operator-requested force behavior for controlled overwrite cases that are intentionally outside the local MVP.
### Scope
Add a CLI-only force option such as:
```bash
distributor run --config config.yml --force
```
Define and implement force planning for:
- unmanaged non-empty destination paths;
- destination state with a different source id;
- destination state with matching source id and created timestamp but different digest;
- destination state with mismatched `pipeline_id` or `destination_id`;
- destination newer than source when the transfer policy explicitly allows replacement.
Force behavior must be explicit per run. It should not be a persistent default in config for this stage.
Update transfer policy validation to allow broader values only when force behavior is implemented and documented:
- `on_destination_newer: replace`
- `on_conflict: replace`
### Safety Requirements
- Dry-run must show every file or object that would be written or deleted before a forced run.
- Force must still never delete above the resolved destination bundle path or configured destination prefix.
- Filesystem replacement should remain staged where practical.
- S3 replacement must remain constrained to the destination bundle prefix.
- Logs must clearly mark force decisions and include pipeline id, destination id, bundle id, and reason.
### Tests
Add tests for:
- force rejected when the flag is absent;
- unmanaged non-empty destination overwritten only with force;
- different source id overwritten only with force and allowed policy;
- same id and created but different digest overwritten only with force and allowed policy;
- destination newer replaced only with force and allowed policy;
- pipeline or destination id mismatch overwritten only with force and allowed policy;
- dry-run reports destructive force actions without writing;
- destructive paths remain bounded to the destination bundle path.
### Completion Criteria
- Force overwrite behavior is explicit, logged, dry-runnable, and test-covered.
- Default non-force behavior remains unchanged and conservative.
## Deferred Post-MVP Work ## Deferred Post-MVP Work
The following items are intentionally outside the MVP unless explicitly pulled into a later roadmap: The following items are intentionally outside the MVP unless explicitly pulled into a later roadmap:
@@ -804,5 +860,4 @@ The following items are intentionally outside the MVP unless explicitly pulled i
- concurrent publication workers; - concurrent publication workers;
- symlink support; - symlink support;
- warning-only digest mismatch handling; - warning-only digest mismatch handling;
- force or unmanaged-overwrite destination behavior;
- password-based SSH authentication in YAML. - password-based SSH authentication in YAML.

View File

@@ -148,7 +148,7 @@ for each selected pipeline:
build publish plan build publish plan
transform as required by that destination transform as required by that destination
execute publish plan unless dry-run execute publish plan unless dry-run
run noop notifier run noop notifier after actual publication or replacement
``` ```
Boundaries: Boundaries:
@@ -309,6 +309,7 @@ Comparison rules:
- same source id, newer destination source `created`: skip; - same source id, newer destination source `created`: skip;
- same source id, same `created`, different digest: conflict; - same source id, same `created`, different digest: conflict;
- different source id: conflict; - different source id: conflict;
- pipeline id or destination id mismatch: conflict;
- absent state: publish only if safe; - absent state: publish only if safe;
- unmanaged non-empty path: fail. - unmanaged non-empty path: fail.
@@ -327,22 +328,12 @@ Responsibilities:
- define object/file metadata types; - define object/file metadata types;
- define path/prefix helpers; - define path/prefix helpers;
- define common storage errors; - define common storage errors;
- provide backend registry mechanisms. - provide backend registry mechanisms;
- provide a fake backend for core package tests. - provide a fake backend for core package tests.
The core application should use storage interfaces such as: The detailed storage contract is defined in `docs/roadmap/storage.md`. Core application code should use that storage interface for backend-rooted logical paths, byte and stream IO, metadata, traversal, typed errors, emptiness checks, and managed deletion.
```go Destructive APIs should remain narrow. Prefer managed deletion of files recorded in `.distributor.json` instead of broad recursive deletion.
type Backend interface {
ReadFile(ctx context.Context, path string) ([]byte, error)
WriteFile(ctx context.Context, path string, data []byte, opts WriteOptions) error
Exists(ctx context.Context, path string) (bool, error)
List(ctx context.Context, prefix string) ([]Entry, error)
DeleteFiles(ctx context.Context, paths []string) error
}
```
Destructive APIs should remain narrow. Prefer deleting explicit files recorded in `.distributor.json` instead of broad recursive deletion.
Boundaries: Boundaries:
@@ -505,100 +496,6 @@ Responsibilities:
- ensure logs omit secrets; - ensure logs omit secrets;
- provide consistent fields for pipeline id, bundle id, destination id, backend, path, action, and reason. - provide consistent fields for pipeline id, bundle id, destination id, backend, path, action, and reason.
## Implementation Slices
### Slice 1: Skeleton, config, storage, and source bundle validation
Deliver:
- basic CLI skeleton;
- config loading and validation;
- storage interface, local backend, and fake backend;
- source manifest model;
- source bundle discovery;
- file size and SHA-256 validation;
- bundle digest validation;
- local backend sufficient for validation;
- fixtures for valid and invalid bundles.
Useful commands:
```bash
distributor validate ./examples/weather-bundle
```
### Slice 2: Destination state and dry-run planning
Deliver:
- `.distributor.json` model;
- destination state comparison;
- publish plan model;
- dry-run output;
- local source to local destination planning;
- tests for same/older/newer/conflict/unmanaged cases.
Useful command:
```bash
distributor run --config ./examples/local.yml --dry-run
```
### Slice 3: Local publish execution
Deliver:
- local destination writes;
- source-file publication;
- `.distributor.json` writes;
- replacement safety checks;
- skip behavior;
- narrow deletion behavior based on destination state outputs.
Useful command:
```bash
distributor run --config ./examples/local.yml
```
### Slice 4: Markdown-to-HTML transform
Deliver:
- Markdown transform registry;
- Markdown-to-HTML implementation;
- per-destination `publish.source` and `publish.html` behavior;
- generated output metadata in `.distributor.json`;
- tests for source-only, HTML-only, and source-plus-HTML destinations.
### Slice 5: S3 backend
Deliver:
- S3-compatible backend;
- endpoint/bucket/prefix/region/force-path-style config;
- credential handling via environment or standard mechanisms;
- object listing, read, write, and narrow delete operations;
- dry-run and publish coverage using fake or local-compatible test strategy.
### Slice 6: SSH/SFTP backend
Deliver:
- native SFTP backend;
- `uri` and `path` config;
- documented authentication behavior;
- read/write/list/delete operations;
- adapter tests or documented integration test strategy.
### Slice 7: No-op notification stage and future extension seam
Deliver:
- no-op notifier wired into orchestration;
- clear internal contract for future email/ntfy adapters;
- no user-facing notification behavior beyond no-op unless implemented.
## Deferred Ideas ## Deferred Ideas
The following are intentionally out of MVP unless separately accepted in a later roadmap: The following are intentionally out of MVP unless separately accepted in a later roadmap:

230
docs/roadmap/storage.md Normal file
View File

@@ -0,0 +1,230 @@
# Storage Interface Roadmap
This roadmap defines the planned `internal/storage` contract for the `distributor` MVP. The goal is to give bundle validation, destination state inspection, publish planning, and backend adapters one consistent IO boundary without leaking local filesystem, SSH/SFTP, or S3-specific behavior into core packages.
## Purpose and Invariants
The storage layer is responsible for safe, backend-rooted access to files, objects, prefixes, and destination bundle paths.
Core invariants:
- Backends are opened at configured roots.
- Core packages operate on backend-rooted logical paths, not absolute filesystem paths or raw object keys.
- Backend adapters translate native storage behavior into common storage entries and typed errors.
- Destructive operations remain narrow and managed.
- Staging or atomic write behavior belongs behind the storage interface where practical.
- The fake backend exists for tests only and must not be registered as a runtime backend.
## Logical Path Model
Storage paths are slash-separated logical paths relative to an already configured backend root.
File paths:
- must be non-empty;
- must be relative;
- must be clean;
- must not contain `.` or `..` segments;
- must not start with `/`;
- must not contain backslashes;
- must not resolve outside the backend root.
Prefix paths use the same slash-separated model. A prefix may be empty to represent the backend root for listing and destination emptiness checks.
Backends own conversion from logical paths to native paths or object keys. Core packages should not construct local filesystem paths, SFTP paths, or S3 object keys directly.
## Core Interface Shape
The MVP should use a hybrid byte and stream interface:
```go
type Backend interface {
ReadFile(ctx context.Context, path string) ([]byte, error)
OpenReader(ctx context.Context, path string) (io.ReadCloser, error)
WriteFile(ctx context.Context, path string, data []byte, opts WriteOptions) (Entry, error)
WriteFrom(ctx context.Context, path string, r io.Reader, opts WriteOptions) (Entry, error)
Stat(ctx context.Context, path string) (Entry, error)
Walk(ctx context.Context, prefix string, opts WalkOptions, fn WalkFunc) error
HasAny(ctx context.Context, prefix string) (bool, error)
DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts DeleteOptions) error
}
```
Byte helpers are expected to cover manifests, destination state, small source artifacts, and generated outputs. Stream methods are included from the start for backend flexibility and larger future artifacts.
Write operations should create required parent directories or prefixes as needed.
## Entries and Metadata
Storage metadata should be represented by an `Entry` model with at least:
- backend-relative logical path;
- entry type;
- size, where available.
Entry types:
- `file`: filesystem file or object-storage object;
- `directory`: filesystem directory or logical prefix;
- `symlink`: local filesystem symlink;
- `other`: unknown or unsupported native entry type.
`Stat` returns metadata for one logical path.
`Walk` traverses entries below a prefix and calls a callback for each entry. `WalkOptions` should include:
- whether traversal is recursive;
- an optional entry limit for callers that only need to know whether content exists.
Backends may stream or paginate traversal internally. S3-compatible adapters should not need to load a whole prefix into memory to satisfy traversal.
Raw traversal is not required to be lexically sorted. A helper that materializes walk results for bundle discovery, tests, or CLI output should sort entries lexically by logical path before returning them.
`HasAny` reports whether at least one entry exists below a prefix. It should stop as soon as content is found.
Source validation must reject symlink entries reported by local `Stat` or `Walk`.
## Read Behavior
`ReadFile` reads the whole object into memory and is appropriate for MVP manifest, state, and ordinary artifact handling.
`OpenReader` returns a stream for callers that need to copy or hash content without requiring a second storage-specific API. Callers must close the returned reader.
Both read methods must:
- validate logical paths before backend access;
- reject directories, prefixes, symlinks, and unsupported entries;
- return typed not-found and invalid-path errors where applicable.
## Write Behavior
`WriteOptions` should include:
- content type, when the destination backend can use it;
- overwrite permission;
- atomic or staged write preference.
Backends own staging and atomic behavior where practical:
- Local backend writes to a temporary file in the destination directory and renames or promotes into place.
- SSH/SFTP backend should use a temporary remote file and rename where available.
- S3-compatible backend treats a successful object PUT as publish-on-success and applies content type metadata.
If overwrite is false and the target exists, writes should fail with an already-exists error.
`WriteFile` and `WriteFrom` should return the written `Entry`, including final path and size where available.
`DeleteOptions` should include:
- whether missing managed output paths are ignored;
- whether empty parent directories may be pruned for filesystem-like backends.
## Managed Deletion
The storage interface should expose a guarded managed deletion operation rather than raw recursive delete.
`DeleteManagedBundle(ctx, bundlePath, managedOutputPaths, opts)` may delete only:
- files or objects listed in valid `.distributor.json.outputs`;
- `.distributor.json` at the destination bundle path;
- empty directories created by those files, for filesystem-like backends.
`managedOutputPaths` are relative to the destination bundle path. The backend validates each path and resolves it under `bundlePath`.
If `bundlePath == ""`, deletion may remove explicit managed files at the destination root, but must never delete the root itself.
Prefix or recursive deletion is out of MVP scope. A future force-overwrite stage may add broader behavior, but it must remain explicit and separately documented.
## Destination Emptiness
Destination emptiness should use `HasAny(prefix)` and typed not-found behavior. Callers that only need emptiness must not materialize a full recursive traversal.
Rules:
- A local destination bundle path is empty when the directory does not exist or exists with no entries.
- An S3-compatible prefix is empty when no objects exist below that exact destination bundle prefix.
- Entries outside the exact destination bundle path or prefix do not affect emptiness.
## Error Model
Storage should expose typed error categories with wrapping context. Callers should use helper predicates rather than string matching.
Required categories:
- not found;
- already exists;
- not empty;
- invalid path;
- conflict;
- permission;
- temporary;
- unsupported;
- unknown.
Adapters should translate backend-native errors into these categories while preserving useful operation, backend, path, and cause context.
## Adapter Expectations
### Local
The local backend should:
- constrain all operations beneath the configured root;
- reject traversal and absolute logical paths;
- report symlinks through metadata;
- reject symlink reads for source artifacts;
- use staged writes where practical;
- perform managed deletion only for explicit managed files and `.distributor.json`;
- clean up empty directories created by managed outputs where safe.
### Fake
The fake backend should:
- be in-memory and deterministic;
- implement the same logical path validation rules;
- support `Stat`, `Walk`, `HasAny`, byte reads and writes, stream reads and writes, managed deletion, and destination emptiness helper behavior;
- support configured symlink entries for validation tests;
- be used only by tests.
### SSH/SFTP
The SSH/SFTP backend should:
- use native SFTP operations;
- enforce the same logical path rules as local storage;
- use temporary file plus rename for staged writes where available;
- translate remote errors into storage error categories;
- avoid exposing SSH or SFTP dependency types through `internal/storage`.
### S3-Compatible
The S3-compatible backend should:
- treat prefixes as object trees, not real directories;
- normalize configured prefix plus logical path into object keys;
- use object PUT as publish-on-success;
- set content type from `WriteOptions`;
- implement traversal and emptiness by exact prefix;
- use backend pagination for traversal where available;
- allow `HasAny` to stop after the first matching object;
- constrain managed deletion to listed output objects and `.distributor.json`.
## Tests and Fixtures
Storage implementation stages should test:
- path validation rejects absolute paths, traversal, empty file paths, backslashes, and dot segments;
- `Walk` visits backend-rooted logical paths under a prefix and supports recursive traversal;
- a materializing helper sorts walk results lexically for deterministic tests and CLI output;
- `HasAny` returns quickly for non-empty prefixes without requiring full traversal;
- `ReadFile` and `OpenReader` return equivalent bytes;
- `WriteFile` and `WriteFrom` honor overwrite and content-type options;
- local staged writes do not leave final files on failure where testable;
- managed deletion deletes only state-listed files and `.distributor.json`;
- managed deletion never deletes destination root or unlisted files;
- destination emptiness helper handles missing, empty, and non-empty local paths;
- S3-compatible traversal can use pagination without loading a whole prefix into memory;
- symlink entries are reported and rejected by source validation;
- typed errors are usable through helper predicates;
- fake backend behavior matches local backend semantics relevant to core tests.