Epic: Split sync_cmd.rs into focused modules

Break the 1843-line god module (sync_cmd.rs) into a sync/ directory with focused files: types.rs, diff.rs, checkpoint.rs, throttle.rs, single.rs, batch.rs, mod.rs. Also deduplicates sync_inner/sync_one_alias_inner overlap (~150 lines). No file exceeds 350 lines.

## Child Beads (execution order)
1. **bd-1re** -- Extract types.rs and diff.rs (data types + pure functions, lowest risk)
2. **bd-1yq** -- Extract checkpoint.rs and throttle.rs (self-contained components)
3. **bd-1fe** -- Extract single.rs and batch.rs, dedup sync_inner (final extraction + dedup)

## Prerequisite
All 3 beads depend on bd-2x7 (sync OutputMode conversion) being complete first, so the output formatting is using emit() before the split.


Add semantic search with embeddings

## What
Add semantic/fuzzy search as alternative to text search. Compute embeddings for endpoint descriptions and schema names during index building. Use cosine similarity for ranking.

## Acceptance Criteria
- [ ] --semantic flag enables embedding-based search
- [ ] Results ranked by cosine similarity
- [ ] Fallback to text search when embeddings unavailable

## Files
- CREATE: src/core/embeddings.rs
- MODIFY: src/core/search.rs (add semantic search mode)


Epic: Output Sink Abstraction

Replace robot:bool threading (29+ branches across 12 command handlers) with an OutputMode enum, HumanDisplay trait, and emit() function. Commands produce typed data; output layer decides presentation. Eliminates tangled output concerns from business logic and enables future output formats (YAML) without touching command handlers.

## Child Beads (execution order)
1. **bd-2im** -- Create OutputMode enum, HumanDisplay trait, emit() (foundation)
2. **bd-bq8** -- Convert tags command and main.rs (proof of concept)
3. **bd-2wp** -- Convert show, schemas, search, diff (query commands, parallel)
4. **bd-29k** -- Convert aliases, doctor, cache_cmd (management commands, parallel with bd-2wp)
5. **bd-1nm** -- Convert list command (most complex query command)
6. **bd-71g** -- Convert fetch command (documented exception, signature-only)
7. **bd-2x7** -- Convert sync command (most complex, last conversion)
8. **bd-2x6** -- Clean up remnants, consolidate resolution logic


Epic: Sync and Updates


Wire fetch command with local file, stdin, and remote URL support

## Background
The fetch command is the primary entry point for getting specs into swagger-cli. It orchestrates: HTTP download (or local file/stdin read), format detection, YAML normalization, index building, pointer validation, and crash-consistent cache write. It supports auth headers, bearer tokens, auth profiles, --force overwrite, and robot output.

## Approach
Implement src/cli/fetch.rs with FetchArgs struct and async execute() function:

**FetchArgs (clap derive):**
- url: String (positional, required — can be URL, file path, or "-" for stdin)
- alias: String (--alias, required)
- header: Vec<String> (--header, repeatable)
- auth_header: Vec<String> (--auth-header, alias for --header)
- bearer: Option<String> (--bearer)
- auth_profile: Option<String> (--auth-profile)
- force: bool (--force)
- timeout_ms: u64 (--timeout-ms, default 10000)
- max_bytes: u64 (--max-bytes, default 26214400)
- retries: u32 (--retries, default 2)
- input_format: Option<String> (--input-format, values: auto/json/yaml)
- resolve_external_refs: bool (--resolve-external-refs)
- ref_allow_host: Vec<String> (--ref-allow-host, repeatable)
- ref_max_depth: u32 (--ref-max-depth, default 3)
- ref_max_bytes: u64 (--ref-max-bytes, default 10MB)
- allow_private_host: Vec<String> (--allow-private-host, repeatable)
- allow_insecure_http: bool (--allow-insecure-http)

**Execute flow:**
1. Validate alias format (validate_alias)
2. Check if alias exists — error unless --force
3. Resolve auth (merge --auth-profile with explicit headers, --bearer)
4. Determine source: URL (http/https), local file (file:// or path), or stdin (-)
5. For URL: use AsyncHttpClient.fetch_spec() with SSRF/HTTPS policy
6. For local file: canonicalize path, read bytes directly (no network policy)
7. For stdin: read all bytes from stdin
8. Detect format, normalize to JSON
9. Parse as serde_json::Value
10. Build index (build_index)
11. Write cache (write_cache with all artifacts)
12. Output robot JSON or human success message

## Acceptance Criteria
- [ ] `swagger-cli fetch ./petstore.json --alias pet` succeeds (local file)
- [ ] `swagger-cli fetch https://... --alias pet --robot` outputs JSON with ok:true, data.alias, data.endpoint_count
- [ ] `swagger-cli fetch - --alias stdin-api` reads from stdin
- [ ] Alias validation rejects "../bad" before any network call
- [ ] Existing alias without --force returns ALIAS_EXISTS (exit 6)
- [ ] --force overwrites existing alias
- [ ] --bearer TOKEN adds Authorization: Bearer TOKEN header
- [ ] --auth-profile loads from config.toml
- [ ] Robot output includes: alias, url, version, title, endpoint_count, schema_count, cached_at, source_format, cache_dir, files, content_hash
- [ ] Auth header values never appear in output or error messages

## Files
- MODIFY: src/cli/fetch.rs (FetchArgs, execute, auth resolution, source routing)
- MODIFY: src/output/robot.rs (add output_fetch function)
- MODIFY: src/output/human.rs (add output_fetch function)

## TDD Anchor
RED: Write integration test `test_fetch_local_file` — use assert_cmd to run `swagger-cli fetch tests/fixtures/petstore.json --alias test-pet --robot` with SWAGGER_CLI_HOME set to tempdir. Assert exit 0, stdout JSON has ok:true.
GREEN: Implement full fetch pipeline.
VERIFY: `cargo test test_fetch_local_file`

Additional tests:
- test_fetch_alias_exists_error
- test_fetch_force_overwrites
- test_fetch_stdin
- test_fetch_bearer_auth
- test_fetch_yaml_file (if YAML fixture exists)

## Edge Cases
- stdin ("-") is not a URL — don't try to HTTP-fetch it
- Local file paths must be canonicalized (resolve symlinks, relative paths) before reading
- file:// URLs must be converted to local paths (strip scheme)
- If auth-profile references an EnvVar source, resolve the env var at fetch time
- Robot output: redact --bearer and --auth-header values even in success output

## Dependency Context
Uses AsyncHttpClient from bd-3b6 (async HTTP client with SSRF protection). Uses build_index, detect_format, normalize_to_json from bd-189 (spec format detection and index building). Uses CacheManager.write_cache and validate_alias from bd-1ie (cache write path). Uses Config for auth profiles from bd-1sb (configuration system).


Implement spec format detection, YAML normalization, and index building

## Background
After downloading raw spec bytes, swagger-cli must detect the input format (JSON vs YAML), normalize YAML to JSON, parse the spec as serde_json::Value, and build a SpecIndex from it. The index building is the heart of the query performance -- it pre-extracts endpoints, schemas, and tags into a compact, sorted structure with JSON pointers back to raw.json. All pointers must be validated.

## Approach
Create src/core/indexer.rs with:

**Format detection:** `detect_format(bytes, filename_hint, content_type_hint) -> Format` where Format is Json or Yaml. Priority: explicit --input-format flag > Content-Type header > file extension > content sniffing (try JSON parse first, fall back to YAML).

**YAML normalization:** `normalize_to_json(bytes, format) -> Result<Vec<u8>>` -- if YAML, parse with serde_yaml then serialize to serde_json. If JSON, pass through (validate it parses).

**Index building:** `build_index(raw_json: &serde_json::Value, content_hash: &str, generation: u64) -> Result<SpecIndex>`:
1. Extract info.title, info.version, openapi version
2. Iterate paths.* -> methods -> build IndexedEndpoint with: path, method (uppercased), summary, description, operation_id, tags, deprecated, parameters (name/location/required/desc), request_body_required, request_body_content_types, security_schemes (effective: operation-level overrides root-level), security_required, operation_ptr (JSON pointer format: /paths/~1pet~1{petId}/get)
3. Iterate components.schemas.* -> build IndexedSchema with name, schema_ptr
4. Compute tags from endpoints + root-level tags -> IndexedTag with endpoint_count
5. Sort deterministically: endpoints by (path ASC, method_rank ASC), schemas by (name ASC), tags by (name ASC)
6. Validate ALL operation_ptr and schema_ptr resolve in the raw Value
7. Set index_version to current version constant (1)

**Canonical method ranking:** GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, OPTIONS=5, HEAD=6, TRACE=7, unknown=99.

**JSON pointer encoding:** Path segments use RFC 6901: `~` -> `~0`, `/` -> `~1`. So `/pet/{petId}` becomes `/paths/~1pet~1{petId}`.

## Acceptance Criteria
- [ ] JSON input detected and passed through correctly
- [ ] YAML input detected and normalized to equivalent JSON
- [ ] build_index extracts correct endpoint count from petstore spec (19 endpoints)
- [ ] Endpoints sorted by path ASC, method_rank ASC (deterministic)
- [ ] Schemas sorted by name ASC
- [ ] Tags have correct endpoint_count
- [ ] All operation_ptr values resolve in raw Value (validated during build_index)
- [ ] Invalid pointer causes fetch failure (not silent corruption)
- [ ] JSON pointer encoding handles /pet/{petId} correctly (escapes /)
- [ ] Security inheritance: operation without security inherits root; operation with empty [] means no auth

## Files
- CREATE: src/core/indexer.rs (detect_format, normalize_to_json, build_index, method_rank, json_pointer_encode, validate_pointers)
- MODIFY: src/core/mod.rs (pub mod indexer;)

## TDD Anchor
RED: Write `test_build_index_petstore` -- load tests/fixtures/petstore.json as Value, call build_index, assert endpoint_count == 19 and endpoints are sorted.
GREEN: Implement full index building.
VERIFY: `cargo test test_build_index_petstore`

Additional tests:
- test_detect_format_json, test_detect_format_yaml
- test_yaml_normalization_roundtrip
- test_json_pointer_encoding
- test_method_rank_ordering
- test_security_inheritance
- test_invalid_pointer_rejected

## Edge Cases
- Some specs have paths with special chars in operation IDs -- don't assume alphanumeric
- serde_yaml may produce different JSON than direct JSON parse (number types, null handling) -- normalize consistently
- Large specs (8MB+ GitHub) should still build index in <1s
- OpenAPI 3.1 may use `webhooks` key -- ignore for MVP (only extract from `paths`)
- Tags defined at root level but not used by any operation should still appear with endpoint_count=0

## Dependency Context
Uses SpecIndex, IndexedEndpoint, IndexedSchema, IndexedTag, IndexedParam types from bd-ilo (error types + core models bead).

Format detection, YAML normalization, index building with pointer validation


Detect HTML responses and suggest correct OpenAPI spec URL

When fetching a URL like /api/docs that returns an HTML page (e.g. Swagger UI), the tool falls through format detection to YAML parsing, producing a cryptic error: 'YAML parse error: mapping values are not allowed in this context'. Should detect text/html Content-Type or HTML content patterns early and return a helpful error like: 'URL returned an HTML page, not an API spec. FastAPI serves specs at /openapi.json -- did you mean that?' Context: Content-Type is already extracted in http.rs read_response(). Detection can happen in normalize_to_json() or earlier in the fetch pipeline before format sniffing.


Implement fetch-time external ref bundling

## Background
Optional fetch-time external ref bundling. When --resolve-external-refs is passed during fetch, external $ref targets are fetched and inlined into raw.json. Requires explicit --ref-allow-host allowlist. Bounded by --ref-max-depth and --ref-max-bytes.

## Approach
After fetching the main spec and parsing as Value:
1. Walk the Value tree looking for $ref values that don't start with "#/"
2. For each external ref: parse URL, check host against --ref-allow-host allowlist (reject if not listed)
3. Fetch external ref content (uses AsyncHttpClient, respects SSRF policy)
4. Track total bytes fetched (abort at --ref-max-bytes)
5. Track resolution depth (abort at --ref-max-depth for ref chains)
6. Replace the $ref object with the fetched content
7. Store bundled result as raw.json; original (with $ref pointers) as raw.source

## Acceptance Criteria
- [ ] --resolve-external-refs without --ref-allow-host returns USAGE_ERROR
- [ ] External refs to allowed hosts are fetched and inlined
- [ ] External refs to disallowed hosts are rejected with PolicyBlocked
- [ ] --ref-max-depth limits chain resolution
- [ ] --ref-max-bytes limits total fetched content
- [ ] Bundled raw.json has all external refs resolved; raw.source preserves originals

## Edge Cases
- **Circular external refs:** A.yaml $refs B.yaml which $refs A.yaml. Must detect and break cycles (return error or annotate).
- **Relative refs:** `$ref: "./schemas/Pet.yaml"` must resolve relative to the base spec URL, not CWD.
- **Mixed internal + external refs:** Only resolve external refs (not starting with #/). Internal refs stay as-is.
- **External ref returns non-JSON/YAML:** Return InvalidSpec error for that ref, don't fail the entire fetch.
- **--ref-max-bytes exceeded:** Abort cleanly and report how many refs were resolved before the limit.

## Files
- CREATE: src/core/external_refs.rs (resolve_external_refs, walk_refs, fetch_ref)
- MODIFY: src/cli/fetch.rs (integrate external ref resolution after main fetch)
- MODIFY: src/core/mod.rs (pub mod external_refs;)

## TDD Anchor
RED: Write `test_external_ref_resolution` — create a spec with external $ref, mock the external URL, run fetch --resolve-external-refs --ref-allow-host localhost, verify ref is inlined.
GREEN: Implement ref walking and fetching.
VERIFY: `cargo test test_external_ref_resolution`

## Dependency Context
Uses AsyncHttpClient from bd-3b6. Extends fetch pipeline from bd-16o.


Implement diff command with structural comparison and CI gates

## Background
The diff command compares two spec states structurally. It reports added, removed, and modified endpoints and schemas by comparing normalized indexes. Supports alias-vs-alias, alias-vs-URL, and --fail-on breaking for CI gates.

## Approach
Implement src/cli/diff.rs with DiffArgs and async execute():

**DiffArgs:** left (String, positional), right (String, positional), fail_on (Option<String> — "breaking"), details (bool).

**Source resolution:** LEFT and RIGHT can be alias names or URLs. If URL, fetch into a temp location, build index, use for comparison (don't persist to cache).

**Diff computation (reuse from sync):**
- Compare endpoint sets by (path, method): added = in right not left, removed = in left not right, modified = in both but different (compare summary, tags, deprecated, parameters, security)
- Compare schema sets by name: added, removed, modified (compare schema_ptr target content hashes? or just name-based for Phase 2)
- Summary: total_changes, has_breaking (Phase 2: just structural; Phase 3: semantic classification)

**--fail-on breaking:** If breaking changes detected, exit non-zero (exit code 17). Phase 2 heuristic: removed endpoint = breaking, removed required parameter = breaking. Added endpoint/schema = non-breaking.

## Acceptance Criteria
- [ ] `diff alias1 alias2 --robot` reports structural changes
- [ ] Added/removed/modified endpoints correctly identified
- [ ] --fail-on breaking exits non-zero when breaking changes exist
- [ ] URL as right-side source works (temp fetch, not persisted)
- [ ] Robot output: left, right, changes.endpoints, changes.schemas, summary

## Edge Cases
- **Same spec on both sides:** Return ok:true with changed:false, empty change lists. Not an error.
- **URL-as-source fails to fetch:** Return Network error, not a diff-specific error.
- **Breaking change heuristic false positives:** Endpoint renamed (removed old + added new) looks like a breaking removal. Phase 2 is heuristic-only — document this limitation.
- **Very large diff (thousands of changes):** Apply same 200-item cap as sync --details with truncated flag.
- **Schema comparison:** Phase 2 is name-based only (added/removed by name). Modified schema detection is Phase 3 (requires deep structural comparison).

## Files
- MODIFY: src/cli/diff.rs (DiffArgs, execute, compute_diff, classify_breaking)
- MODIFY: src/output/robot.rs (add output_diff)
- MODIFY: src/output/human.rs (add output_diff)

## TDD Anchor
RED: Write `test_diff_detects_added_endpoint` — fetch petstore twice (one modified), run diff, assert added endpoint appears.
GREEN: Implement index comparison.
VERIFY: `cargo test test_diff_detects_added`

## Dependency Context
Reuses index diff logic from sync command (bd-3f4). Uses AsyncHttpClient for URL-as-source (bd-3b6). Uses indexer for building temp index from URL (bd-189).

Diff command implemented with structural comparison, CI gates, and 9 unit tests


Implement global network policy (offline/auto/online-only)

## Background
The --network global flag controls whether swagger-cli makes network calls. "auto" (default) allows network when needed. "offline" blocks all network calls (fetch/sync fail with OFFLINE_MODE). "online-only" flags when network would be needed but is not available. This is critical for CI reproducibility and agent sandboxing.

## Approach

### NetworkPolicy Enum (src/core/network.rs)
Create `NetworkPolicy` enum with three variants:
- **Auto** (default): Allow network calls when needed, no restrictions. Standard behavior.
- **Offline**: Block ALL outbound network calls preemptively. Any command that would make a network request (fetch, sync) returns `SwaggerCliError::OfflineMode` immediately without attempting the call. Commands that are index-only (list, search, show, tags) work normally.
- **OnlineOnly**: Allow network calls but surface a clear, distinct error if a network call would be needed AND DNS resolution fails or the host is unreachable. The error is different from OfflineMode — it indicates "you requested online-only mode but network is unavailable" rather than "network calls are blocked by policy."

### Implementation
1. Parse --network flag in CLI (already in Cli struct from skeleton bead)
2. Create `NetworkPolicy` enum and `check_network_policy()` function in `src/core/network.rs`
3. Check policy before any HTTP call in AsyncHttpClient — if Offline, return `SwaggerCliError::OfflineMode` without making the request
4. For OnlineOnly: attempt the request, but if it fails due to network issues, return a specific `SwaggerCliError::NetworkUnavailable` (different exit code or error code than OfflineMode)
5. `SWAGGER_CLI_NETWORK` env var as alternative to --network flag (flag takes precedence)

### Scope boundaries
- **Local file sources (file:// paths, stdin):** Network policy does NOT apply. These are local I/O operations. `fetch ./local-spec.yaml --network offline` should succeed because no network call is made.
- **Stdin sources:** Same as local files — no network needed, policy irrelevant.
- **sync --dry-run in offline mode:** ALLOWED. Dry-run compares cached state without actually fetching, so no network call is needed. Returns cached state comparison only.

## Acceptance Criteria
- [ ] NetworkPolicy enum with Auto, Offline, OnlineOnly variants in src/core/network.rs
- [ ] --network offline causes fetch (remote URL) to fail with OFFLINE_MODE error (exit 15)
- [ ] --network offline allows fetch from local file path (no network needed)
- [ ] --network offline allows fetch from stdin (no network needed)
- [ ] --network offline allows list/search/show/tags (index-only, no network needed)
- [ ] --network offline allows sync --dry-run (cached comparison only, no network)
- [ ] --network online-only surfaces clear error when network is unavailable (distinct from OFFLINE_MODE)
- [ ] --network auto allows all commands (default behavior, no restrictions)
- [ ] SWAGGER_CLI_NETWORK=offline env var works same as --network offline flag
- [ ] Flag takes precedence over env var when both are set
- [ ] Robot error for offline mode has code OFFLINE_MODE with suggestion to remove --network flag or unset env var
- [ ] Robot error for online-only network failure has code NETWORK_UNAVAILABLE with distinct suggestion

## Edge Cases
- **sync --dry-run in offline mode:** Allowed — returns cached state comparison only, no actual fetch happens. This is a read-only operation on cached data.
- **Local file + offline mode:** Allowed — `fetch ./spec.yaml --network offline` succeeds because it is local I/O, not a network call.
- **OnlineOnly vs Offline distinction:** Offline blocks proactively (never attempts the call). OnlineOnly attempts the call and fails with a specific error if the network is down. This matters for agents that want to know "was this blocked by policy or by actual network unavailability?"
- **Mixed sources:** If a future version supports specs that reference remote $ref URLs but the base spec is local, the network policy should apply to the remote $ref resolution (not the local file read).

## Files
- CREATE: src/core/network.rs (NetworkPolicy enum, check_network_policy function)
- MODIFY: src/core/http.rs (check policy before fetch)
- MODIFY: src/cli/fetch.rs (check policy at start, skip for local/stdin sources)
- MODIFY: src/cli/sync.rs (check policy at start, allow --dry-run in offline)

## TDD Anchor
RED: Write `test_offline_blocks_fetch` — set SWAGGER_CLI_NETWORK=offline, run fetch with remote URL, assert exit 15 and OFFLINE_MODE error.
RED: Write `test_offline_allows_local_file` — set offline, fetch local file, assert success.
RED: Write `test_offline_allows_list` — set offline, run list on cached alias, assert success.
RED: Write `test_online_only_network_unavailable` — set online-only, mock DNS failure, assert NETWORK_UNAVAILABLE error.
GREEN: Implement policy check.
VERIFY: `cargo test test_offline_blocks_fetch`

## Dependency Context
Modifies AsyncHttpClient in bd-3b6 (async HTTP client) to check network policy before requests. Uses SwaggerCliError variants (OfflineMode) from bd-ilo (error types and core data models).

Implemented NetworkPolicy enum (Auto/Offline/OnlineOnly), resolve_policy with CLI flag > env var precedence, check_remote_fetch enforcement. Integrated into AsyncHttpClient and fetch command.


Implement cache lifecycle command with stats, prune, and LRU eviction

## Background
The cache command manages cache growth with stats, pruning, and LRU eviction. Separate from doctor (which validates health). Uses coalesced last_accessed timestamps for LRU ordering.

## Approach
Implement src/cli/cache.rs with CacheArgs and execute():

**CacheArgs:** stats (bool, default action), prune_stale (bool), prune_threshold (u32, default 90 days), max_total_mb (Option<u64>), dry_run (bool).

**Operations:**
- Stats: list_aliases with size computation, show per-alias and total bytes
- Prune: find aliases older than threshold, delete (or dry-run report)
- LRU eviction: sort by last_accessed ASC, delete oldest until total < max_total_mb (or dry-run)

## Acceptance Criteria
- [ ] cache --stats shows per-alias sizes and total
- [ ] cache --prune-stale deletes aliases >90 days old
- [ ] cache --prune-threshold 30 overrides default
- [ ] cache --max-total-mb 500 evicts oldest-accessed aliases
- [ ] --dry-run shows what would be pruned without deleting
- [ ] Robot output: aliases[], total_bytes, pruned[], evicted[]

## Edge Cases
- **No aliases cached:** cache --stats returns ok:true with total_bytes:0, empty aliases array.
- **Concurrent prune + fetch:** If a fetch writes a new alias while prune is deleting, the new alias should not be affected. Prune operates on snapshot of alias list taken at start.
- **last_accessed coalescing:** LRU ordering uses coalesced last_accessed (10-min granularity). Hot-read aliases within the same 10-min window have identical last_accessed — tie-break by fetched_at.
- **--max-total-mb smaller than single largest alias:** Evict everything except the largest, then warn that target cannot be reached.
- **Alias being synced during prune:** Skip locked aliases with warning.

## Files
- MODIFY: src/cli/cache.rs (CacheArgs, execute, prune, evict)
- MODIFY: src/output/robot.rs (add output_cache)
- MODIFY: src/output/human.rs (add output_cache)

## TDD Anchor
RED: Write `test_cache_prune_stale` — create alias with old fetched_at, run cache --prune-stale --robot, assert alias appears in pruned[].
GREEN: Implement stale detection and pruning.
VERIFY: `cargo test test_cache_prune_stale`

## Dependency Context
Uses CacheManager (list_aliases, delete_alias) from bd-3ea. Uses is_stale from CacheMetadata.


Implement show command with ref expansion and pointer navigation

## Background
The show command displays full details for a specific endpoint. Unlike list/search, show MUST load raw.json to extract the complete operation subtree (parameters with schemas, request body, response schemas, etc.). It uses the operation_ptr from the index to locate the exact JSON node in raw.json, avoiding full-spec parsing. Optional --expand-refs resolves internal $ref pointers with cycle detection.

## Approach
Implement src/cli/show.rs with ShowArgs and execute():

**ShowArgs:** alias (Option<String>), path (String, positional), method (Option<String>), format (String, default "pretty"), expand_refs (bool), max_depth (u32, default 3).

**Execute flow:**
1. Resolve alias, load_index
2. Find matching endpoint(s) by path in index
3. If multiple methods and --method not specified → USAGE_ERROR listing available methods
4. Get operation_ptr from matched IndexedEndpoint
5. CacheManager::load_raw(alias, &meta) — loads raw.json as Value, validates raw_hash
6. Navigate to operation subtree using JSON pointer (operation_ptr)
7. If --expand-refs: recursively resolve internal $ref pointers (starting with #/) with cycle detection (max_depth). External refs get annotated as {"$external_ref": "..."}. Circular refs get {"$circular_ref": "..."}. Add warnings to meta.warnings[].
8. Output robot JSON or human formatted details

**JSON Pointer navigation:** Parse `/paths/~1pet~1{petId}/get` → navigate Value tree. `~1` → `/`, `~0` → `~`.

**Ref expansion:** Walk the Value tree. When encountering `{"$ref": "#/components/schemas/Pet"}`, resolve by navigating the pointer in raw Value. Track visited refs for cycle detection. Stop at max_depth.

## Acceptance Criteria
- [ ] `swagger-cli show petstore "/pet/{petId}" --robot` returns full operation details
- [ ] Multiple methods without --method returns USAGE_ERROR with available methods
- [ ] --method POST selects specific method
- [ ] operation_ptr correctly navigates to the right subtree in raw.json
- [ ] --expand-refs resolves internal refs up to max_depth
- [ ] Circular refs produce $circular_ref annotation (not infinite loop)
- [ ] External refs produce $external_ref annotation + warning
- [ ] raw_hash mismatch returns CacheIntegrity error
- [ ] Robot output includes: path, method, summary, description, tags, operation_id, parameters, request_body, responses, security

## Files
- MODIFY: src/cli/show.rs (ShowArgs, execute, pointer navigation, ref expansion)
- CREATE: src/core/refs.rs (expand_refs, resolve_pointer, cycle detection)
- MODIFY: src/core/mod.rs (pub mod refs;)
- MODIFY: src/output/robot.rs (add output_show)
- MODIFY: src/output/human.rs (add output_show)

## TDD Anchor
RED: Write `test_show_endpoint_details` — fetch petstore fixture, run show "/pet/{petId}" --method GET --robot, parse JSON, assert data.path == "/pet/{petId}" and data.method == "GET".
GREEN: Implement pointer navigation and output.
VERIFY: `cargo test test_show_endpoint_details`

Additional tests:
- test_show_multiple_methods_error
- test_expand_refs_basic
- test_expand_refs_circular_detection
- test_expand_refs_external_annotation

## Edge Cases
- JSON pointer decoding: `~1` → `/`, `~0` → `~` (order matters: decode ~1 first, then ~0)
- Path matching should be exact (not regex) — "/pet/{petId}" must match literally
- Some operations may have no request body, no parameters, or no security — handle None gracefully
- ref expansion must handle refs-to-refs (transitive resolution)

## Dependency Context
Uses CacheManager.load_index and load_raw from bd-3ea (cache read). Uses index types from bd-ilo. Requires a fetched spec in cache.


Extract sync single.rs and batch.rs, deduplicate sync_inner, finalize mod.rs

## Background
Final extraction step. Move single-alias sync logic and batch sync logic into dedicated files. Also deduplicate sync_inner() and sync_one_alias_inner() which share ~150 lines of identical HTTP client setup + conditional fetch + normalize + diff + write logic.

## Approach
1. Create src/cli/sync/single.rs containing:
   - sync_one_alias() (line 351) -- the reusable single-alias sync
   - sync_one_alias_inner() (line 396) -- the core implementation
   - DELETE sync_inner() (lines 557-700) -- currently duplicates sync_one_alias_inner
   - The execute() entry point for single-alias mode calls sync_one_alias() directly instead of sync_inner()
   - HumanDisplay impls for SyncOutput (from Epic 1 conversion) stay with the output type or move here
2. Create src/cli/sync/batch.rs containing:
   - sync_all_inner() (line 786) -- the concurrent stream-based batch sync
   - All Arc/AtomicUsize/stream::iter logic
   - Uses checkpoint::{load,save,remove}_checkpoint and throttle::PerHostThrottle from sibling modules
3. Finalize src/cli/sync/mod.rs:
   - Args struct (lines 27-70) stays here
   - pub fn execute() (line 1073) stays here as the entry point
   - Re-exports: pub use types::*, pub(crate) use diff::compute_diff, etc.
4. Delete src/cli/sync_cmd.rs (now fully replaced by sync/ directory)

Deduplication strategy for sync_inner/sync_one_alias_inner:
- sync_inner() currently: loads meta, builds HTTP client, does conditional fetch, normalizes, diffs, writes cache, outputs
- sync_one_alias_inner() does the same thing with slightly different parameter threading
- Collapse: execute(single mode) calls sync_one_alias() with args extracted, then formats output from AliasSyncResult
- This eliminates ~150 lines of duplicate code

## Acceptance Criteria
- src/cli/sync/single.rs contains sync_one_alias and sync_one_alias_inner (~250 lines max)
- src/cli/sync/batch.rs contains sync_all_inner (~300 lines max)
- sync_inner() function is DELETED (deduped into sync_one_alias)
- No file in src/cli/sync/ exceeds 350 lines
- Old sync_cmd.rs is deleted
- All integration tests pass (sync single and sync --all)
- All unit tests pass (diff tests, checkpoint tests, etc.)
- cargo clippy --all-targets -- -D warnings passes
- cargo fmt --check passes

## Files
- CREATE: src/cli/sync/single.rs
- CREATE: src/cli/sync/batch.rs
- MODIFY: src/cli/sync/mod.rs (Args, execute, re-exports)
- DELETE: src/cli/sync_cmd.rs (must ask user permission per AGENTS.md rule)

## TDD Anchor
RED: test_single_sync_uses_sync_one_alias -- verify that single-mode execute() produces an AliasSyncResult (not a SyncOutput directly), confirming the dedup worked
GREEN: Refactor execute(single) to call sync_one_alias then format
VERIFY: cargo test sync

## Edge Cases
- sync_inner() output functions (output_no_changes, output_changes) were deleted in Epic 1 -- confirm they're gone
- batch.rs captures many Arc-wrapped values in async closures -- ensure all moved values are Clone
- PerHostThrottle is passed as Option<&PerHostThrottle> to sync_one_alias -- keep this signature
- The #[allow(clippy::too_many_arguments)] on sync_one_alias_inner may need updating after dedup

## Dependency Context
Depends on bd-1re (types/diff extracted) and bd-1yq (checkpoint/throttle extracted). Uses types from sync::types and diff from sync::diff.


Implement cache write path with crash-consistent protocol and alias validation

## Background
The cache write path is the most safety-critical code in swagger-cli. It implements a crash-consistent multi-file commit protocol: raw.source -> raw.json -> index.json are written as .tmp files with fsync, renamed atomically, then meta.json is written LAST as the commit marker. Per-alias file locking prevents concurrent write corruption. Alias names are validated against a strict regex to prevent path traversal.

## Approach
**Alias validation:** Implement `validate_alias()` that checks against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, rejects path separators (/ \), `..`, reserved device names (CON, PRN, NUL, AUX, COM1-9, LPT1-9 case-insensitive), and leading dots. Return SwaggerCliError::Usage on failure.

**Cache directory management:** Implement `CacheManager` struct with `new()` (uses Config::cache_dir()), `new_with_path()` (for tests), `alias_dir()`, `ensure_dirs()`. Cache layout: `{cache_dir}/aliases/{alias}/`.

**File locking:** Use `fs2::FileExt::try_lock_exclusive()` on `{alias_dir}/.lock` with a bounded timeout (default 1000ms, poll every 50ms). Return SwaggerCliError::CacheLocked on timeout.

**Hash computation:** Implement `compute_hash(bytes: &[u8]) -> String` returning `"sha256:{hex}"` using sha2 crate.

**Crash-consistent write:** Implement `CacheManager::write_cache()` that:
1. Acquires exclusive lock on .lock (bounded timeout)
2. Computes content_hash, raw_hash, next generation, index_hash
3. Writes raw.source.tmp, raw.json.tmp, index.json.tmp (each with sync_all before rename)
4. Renames each .tmp to final name (sync_all after each rename)
5. Writes meta.json.tmp LAST (commit marker)
6. Renames meta.json.tmp -> meta.json (sync_all)
7. Best-effort fsync on alias directory fd (Unix only)
8. Releases lock

**Parameters for write_cache:** Takes alias, raw_source_bytes, raw_json_bytes, index (SpecIndex), url, spec_version, spec_title, source_format, etag, last_modified, previous_generation (Option<u64>).

## Acceptance Criteria
- [ ] validate_alias("petstore") -> Ok
- [ ] validate_alias("../etc/passwd") -> Err(Usage)
- [ ] validate_alias("CON") -> Err(Usage) (case-insensitive)
- [ ] validate_alias(".hidden") -> Err(Usage)
- [ ] validate_alias("a".repeat(65)) -> Err(Usage)
- [ ] write_cache creates all 4 files + .lock in correct directory
- [ ] meta.json is the last file written (verified by checking file mtimes or write order)
- [ ] compute_hash produces deterministic sha256:hex output
- [ ] Lock timeout returns CacheLocked error (not hang)
- [ ] Hash values are deterministic (same input -> same sha256 output)
- [ ] Files survive process kill between steps (no partial meta.json)

## Files
- CREATE: src/core/cache.rs (CacheManager, validate_alias, compute_hash, write_cache, lock helpers)
- MODIFY: src/core/mod.rs (pub mod cache;)

## TDD Anchor
RED: Write `test_validate_alias_rejects_traversal` -- assert validate_alias("../etc") returns Err with Usage variant.
GREEN: Implement regex validation + blocklist checks.
VERIFY: `cargo test test_validate_alias`

Additional tests:
- test_validate_alias_accepts_valid (petstore, my-api, v1.0, API_2)
- test_validate_alias_rejects_reserved (CON, con, NUL, COM1)
- test_write_cache_creates_all_files
- test_compute_hash_deterministic

## Edge Cases
- On macOS, fsync on directory fd may not be supported -- handle gracefully (best-effort)
- Lock file must be created if it doesn't exist (open with create flag)
- If alias directory doesn't exist, create it before acquiring lock
- sync_all() is critical -- without it, data may be in OS page cache but not on disk after rename
- Generation starts at 1 for new aliases, increments from previous for updates

## Dependency Context
Uses SpecIndex and CacheMetadata types from bd-ilo (error types and core data models). Uses SwaggerCliError variants (CacheLocked, Cache, Usage) from bd-ilo.

CacheManager with validate_alias, compute_hash, crash-consistent write_cache


Create CommandContext struct

## Background
Every command handler independently captures Instant::now(), creates CacheManager/AsyncCache, resolves network policy, and loads config. This is ~15-20 lines of identical scaffolding per command. CommandContext encapsulates this common setup.

## Approach
1. Create src/cli/context.rs with:
   ```rust
   use std::path::{Path, PathBuf};
   use std::time::{Duration, Instant};
   use crate::core::cache::AsyncCache;
   use crate::core::config::cache_dir;
   use crate::core::network::{NetworkPolicy, resolve_policy};
   use crate::errors::SwaggerCliError;
   use crate::output::OutputMode;

   pub struct CommandContext {
       pub cache: AsyncCache,
       pub mode: OutputMode,
       pub network_policy: NetworkPolicy,
       pub config_path: Option<PathBuf>,
       start: Instant,
   }

   impl CommandContext {
       pub fn new(
           mode: OutputMode,
           network_flag: &str,
           config_override: Option<&Path>,
       ) -> Result<Self, SwaggerCliError> {
           Ok(Self {
               cache: AsyncCache::new(cache_dir()),
               mode,
               network_policy: resolve_policy(network_flag)?,
               config_path: config_override.map(PathBuf::from),
               start: Instant::now(),
           })
       }

       pub fn elapsed(&self) -> Duration {
           self.start.elapsed()
       }
   }
   ```
2. Update src/cli/mod.rs to add: pub mod context;
3. Update main.rs to create CommandContext once and pass to dispatch:
   ```rust
   let ctx = CommandContext::new(mode, cli.network.as_str(), cli.config.as_deref())?;
   match &cli.command {
       Commands::Tags(args) => tags::execute(args, &ctx).await,
       // ...
   }
   ```
4. Convert tags.rs first as proof of concept:
   - Change execute(args, mode) to execute(args, ctx: &CommandContext)
   - Replace AsyncCache::new(cache_dir()) with ctx.cache
   - Replace Instant::now() with ctx.start (use ctx.elapsed())
   - Replace mode with ctx.mode

## Acceptance Criteria
- CommandContext struct in src/cli/context.rs with cache, mode, network_policy, config_path, start fields
- CommandContext::new() creates all shared state
- ctx.elapsed() returns duration since construction
- main.rs creates CommandContext and passes to tags (proof of concept)
- tags.rs uses &CommandContext instead of individual parameters
- All tests pass
- cargo clippy --all-targets -- -D warnings passes

## Files
- CREATE: src/cli/context.rs
- MODIFY: src/cli/mod.rs (add pub mod context)
- MODIFY: src/main.rs (create CommandContext, pass to tags)
- MODIFY: src/cli/tags.rs (accept &CommandContext)

## TDD Anchor
RED: test_command_context_creates_with_auto_network -- CommandContext::new(Human, "auto", None) succeeds, network_policy is Auto
GREEN: Implement CommandContext::new()
VERIFY: cargo test context

Additional tests:
- test_command_context_offline_policy
- test_command_context_elapsed_increases (sleep 10ms, assert elapsed >= 10ms)
- test_command_context_invalid_network_flag (should error)

## Edge Cases
- robot-docs command is sync and doesnt use cache -- it still receives &CommandContext but ignores cache field
- CommandContext::new() can fail on invalid network flag -- error before any command runs
- config_override as None means use default config path -- cache_dir() handles this

## Dependency Context
Depends on bd-2im (OutputMode) and bd-39e (AsyncCache). Uses both types in CommandContext struct.


Implement sync --all with async concurrency and resumable execution

## Background
sync --all synchronizes all aliases concurrently. It uses bounded async concurrency (--jobs, default 4) with per-host throttling (--per-host, default 2) to avoid abusive request patterns. Supports resumable execution via checkpoint files and a failure budget (--max-failures) to limit blast radius.

## Approach
Build on the single-alias sync from the previous bead:

**Async concurrency:** Use tokio::sync::Semaphore for global concurrency (--jobs). Use a per-host semaphore map (HashMap<String, Semaphore>) for --per-host throttling. Process aliases via futures::stream::StreamExt::buffer_unordered.

**Resumable sync (--resume):** Write a checkpoint file ({cache_dir}/sync-checkpoint.json) after each alias completes. Contains: aliases_completed, aliases_failed, started_at. On --resume, skip already-completed aliases.

**Failure budget (--max-failures):** Track failure count. When exceeded, abort remaining aliases and report partial results.

**Retry-After:** Honor response header (seconds or HTTP-date format). Use exponential backoff + jitter when header absent.

**Per-alias output:** Collect results from all aliases. Report per-alias success/failure in robot output. Don't abort on single-alias failure (unless failure budget exceeded).

## Acceptance Criteria
- [ ] sync --all processes all aliases concurrently
- [ ] --jobs limits concurrent syncs (verified: never more than N in-flight)
- [ ] --per-host limits requests to same host
- [ ] --resume skips already-completed aliases
- [ ] --max-failures aborts after N failures
- [ ] Per-alias success/failure reported in robot output
- [ ] Retry-After header honored

## Edge Cases
- **Empty aliases (no specs cached):** sync --all with no aliases should succeed with empty results, not error.
- **All aliases fail:** If every alias fails sync, the overall command should still exit 0 with per-alias failure details (unless --max-failures triggered, then exit non-zero).
- **Checkpoint file corruption:** If sync-checkpoint.json is malformed, delete it and start fresh (don't error).
- **Per-host semaphore with many aliases to same host:** 10 aliases all pointing to api.example.com with --per-host 2 means only 2 concurrent to that host, even if --jobs allows more.
- **Retry-After as HTTP-date:** Parse both formats: seconds (e.g., "120") and HTTP-date (e.g., "Thu, 01 Jan 2026 00:00:00 GMT").

## Files
- MODIFY: src/cli/sync.rs (add sync_all, concurrency control, checkpoint, failure budget)

## TDD Anchor
RED: Write `test_sync_all_concurrent` — set up 4 aliases, run sync --all --jobs 2 --robot, verify all 4 synced and output includes per-alias results.
GREEN: Implement concurrent sync with semaphore.
VERIFY: `cargo test test_sync_all_concurrent`

## Dependency Context
Extends single-alias sync from bd-3f4 (sync command). Uses tokio Semaphore for concurrency control. Uses CacheManager.list_aliases from bd-3ea (cache read path).


Create CI/CD pipeline and cargo-deny configuration

## Background
The CI/CD pipeline runs tests, linting, security audits, and multi-platform builds on GitLab CI. It also configures cargo-deny for license/advisory policy enforcement.

## Approach

### .gitlab-ci.yml Structure

**Stages:** test, build, release

**Test stage jobs:**
- `test:unit` — `cargo test --lib` (unit tests only, no integration tests)
- `test:integration` — `cargo test --test '*'` (integration tests in tests/ directory)
- `lint` — `cargo fmt --check` + `cargo clippy -- -D warnings` (warnings are errors)
- `security:deps` — `cargo-deny check` (license + advisory) + `cargo-audit` (RUSTSEC advisory DB)

**Build stage:**
- Build template (YAML anchor or extends) shared across 4 target jobs:
  - `build:aarch64-apple-darwin` — Apple Silicon macOS
  - `build:x86_64-apple-darwin` — Intel macOS
  - `build:x86_64-unknown-linux-gnu` — x86 Linux
  - `build:aarch64-unknown-linux-gnu` — ARM Linux
- Each job: `cargo build --release --locked --target $TARGET`, upload binary as artifact

**Release stage:**
- `release` job (runs on tagged commits only):
  - Collect all 4 binaries from build artifacts
  - Generate `SHA256SUMS` file: `sha256sum swagger-cli-* > SHA256SUMS`
  - Sign with minisign: `minisign -S -m SHA256SUMS` (produces SHA256SUMS.minisig)
  - Upload all files to GitLab Package Registry via `curl` to Package Registry API
- `docker` job (runs on tagged commits):
  - `docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .`
  - `docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG`
  - Also tag as `:latest`

### deny.toml Structure

**[licenses]:**
- `unlicensed = "deny"` — all deps must have a license
- `allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Zlib", "Unicode-3.0", "Unicode-DFS-2016", "OpenSSL"]` — common OSS license allow-list
- `copyleft = "deny"` — no copyleft licenses without explicit exception

**[advisories]:**
- `vulnerability = "deny"` — fail on known vulnerabilities
- `unmaintained = "warn"` — warn but don't fail on unmaintained crates
- `unsound = "warn"` — warn on unsound crates
- `yanked = "deny"` — fail on yanked versions
- `notice = "warn"` — warn on advisories with notices

**[bans]:**
- `multiple-versions = "warn"` — warn if same crate appears in multiple versions
- `wildcards = "deny"` — no wildcard version specs
- Specific ban list empty initially (add problematic crates as discovered)

## Acceptance Criteria
- [ ] .gitlab-ci.yml has test, build, release stages in correct order
- [ ] test:unit runs `cargo test --lib`
- [ ] test:integration runs `cargo test --test '*'`
- [ ] lint job runs both fmt --check and clippy -D warnings
- [ ] security:deps runs both cargo-deny check and cargo-audit
- [ ] All 4 platform targets defined as separate build jobs
- [ ] Build jobs use shared template/anchor to avoid duplication
- [ ] Release job generates SHA256SUMS from all binaries
- [ ] Release job signs SHA256SUMS with minisign
- [ ] Release job uploads to GitLab Package Registry
- [ ] Docker job builds and pushes to CI_REGISTRY
- [ ] deny.toml license allow-list covers common OSS licenses
- [ ] deny.toml advisory policy denies known vulnerabilities
- [ ] `cargo deny check` passes locally after creation

## Files
- CREATE: .gitlab-ci.yml
- CREATE: deny.toml

## TDD Anchor
Validate: `cargo deny check` passes locally (after deny.toml is created).
VERIFY: `cargo deny check 2>&1 | head -5`

## Edge Cases
- **Cross-compilation toolchains:** macOS targets require macOS runners or cross-compilation tools. Verify CI has the right runners or use `cross` tool.
- **cargo-deny not installed:** The security:deps job must install cargo-deny and cargo-audit before running. Use `cargo install` with version pinning.
- **Minisign key management:** The release job needs the minisign private key as a CI secret variable. Document the required CI variables.
- **Tag-only release trigger:** Release jobs must have `rules: - if: $CI_COMMIT_TAG` to avoid running on every push.
- **Large binary artifacts:** Rust release binaries can be 10MB+. Verify CI artifact storage limits.


Epic: Distribution and CI


Convert list command to OutputMode

## Background
list.rs (898 lines) is the most feature-rich query command with filtering (method, tag, path regex), sorting, pagination, and an all-aliases variant. It has the most complex human output with tables, filter summaries, and pagination info.

Current execute signature (line 135):
```rust
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError>
```

## Approach

### 1. Change execute() and all-aliases path to accept OutputMode
- `execute()` at line 135: replace `robot_mode: bool` with `mode: OutputMode`
- All-aliases branch (around line 307): propagate `mode`

### 2. Impl HumanDisplay for ListOutput
`ListOutput` struct (line 56):
```rust
pub struct ListOutput {
    pub endpoints: Vec<EndpointEntry>,
    pub total: usize,
    pub filtered: usize,
    pub applied_filters: BTreeMap<String, String>,
    pub meta: ListMeta,
}
```
`ListMeta` struct (line 98):
```rust
pub struct ListMeta {
    pub alias: String,
    pub spec_version: Option<String>,
    pub cached_at: String,
    pub duration_ms: u64,
}
```
`EndpointEntry` struct (~line 65) with Tabled derive: `method`, `path`, `summary`, `tags`, `deprecated`

HumanDisplay logic (currently at lines 269-297):
- Header: "API: {title} {version} -- {total} endpoints"
- Table via `render_table_or_empty(endpoints, "No endpoints found")`
- Filter summary: "Showing {filtered} of {total}" -- only when filters active
- Pagination: "({applied_filters})" after filter line

### 3. Impl HumanDisplay for AllAliasesListOutput
`AllAliasesListOutput` struct (line 65):
```rust
pub struct AllAliasesListOutput {
    pub endpoints: Vec<AllAliasEndpointEntry>,
    pub total: usize,
    pub filtered: usize,
    pub applied_filters: BTreeMap<String, String>,
    pub aliases_searched: usize,
    pub warnings: Vec<String>,
    pub duration_ms: u64,
}
```
`AllAliasEndpointEntry` adds `alias` column to the table.

Human output: same as ListOutput but with ALIAS column in table and warnings list.

### 4. Replace branches with emit()
Replace `if robot_mode { robot_success(...) } else { ... }` with `emit(&output, mode, "list", duration)`.

## Acceptance Criteria
- [ ] `list::execute()` accepts `OutputMode` instead of `bool`
- [ ] `HumanDisplay for ListOutput` produces identical output to current lines 269-297
- [ ] `HumanDisplay for AllAliasesListOutput` produces identical output with alias column
- [ ] Zero `if robot_mode` / `if !robot_mode` branches in list.rs
- [ ] Pagination footer: "Showing {filtered} of {total}" only when filters are active, "filtered from {total_unfiltered}" only when filtered < total
- [ ] Integration tests pass (`test_list_endpoints`, `test_list_method_filter`, etc.)
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/list.rs (accept OutputMode, impl HumanDisplay for ListOutput + AllAliasesListOutput)
- MODIFY: src/main.rs (pass mode to list at line 124)

## TDD Anchor
RED: `test_list_human_display_with_filters` -- create `ListOutput` with `applied_filters: {"method": "GET"}` and 3 endpoints, call `display_human(&mut Vec<u8>)`, assert contains "API:" header, table content, and "Showing 3 of 10" footer
GREEN: Impl `HumanDisplay for ListOutput`
VERIFY: `cargo test list`

Additional tests:
- `test_list_empty_human_display` -- 0 endpoints, assert "No endpoints found"
- `test_list_all_aliases_human_display` -- assert ALIAS column present
- `test_list_no_filters_no_footer` -- when no filters applied, no "Showing X of Y" line

## Edge Cases
- Pagination footer varies: shows "filtered from {total}" ONLY when filters reduce the count
- All-aliases output includes warnings list that may be empty -- skip warnings section if empty
- Tags in endpoint rows may be empty -- display as empty string, not "[]"
- `render_table_or_empty` from `src/output/table.rs` is reused unchanged

## Dependency Context
Uses `OutputMode`, `HumanDisplay`, `emit()` from bd-2im. `render_table_or_empty` from `src/output/table.rs` (unchanged). Pattern validated by bd-bq8 (tags PoC).


Extract sync types.rs and diff.rs from sync_cmd.rs

## Background
sync_cmd.rs is 1843 lines. First extraction step: pull out all data types and the diff computation logic into dedicated files. This is the lowest-risk extraction since these are pure data types and pure functions with no side effects.

## Approach
1. Create src/cli/sync/ directory
2. Create src/cli/sync/types.rs containing (from sync_cmd.rs):
   - EndpointKey (line 79)
   - SchemaDiff (line 85)
   - EndpointDiff (line 91)
   - ChangeSummary (line 98)
   - ChangeDetails (line 107)
   - SyncOutput (line 118)
   - AliasSyncResult (line 132)
   - SyncAllOutput (line 152)
   - All derive macros and serde attributes preserved exactly
   - pub(crate) visibility for types used within sync module, pub for types used by tests
3. Create src/cli/sync/diff.rs containing:
   - MAX_DETAIL_ITEMS const (line 76)
   - endpoint_key() (line 208)
   - endpoint_fingerprint() (line 215)
   - compute_diff() (line 236)
   - Move ALL diff-related tests from sync_cmd::tests into diff.rs #[cfg(test)] mod tests
4. Create src/cli/sync/mod.rs with:
   - pub mod types; pub mod diff;
   - Re-export what sync_cmd.rs currently exposes
5. Update src/cli/mod.rs: replace pub mod sync_cmd with pub mod sync
6. Keep remaining code in a temporary sync/legacy.rs or directly in mod.rs until later extraction beads

## Acceptance Criteria
- src/cli/sync/types.rs contains all 8 output/data structs
- src/cli/sync/diff.rs contains compute_diff, endpoint_key, endpoint_fingerprint, MAX_DETAIL_ITEMS
- All diff tests pass in their new location (test_diff_no_changes, test_diff_added_endpoint, test_diff_removed_endpoint, test_diff_modified_endpoint, test_diff_added_schema, test_diff_removed_schema, test_diff_endpoint_modified_by_params)
- src/cli/mod.rs declares pub mod sync (not sync_cmd)
- All integration tests pass unchanged
- cargo clippy --all-targets -- -D warnings passes

## Files
- CREATE: src/cli/sync/mod.rs
- CREATE: src/cli/sync/types.rs
- CREATE: src/cli/sync/diff.rs
- MODIFY: src/cli/mod.rs (pub mod sync_cmd -> pub mod sync)
- MODIFY: src/main.rs (update import paths if needed)
- The remaining sync_cmd.rs code stays in sync/mod.rs temporarily

## TDD Anchor
No new tests -- move existing diff tests. After extraction:
VERIFY: cargo test sync::diff -- verify all 7 diff tests pass in new location
VERIFY: cargo test integration -- verify end-to-end sync tests pass

## Edge Cases
- Import paths change: anything that imports from crate::cli::sync_cmd must change to crate::cli::sync
- The types need correct visibility: pub(crate) for internal sync use, pub for types in test assertions
- compute_diff uses IndexedEndpoint from crate::core::spec -- import path unchanged

## Dependency Context
Depends on bd-2x7 (sync OutputMode conversion) being complete, so the output formatting is already using emit() before we split.


Implement cross-alias discovery (--all-aliases)

## Background
--all-aliases on list and search queries across every cached alias, returning results with an additional "alias" field per result. Useful for agents managing multiple APIs.

## Approach
1. In list execute: if --all-aliases, iterate all aliases via list_aliases(), load each index, filter/sort per-alias, merge results with "alias" field added to each endpoint item
2. In search execute: same pattern — search each alias's index, merge results, re-sort by score
3. Robot output: each endpoint/result includes "alias" string field identifying which spec it came from

**Sorting rules:**
- For `list --all-aliases`: merged endpoints sorted by (alias ASC, path ASC, method_rank ASC) where method_rank follows standard HTTP method ordering (GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, etc.)
- For `search --all-aliases`: merged results sorted by (score DESC, then normal tie-breaking within same score) — the alias field is just additional metadata, it does not affect ranking

**Robot output envelope:**
- `data.endpoints[]` or `data.results[]` — each item includes `"alias": "petstore"` field
- `data.aliases_searched: string[]` — lists which aliases were queried (e.g., `["petstore", "github-api", "stripe"]`)
- This allows agents to detect if an alias was missing/skipped vs simply had no matching results

**Error handling for individual alias failures:**
- If one alias fails to load (corrupted index, missing files), do NOT abort the entire operation
- Skip the failed alias and continue with remaining aliases
- Add warning to `meta.warnings[]` array: `{"alias": "broken-api", "code": "INDEX_LOAD_FAILED", "message": "..."}`
- Robot output still returns ok:true with partial results (this is a degraded success, not an error)
- If ALL aliases fail, then return an error

## Acceptance Criteria
- [ ] `list --all-aliases --robot` returns endpoints from all aliases with alias field on each item
- [ ] `search "pet" --all-aliases --robot` searches across all aliases with alias field on each result
- [ ] List results sorted by alias ASC, then path ASC, then method_rank ASC
- [ ] Search results sorted by score DESC (alias does not affect ranking)
- [ ] Robot output includes data.aliases_searched listing all queried aliases
- [ ] If one alias fails to load, operation continues with remaining aliases and meta.warnings populated
- [ ] If all aliases fail, returns error (not partial success)
- [ ] Human output groups or labels results by alias for readability

## Edge Cases
- **Single alias cached:** --all-aliases works but is equivalent to normal query (aliases_searched has one entry)
- **No aliases cached:** Return error — no specs available
- **One alias fails to load:** Skip with warning in meta.warnings, return results from healthy aliases
- **All aliases fail:** Return error with details about each failure
- **Duplicate endpoint paths across aliases:** Both appear in results, distinguished by alias field

## Files
- MODIFY: src/cli/list.rs (add --all-aliases logic)
- MODIFY: src/cli/search.rs (add --all-aliases logic)

## TDD Anchor
RED: Write `test_list_all_aliases` — fetch 2 different specs, run list --all-aliases --robot, assert results contain endpoints from both with alias field.
RED: Write `test_search_all_aliases` — fetch 2 specs, run search --all-aliases --robot, assert results from both with correct score-based sorting.
RED: Write `test_all_aliases_partial_failure` — corrupt one alias index, run list --all-aliases, assert partial results returned with warning.
GREEN: Implement cross-alias iteration.
VERIFY: `cargo test test_list_all_aliases`

## Dependency Context
Requires list command (bd-3km) and search command (bd-acf) to be implemented first.


Implement configuration system with path resolution and auth profiles

## Background
swagger-cli uses TOML config files for user preferences and auth profiles, stored in XDG config dir. The config system handles path resolution with a specific precedence: --config > SWAGGER_CLI_CONFIG > SWAGGER_CLI_HOME > XDG defaults. Auth profiles allow loading credentials from config.toml instead of passing raw tokens on the command line.

## Approach
Implement Config, AuthConfig, CredentialSource, AuthType, DisplayConfig structs in src/core/config.rs per the PRD. Config::load() reads from config_path(), Config::save() writes with toml::to_string_pretty(). Both config_path() and cache_dir() implement the D7 override precedence using env vars SWAGGER_CLI_HOME, SWAGGER_CLI_CONFIG, SWAGGER_CLI_CACHE, and directories::ProjectDirs. CredentialSource is a tagged enum (Literal, EnvVar, Keyring) with serde tag="source". AuthType is an enum (Bearer, ApiKey { header }). Default stale_threshold_days is 30.

## Acceptance Criteria
- [ ] Config::load() returns default Config when no file exists
- [ ] Config::save() writes valid TOML that round-trips through load()
- [ ] config_path() respects: --config flag > SWAGGER_CLI_CONFIG env > SWAGGER_CLI_HOME/config/config.toml > XDG
- [ ] cache_dir() respects: SWAGGER_CLI_CACHE > SWAGGER_CLI_HOME/cache > XDG cache dir
- [ ] AuthConfig with CredentialSource::EnvVar serializes/deserializes correctly
- [ ] Default config has stale_threshold_days=30, empty auth_profiles, no default_alias
- [ ] All tests hermetic (use SWAGGER_CLI_HOME with tempdir)

## Files
- CREATE: src/core/config.rs (Config, AuthConfig, CredentialSource, AuthType, DisplayConfig, Default impl, load/save, path resolution)
- MODIFY: src/core/mod.rs (pub mod config;)

## TDD Anchor
RED: Write `test_config_path_precedence` — set SWAGGER_CLI_HOME to a tempdir, verify config_path() returns tempdir/config/config.toml. Then set SWAGGER_CLI_CONFIG to a specific path, verify it takes precedence.
GREEN: Implement config_path() with env var checks.
VERIFY: `cargo test test_config_path_precedence`

## Edge Cases
- CredentialSource::Keyring is Phase 2 — include the enum variant but document it as unimplemented
- Config file might not exist (return default, don't error)
- SWAGGER_CLI_HOME dir might not exist yet (create parent dirs on save)
- toml crate is named `toml`, not `serde_toml`

Config with TOML load/save, path precedence, auth profiles


Epic: AsyncCache with spawn_blocking

Wrap CacheManager blocking file I/O (std::fs + fs2::FileExt flock) in tokio::task::spawn_blocking via an AsyncCache wrapper. Prevents runtime starvation under sync --all --jobs=4 concurrent cache access. Preserves sync CacheManager for tests.

## Child Beads (execution order)
1. **bd-39e** -- Create AsyncCache wrapper with spawn_blocking (new struct in src/core/cache.rs)
2. **bd-3aa** -- Convert all 12 command handlers from CacheManager to AsyncCache (mechanical .await additions)

## Independence
This epic is fully independent of Epic 1 (Output Sink) and Epic 4 (Property Tests). Can run in parallel.


Add property tests for search engine

## Background
The search engine (src/core/search.rs, 722 lines) has tokenized scoring, Unicode-safe snippet extraction, coverage boost, and deterministic ordering. Current tests are example-based (petstore index). No property tests verify invariants across arbitrary inputs.

## Approach
1. Create tests/property_search_test.rs
2. Build proptest strategies:
   - arb_indexed_endpoint(): generates IndexedEndpoint with random paths (/[a-z]{1,5}(/[a-z]{1,5}){0,3}), methods (GET|POST|PUT|DELETE|PATCH), optional summaries and descriptions
   - arb_spec_index(): Vec of 0..50 endpoints + 0..20 schemas
   - arb_search_options(): random combination of search_paths/search_descriptions/search_schemas, case_sensitive, exact, limit 1..100
   - arb_query(): 1-5 words of [a-z]{1,10} joined by spaces (for normal queries), plus Unicode strings for safety tests

3. Implement 8 property tests:

   a) score_monotonicity: Given an endpoint matching term T in field F, adding a second match in field G should produce score >= original score.

   b) deterministic_ordering: search(query, index, opts) called twice = identical Vec<SearchResult> (same scores, ranks, names, methods).

   c) limit_respected: results.len() <= opts.limit for any query and index.

   d) coverage_boost: For a 2-term query where endpoint A matches both terms and endpoint B matches 1 term (in same field), A.score >= B.score.

   e) case_insensitivity: For any query Q and index I, search(Q.to_lowercase(), I, case_insensitive) == search(Q.to_uppercase(), I, case_insensitive) in terms of result sets (same names returned).

   f) empty_query_safety: search(whitespace_only, index, opts) always returns empty vec. Strategy: " ".repeat(0..10) + "\t".repeat(0..3).

   g) unicode_safety: search(arbitrary_unicode, index, opts) never panics. Strategy: proptest::string::string_regex(".*").unwrap() with emoji, RTL, zero-width chars.

   h) snippet_bounds: For any match in results, snippet body (excluding "...") is <= 50 chars. Call safe_snippet directly with arbitrary haystack/needle.

## Acceptance Criteria
- tests/property_search_test.rs exists with 8 proptest tests
- All 8 tests pass with default case count (256)
- No new dependencies (proptest already in dev-deps)
- safe_snippet is pub(crate) or the test uses a test helper to access it
- cargo clippy --all-targets -- -D warnings passes

## Files
- CREATE: tests/property_search_test.rs
- POSSIBLY MODIFY: src/core/search.rs (make safe_snippet pub(crate) for direct testing, or test via SearchEngine::search results)

## TDD Anchor
These ARE the tests. Write them and verify they pass against existing implementation.
RED: If any test fails, that is a real bug to fix in src/core/search.rs
VERIFY: cargo test property_search

## Edge Cases
- Unicode safety test may trigger panics in safe_snippet if multi-byte char boundaries are mishandled -- the existing code has careful char-based iteration but property testing may find edge cases
- Empty index (0 endpoints, 0 schemas) should return empty results for any query
- Very long queries (100+ chars) should not cause excessive allocation

## Dependency Context
No dependencies. Fully independent. Tests existing code without modifications.


Implement reliability stress tests and performance benchmarks

## Background
Reliability tests validate the crash-consistency, concurrency safety, and determinism claims. These are the hardest tests but most important for production confidence. Includes fault injection (simulated crash at each write step), multi-process lock contention (32 concurrent processes), and property-based tests (proptest for deterministic ordering).

## Approach
**Crash consistency (tests/reliability/crash_consistency_test.rs):**
- test_crash_before_meta_rename: write raw+index but not meta → read protocol detects → doctor --fix repairs
- test_crash_after_raw_before_index: write raw but not index → doctor --fix rebuilds

**Lock contention (tests/reliability/lock_contention_test.rs):**
- test_concurrent_fetch_32_processes: spawn 32 threads, all fetch same alias with --force → verify all exit 0 or 9 (CACHE_LOCKED), no panics, final state passes doctor

**Property-based (tests/reliability/property_test.rs with proptest):**
- index_ordering_deterministic: random endpoints → build_index → build_index from shuffled → same JSON
- hash_deterministic: same bytes → same hash

Add proptest = '1.0' to [dev-dependencies] in Cargo.toml alongside existing test deps.

**Benchmarks (benches/perf.rs with Criterion):**
- bench_load_index, bench_list_endpoints, bench_search_query on 500+ endpoint spec

## Acceptance Criteria
- [ ] Crash consistency tests verify doctor can repair all simulated crash states
- [ ] 32-process contention test passes without deadlocks or corruption
- [ ] Property-based tests verify deterministic ordering
- [ ] Benchmarks establish baseline performance numbers
- [ ] All reliability tests pass on CI

## Files
- CREATE: tests/reliability/crash_consistency_test.rs
- CREATE: tests/reliability/lock_contention_test.rs
- CREATE: tests/reliability/property_test.rs
- CREATE: benches/perf.rs
- MODIFY: Cargo.toml (add [[bench]] for criterion, add proptest dev-dep)

## TDD Anchor
Run: `cargo test --test crash_consistency && cargo test --test lock_contention && cargo test --test property`
VERIFY: `cargo bench -- --test`

## Dependency Context
Uses CacheManager write/read protocol from cache beads. Uses build_index from indexer bead. Uses compute_hash. Tests the claims made in the cache architecture.

## Edge Cases
- **Crash test cleanup:** Crash simulation tests must clean up temp files even on test failure. Use Drop guards or panic hooks.
- **32-process test may exceed file descriptor limits:** On CI, ulimit may be low. Test should check and skip with a clear message if fd limit < 256.
- **Property test shrinking:** proptest shrinking can be slow for complex inputs. Set max shrink iterations to avoid CI timeouts.
- **Benchmark stability:** Criterion requires multiple iterations. First run creates baseline. Tests should not fail on benchmark regression — only warn.
- **Lock contention test timing:** 32 threads racing for a lock is timing-dependent. Use a barrier to ensure all threads start simultaneously.

Implemented: 3 test files (crash_consistency_test.rs with 8 tests, lock_contention_test.rs with 3 tests, property_test.rs with 4 tests), 5 Criterion benchmarks in perf.rs, and fixed doctor bug for missing index repair


Epic: Health and Cache Lifecycle

All 2 child beads closed: doctor command, cache lifecycle


Extract sync checkpoint.rs and throttle.rs

## Background
Second extraction from the sync module. Checkpoint (resume/save/load) and per-host throttle are self-contained components with clear boundaries.

## Approach
1. Create src/cli/sync/checkpoint.rs containing:
   - CHECKPOINT_FILE const (line 166)
   - SyncCheckpoint struct (line 168)
   - load_checkpoint() (line 175)
   - save_checkpoint() (line 181)
   - remove_checkpoint() (line 197)
   - All use SwaggerCliError for error types
2. Create src/cli/sync/throttle.rs containing:
   - extract_host() (line 311)
   - PerHostThrottle struct (line 318)
   - PerHostThrottle::new() and acquire() methods
   - Uses tokio::sync::{Mutex, Semaphore} and Arc
3. Update src/cli/sync/mod.rs to add: pub(crate) mod checkpoint; pub(crate) mod throttle;
4. Update imports in remaining sync code to reference sync::checkpoint:: and sync::throttle::

## Acceptance Criteria
- checkpoint.rs contains all checkpoint logic (~60 lines)
- throttle.rs contains PerHostThrottle and extract_host (~50 lines)
- Both modules compile independently (no circular deps)
- All integration tests pass (especially sync --all tests that exercise checkpointing and throttling)
- cargo clippy --all-targets -- -D warnings passes

## Files
- CREATE: src/cli/sync/checkpoint.rs
- CREATE: src/cli/sync/throttle.rs
- MODIFY: src/cli/sync/mod.rs (add module declarations, update imports)

## TDD Anchor
RED: test_extract_host_parses_url -- verify extract_host("https://api.example.com/v1/spec.json") returns "api.example.com"
GREEN: Move extract_host to throttle.rs
VERIFY: cargo test sync::throttle

Additional tests: test_extract_host_invalid_url (returns empty string), test_checkpoint_roundtrip (save then load)

## Edge Cases
- extract_host() returns empty string for unparseable URLs -- preserve this behavior
- PerHostThrottle::acquire() uses expect("semaphore should not be closed") -- acceptable since we control the semaphore lifecycle
- Checkpoint JSON format must not change (backwards compat with existing checkpoint files)

## Dependency Context
Depends on bd-1re (types and diff extracted first so sync/mod.rs structure exists).


Epic: Diff Command

All 1 child bead closed: diff command with CI gate


Convert aliases, doctor, cache_cmd commands to OutputMode

## Background
Convert the 3 management commands (aliases, doctor, cache). These are moderately complex: aliases has 5 sub-operations with different output types, doctor runs progressive checks, and cache_cmd has 4 sub-modes.

## Approach
For each command, change execute() to accept `OutputMode` instead of `robot: bool`, impl `HumanDisplay`, and replace `if robot` branches with `emit()`.

### aliases.rs (execute at line 123: `pub async fn execute(args: &Args, robot: bool)`, 615 lines)
5 sub-operations, each with different output. Current output structs:
- `AliasListOutput` (line 57): `aliases: Vec<AliasListEntry>`, `default_alias: Option<String>`, `count: usize`
- `AliasShowOutput` (line 64): `name`, `source_url`, `version`, `is_default`, `cached_at`, `last_accessed`, `content_hash`, `endpoint_count`, `schema_count`, `raw_size_bytes`, `source_format`, `generation`
- `AliasRenameOutput` (line 81): `old_name`, `new_name`, `message`
- `AliasDeleteOutput` (line 87): `name`, `message`
- `AliasSetDefaultOutput` (line 93): `name`, `message`
- `AliasListEntry` (line 46): `name`, `source_url`, `version`, `is_default`, `cached_at`, `endpoints`, `schemas`

Impl `HumanDisplay` for each:
- `AliasListOutput`: table via `render_table_or_empty` with columns NAME/URL/VERSION/DEFAULT/CACHED_AT/ENDPOINTS/SCHEMAS
- `AliasShowOutput`: key-value display (Name: ..., URL: ..., etc.)
- `AliasRenameOutput`, `AliasDeleteOutput`, `AliasSetDefaultOutput`: confirmation message strings

Sub-functions (`cmd_list`, `cmd_show`, `cmd_rename`, `cmd_delete`, `cmd_set_default`) each receive `mode: OutputMode` and call `emit()`.

### doctor.rs (execute at line 344: `pub async fn execute(args: &Args, robot_mode: bool)`, 767 lines)
**DOCUMENTED EXCEPTION** to HumanDisplay pattern. Doctor prints progressive check results (pass/fail per check with real-time output), which does not fit the produce-data-emit-once model.

Output struct: `DoctorOutput` (line 71) with `health: HealthStatus`, `aliases: Vec<AliasReport>`, `warnings: Vec<String>`, `total_disk_bytes: u64`, `fixable_count: usize`, `unfixable_count: usize`

Approach:
- Accept `OutputMode` instead of `bool`
- For `Robot` mode: collect all results, emit `DoctorOutput` via `robot_success()` at end (existing behavior)
- For `Human` mode: keep progressive `println!` calls inline (existing behavior)
- Replace `if robot_mode` checks with `matches!(mode, OutputMode::Robot { .. })`
- Do NOT impl `HumanDisplay for DoctorOutput`

### cache_cmd.rs (execute at line 151: `pub async fn execute(args: &Args, robot: bool)`, 586 lines)
4 sub-modes with tagged enum output:
- `CacheOutput` enum (line 54): `Stats(StatsOutput)`, `Path(PathOutput)`, `Prune(PruneOutput)`, `Evict(EvictOutput)`
- `StatsOutput` (line 66): `aliases: Vec<AliasStats>`, `total_size_bytes`, `total_aliases`, `total_endpoints`, `total_schemas`
- `PathOutput` (line 78): `path: String`
- `PruneOutput` (line 82): `removed: Vec<String>`, `total_removed`, `bytes_freed`
- `EvictOutput` (line 91): `alias`, `message`, `removed: bool`

Impl `HumanDisplay` for each variant's inner type:
- `StatsOutput`: table of alias stats + summary line
- `PathOutput`: just the path string
- `PruneOutput`: list of removed items + "Freed X bytes" summary
- `EvictOutput`: confirmation message

Sub-functions (`execute_path`, `execute_stats`, `execute_prune`, `execute_evict`) each receive `mode` and call `emit()`.

## Acceptance Criteria
- [ ] aliases.rs and cache_cmd.rs: zero `if robot` / `if !robot` branches, all output through `emit()`
- [ ] doctor.rs: accepts `OutputMode`, uses `matches!(mode, OutputMode::Robot { .. })` for branching, keeps progressive human output inline (documented exception)
- [ ] `HumanDisplay` implemented for: `AliasListOutput`, `AliasShowOutput`, `AliasRenameOutput`, `AliasDeleteOutput`, `AliasSetDefaultOutput`, `StatsOutput`, `PathOutput`, `PruneOutput`, `EvictOutput`
- [ ] No `HumanDisplay` impl for `DoctorOutput` (documented exception)
- [ ] All integration tests pass unchanged
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/aliases.rs (accept OutputMode, impl HumanDisplay for 5 output types)
- MODIFY: src/cli/doctor.rs (accept OutputMode, replace bool checks with OutputMode matches)
- MODIFY: src/cli/cache_cmd.rs (accept OutputMode, impl HumanDisplay for 4 output types)
- MODIFY: src/main.rs (pass mode to these 3 commands at lines 129, 133, 134)

## TDD Anchor
RED: `test_alias_list_human_display` -- create `AliasListOutput` with 2 aliases, call `display_human(&mut Vec<u8>)`, assert table contains alias names and "default" marker
GREEN: Impl `HumanDisplay for AliasListOutput`
VERIFY: `cargo test aliases`

Additional tests:
- `test_alias_show_human_display` -- assert key-value pairs present
- `test_cache_stats_human_display` -- assert summary line
- `test_cache_path_human_display` -- assert just the path string
- `test_cache_prune_human_display` -- assert "Freed X bytes" line

## Edge Cases
- doctor.rs is a documented exception -- an agent MUST NOT try to impl HumanDisplay for it. If tempted, check the progressive output pattern: it prints per-alias check results in real-time before the final summary.
- cache prune/evict modify state AND produce output -- `HumanDisplay` must handle success messages and counts, not the mutation itself
- aliases rename/delete produce confirmation messages, not tables -- `HumanDisplay` writes a single line like "Renamed 'old' to 'new'"
- cache_cmd.rs has 4 separate `CacheManager::new()` calls in different sub-functions (lines 151, 192, 240, 308) -- all change to accept mode param, cache creation stays local for now (AsyncCache conversion is bd-3aa)

## Dependency Context
Uses `OutputMode`, `HumanDisplay`, `emit()` from bd-2im (output foundation). Pattern validated by bd-bq8 (tags) and bd-2wp (query commands).


Flesh out Phase 3 scope and requirements

## Background
Phase 3 beads (SBOM, keyring, curl gen, breaking-change classification, semantic search, YAML output) are currently stubs without enough detail for implementation. Before any Phase 3 work begins, the requirements and scope for each feature must be fleshed out with proper acceptance criteria, approach, and file lists.

## What Needs to Happen
1. Review each Phase 3 bead against the PRD's Phase 3 section
2. Research any dependencies or design decisions not yet documented
3. Write full bead descriptions (Background, Approach, Acceptance Criteria, Files, TDD Anchor, Edge Cases) for each Phase 3 bead
4. Validate scope boundaries — what's in vs out for each feature

## Acceptance Criteria
- [ ] All Phase 3 beads have full descriptions (score 4+/5 on agent-readiness)
- [ ] Each bead has concrete file lists and TDD anchors
- [ ] Scope boundaries are documented (what's explicitly out of scope)
- [ ] No unresolved ambiguities — all genuinely ambiguous decisions are resolved

## Phase 3 Beads to Flesh Out
- bd-37c: SBOM generation and cosign attestation
- bd-3pz: OS keychain credential backend
- bd-60k: Generate curl commands from endpoints
- bd-j23: Breaking-change classification for diff
- bd-132: Semantic search with embeddings
- bd-b8h: YAML output format


Implement golden robot output tests and index-only invariant tests

## Background
Golden tests are the #1 defense against robot JSON shape regressions. They verify structural invariants (ok, data, meta.schema_version, meta.tool_version, meta.command, meta.duration_ms) and snapshot-compare against golden files. Index-only invariant tests verify that list/search work without raw.json (core performance promise).

## Approach
**Golden tests (tests/integration/golden_test.rs):**
1. For each command (list, show, search, schemas, tags, aliases), run with --robot
2. Parse output as JSON, verify structural invariants (ok is bool, data is object, meta has required fields)
3. Compare against golden snapshot files in tests/integration/golden/
4. Fail CI if shape changes unless schema_version is incremented

**Index-only invariant tests:**
- test_list_does_not_read_raw_json: fetch spec, delete raw.json, run list — must succeed
- test_search_does_not_read_raw_json: same pattern with search
- test_tags_does_not_read_raw_json: same
- test_schemas_list_does_not_read_raw_json: same (list mode only; show mode needs raw)

**JSON Schema validation (optional enhancement):**
- Create docs/robot-schema/v1/success.schema.json and error.schema.json
- Validate robot output against these schemas in golden tests

## Acceptance Criteria
- [ ] Golden test verifies all 6 command outputs have correct structure
- [ ] Index-only tests pass (list/search/tags/schemas-list work without raw.json)
- [ ] Golden files exist in tests/integration/golden/
- [ ] JSON Schema files exist in docs/robot-schema/v1/

## Edge Cases
- **duration_ms non-determinism:** Golden files must NOT include duration_ms in snapshot comparison (it changes every run). Strip or mask it before comparing.
- **tool_version changes:** Updating Cargo.toml version breaks golden files. Golden comparison should either mask tool_version or tests should update golden files via an env var flag.
- **Platform-specific key ordering:** serde_json with BTreeMap ensures deterministic ordering. Verify golden files use sorted keys.
- **Index-only tests must verify raw.json is actually deleted:** Don't just skip loading it — physically remove the file and prove the command works without it.

## Files
- CREATE: tests/integration/golden_test.rs
- CREATE: tests/integration/golden/*.json (golden snapshot files)
- CREATE: docs/robot-schema/v1/success.schema.json
- CREATE: docs/robot-schema/v1/error.schema.json

## TDD Anchor
Run all golden and invariant tests.
VERIFY: `cargo test golden && cargo test does_not_read_raw`

## Dependency Context
Requires all query commands to be implemented. Uses test helpers from bd-lx6 (Create test fixtures and integration test helpers).

Golden robot output tests + index-only invariant tests complete with JSON Schema artifacts


Create OutputMode enum, HumanDisplay trait, and emit() function

## Background
Every command handler takes `robot: bool` and manually branches (29+ if-branches across 12 files). `output/human.rs` is 8 lines. All formatting lives in command handlers.

This bead creates the foundational types that replace the `robot:bool` threading pattern. All subsequent Epic 1 beads depend on these types.

## Approach
1. Add `OutputMode` enum to `src/output/mod.rs` (after existing `RobotEnvelope` and `RobotError`):
   ```rust
   #[derive(Debug, Clone, Copy)]
   pub enum OutputMode {
       Robot { pretty: bool },
       Human,
   }
   ```

2. Add `HumanDisplay` trait to `src/output/mod.rs`:
   ```rust
   pub trait HumanDisplay {
       fn display_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()>;
   }
   ```
   Using `dyn Write` enables testing against `Vec<u8>`.

3. Add `emit()` function to `src/output/mod.rs`:
   ```rust
   pub fn emit<T: Serialize + HumanDisplay>(
       data: &T,
       mode: OutputMode,
       command: &str,
       duration: std::time::Duration,
   ) {
       match mode {
           OutputMode::Robot { pretty: true } => robot::robot_success_pretty(data, command, duration),
           OutputMode::Robot { pretty: false } => robot::robot_success(data, command, duration),
           OutputMode::Human => data.display_human(&mut std::io::stdout()).expect("stdout write"),
       }
   }
   ```

4. Keep existing `robot::robot_success()`, `robot::robot_success_pretty()`, and `robot::robot_error()` unchanged -- they are called internally by `emit()` and also directly by `main.rs` error handling.

Current `src/output/mod.rs` structure (47 lines):
- Lines 1-3: pub mod declarations (human, robot, table)
- Lines 5-7: use statements
- Lines 9-17: `RobotEnvelope<T>` struct
- Lines 19-25: `RobotError` struct
- Lines 27-47: impl blocks for `RobotEnvelope`

Add `OutputMode`, `HumanDisplay`, and `emit()` after line 47.

## Acceptance Criteria
- [ ] `OutputMode` enum with `Robot { pretty: bool }` and `Human` variants exists in `src/output/mod.rs`
- [ ] `HumanDisplay` trait with `fn display_human(&self, w: &mut dyn Write) -> io::Result<()>` exists
- [ ] `emit()` dispatches to `robot::robot_success` / `robot_success_pretty` for Robot mode
- [ ] `emit()` calls `data.display_human(&mut stdout())` for Human mode
- [ ] `emit()` with `Robot { pretty: false }` produces valid `RobotEnvelope` JSON (ok:true, data, meta with schema_version/tool_version/command/duration_ms)
- [ ] `emit()` with `Robot { pretty: true }` produces pretty-printed (indented) JSON
- [ ] `emit()` with `Human` calls `HumanDisplay::display_human()`
- [ ] `cargo test` passes, `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: `src/output/mod.rs` (add `OutputMode`, `HumanDisplay`, `emit()` after line 47)

## TDD Anchor
RED: `test_emit_robot_produces_valid_envelope` -- Create a dummy struct that impls both `Serialize` and `HumanDisplay`, call `emit` in robot mode capturing stdout, parse as `RobotEnvelope`, assert `ok == true` and `data` present.
GREEN: Implement `OutputMode`, `HumanDisplay`, `emit()`
VERIFY: `cargo test output`

Additional tests:
- `test_emit_human_calls_display_human` -- emit with Human mode, verify display_human output written to stdout
- `test_emit_robot_pretty_is_indented` -- emit with `Robot { pretty: true }`, verify output contains newlines/indentation
- `test_output_mode_debug` -- verify Debug derive works

## Edge Cases
- `HumanDisplay` io::Error: `expect()` in emit is acceptable since stdout failure is unrecoverable (same pattern as `println!`)
- Empty data structs should still produce valid JSON (`{"ok":true,"data":{},"meta":{...}}`)
- `emit()` takes `&T` not `T` since robot_success/robot_success_pretty take `T: Serialize` -- need to verify the existing functions accept references. Current signatures: `pub fn robot_success<T: Serialize>(data: T, ...)` -- since `&T: Serialize` when `T: Serialize`, passing `data` directly works. For HumanDisplay, `data.display_human()` works because the trait is implemented on the concrete type and called via reference.

## Dependency Context
No dependencies. This is the foundation bead for Epic 1.


Epic: Shared Command Pipeline (CommandContext)

Create a CommandContext struct encapsulating common command setup (cache, output mode, timing, network policy, config path). Eliminates ~15-20 lines of identical scaffolding per command handler. Enables future cross-cutting concerns (telemetry, logging) without touching individual commands.

## Child Beads (execution order)
1. **bd-1iz** -- Create CommandContext struct and convert tags as proof of concept
2. **bd-2z1** -- Convert all remaining 11 commands to use &CommandContext

## Prerequisites
Depends on Epic 1 (bd-14o, all commands accept OutputMode) and Epic 3 (bd-1ti, bd-39e AsyncCache exists). CommandContext wraps both OutputMode and AsyncCache.


Add supply chain hardening and robot JSON Schema artifacts

## Background
Supply chain hardening: release artifacts include SHA256SUMS + minisign signatures. Robot output JSON Schemas published as build artifacts for agent validation.

## Approach

### Supply Chain Artifacts
1. Update release job in .gitlab-ci.yml to generate SHA256SUMS and sign with minisign
2. Upload SHA256SUMS + SHA256SUMS.minisig alongside binaries to GitLab Package Registry
3. Update install.sh to verify signature when minisign is available

### JSON Schema Files

Create `docs/robot-schema/v1/success.schema.json`:
```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["ok", "data", "meta"],
  "properties": {
    "ok": { "type": "boolean", "const": true },
    "data": { "type": "object" },
    "meta": {
      "type": "object",
      "required": ["schema_version", "tool_version", "command", "duration_ms"],
      "properties": {
        "schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$" },
        "tool_version": { "type": "string" },
        "command": { "type": "string" },
        "command_version": { "type": "string", "description": "Per-command payload version for independent evolution" },
        "duration_ms": { "type": "integer", "minimum": 0 }
      }
    }
  },
  "additionalProperties": false
}
```

Create `docs/robot-schema/v1/error.schema.json`:
```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["ok", "error", "meta"],
  "properties": {
    "ok": { "type": "boolean", "const": false },
    "error": {
      "type": "object",
      "required": ["code", "message"],
      "properties": {
        "code": { "type": "string" },
        "message": { "type": "string" },
        "suggestion": { "type": "string" }
      }
    },
    "meta": {
      "type": "object",
      "properties": {
        "schema_version": { "type": "string" },
        "tool_version": { "type": "string" },
        "command": { "type": "string" },
        "duration_ms": { "type": "integer", "minimum": 0 }
      }
    }
  },
  "additionalProperties": false
}
```

### Compatibility Policy
- **No version bump:** Adding new optional fields to data or meta (additive changes)
- **MUST bump schema_version:** Removing fields, renaming fields, changing field types, changing required status
- **meta.command_version:** Each command can independently evolve its data payload structure. When a command's data shape changes in a breaking way, bump command_version without bumping the global schema_version. This allows agents to pin to specific command output shapes.

## Acceptance Criteria
- [ ] Release pipeline generates SHA256SUMS from all binary artifacts
- [ ] minisign signature generated for SHA256SUMS (when key available in CI)
- [ ] SHA256SUMS and SHA256SUMS.minisig uploaded alongside binaries
- [ ] install.sh attempts signature verification when minisign is on PATH
- [ ] docs/robot-schema/v1/success.schema.json matches the structure above
- [ ] docs/robot-schema/v1/error.schema.json matches the structure above
- [ ] success.schema.json meta requires: schema_version, tool_version, command, duration_ms
- [ ] success.schema.json meta includes optional command_version field
- [ ] error.schema.json error requires: code, message (suggestion is optional)
- [ ] Both schemas have additionalProperties: false at top level
- [ ] Schema files are valid JSON Schema (validate with a JSON Schema validator)

## Files
- MODIFY: .gitlab-ci.yml (add checksum + signing to release)
- MODIFY: install.sh (add signature verification)
- CREATE: docs/robot-schema/v1/success.schema.json
- CREATE: docs/robot-schema/v1/error.schema.json

## Dependency Context
Extends CI pipeline from bd-1lj and install script from bd-gvr.

## Edge Cases
- **Schema evolution testing:** When robot output changes, both the code AND the JSON Schema must be updated together. Tests should validate all command outputs against the schemas.
- **Minisign not available in CI:** If minisign is not installed, the release should still succeed but skip signing with a clear warning (don't fail the release).
- **JSON Schema draft version:** Use 2020-12 draft. Older validators may not support it — document the minimum validator version.
- **additionalProperties:false on nested objects:** The top-level schemas have it, but nested objects (data, meta) may need it too if strict validation is desired. Decision: only enforce at top level for now.


Epic: Alias Management

All 1 child bead closed: aliases command


Implement doctor command with integrity validation and --fix repair

## Background
The doctor command validates installation health and cache integrity. It checks config/cache directories exist, validates each alias's cache files (meta, index, raw), detects integrity issues (generation/hash mismatches, stale index versions, missing files), warns on insecure config permissions, and validates all index pointers against raw.json. Optional --fix repairs recoverable issues.

## Approach
Implement src/cli/doctor.rs with DoctorArgs and execute():

**Checks performed:**
1. Config directory exists and is readable/writable
2. Cache directory exists and is readable/writable
3. For each alias: check meta.json exists, validate generation/index_hash/index_version against index.json, validate raw_hash against raw.json, validate all operation_ptr/schema_ptr resolve in raw.json
4. Detect stale caches (>30 days, configurable via config)
5. Check config.toml permissions -- warn if group/world readable when auth tokens present
6. Report disk usage (per-alias and total)

**--fix repair modes (per alias, after acquiring lock):**
1. If raw exists but index missing/invalid or index_version mismatched -> rebuild index from raw
2. If raw + index valid but meta missing -> reconstruct meta from raw + index
3. If raw unreadable/unparseable -> delete alias (last resort)

**Health status:** HEALTHY (no issues), WARNING (stale caches, permission issues), DEGRADED (some aliases have integrity issues but are fixable), UNHEALTHY (unfixable corruption).

## Acceptance Criteria
- [ ] doctor reports HEALTHY for a valid cache
- [ ] doctor detects missing meta.json as integrity issue
- [ ] doctor detects generation mismatch between meta and index
- [ ] doctor detects invalid operation_ptr (pointer doesn't resolve)
- [ ] doctor warns on stale caches (>30 days)
- [ ] doctor warns on insecure config permissions
- [ ] --fix rebuilds index from raw when index is invalid
- [ ] --fix reconstructs meta when meta is missing but raw+index exist
- [ ] --fix deletes alias only when raw is unreadable
- [ ] Robot output: health status, per-alias status, warnings[], disk_usage

## Edge Cases
- **Concurrent doctor + fetch:** Doctor reads while fetch writes. Doctor should acquire read lock or tolerate mid-write state gracefully (report as integrity issue, not crash).
- **Very large cache (hundreds of aliases):** Doctor must not OOM — process aliases one at a time, not load all into memory.
- **Permission denied on cache directory:** Report as WARNING, not crash. Doctor should be resilient to partial access.
- **Empty alias directory (no files):** Skip with warning, don't crash. This can happen from interrupted deletes.
- **--fix on locked alias:** If another process holds the lock, skip that alias with warning (don't block).

## Files
- MODIFY: src/cli/doctor.rs (DoctorArgs, execute, check_alias, fix_alias, permission_check)
- MODIFY: src/output/robot.rs (add output_doctor)
- MODIFY: src/output/human.rs (add output_doctor)

## TDD Anchor
RED: Write `test_doctor_detects_missing_meta` -- create cache with raw+index but no meta, run doctor --robot, assert alias status is "integrity_error".
GREEN: Implement per-alias integrity checking.
VERIFY: `cargo test test_doctor_detects_missing_meta`

## Dependency Context
Uses CacheManager (load_index, load_raw, list_aliases) from bd-3ea (cache read path). Uses SpecIndex and CacheMetadata types from bd-ilo (error types and core data models). Uses build_index from bd-189 (indexer) for --fix index rebuild.


Add property tests for indexer and format detection

## Background
The indexer (src/core/indexer.rs, 659 lines) handles format detection, JSON/YAML normalization, and index building. Current tests are example-based. Property tests can catch edge cases in format sniffing and ensure build_index invariants hold for arbitrary valid OpenAPI structures.

## Approach
1. Create tests/property_indexer_test.rs
2. Build proptest strategies:
   - arb_openapi_spec(): generates valid-ish OpenAPI 3.x JSON with random paths, methods, summaries, schemas
   - arb_bytes_with_format(): generates bytes that are either valid JSON or YAML, paired with expected Format
   - arb_content_type(): random valid Content-Type strings (application/json, application/yaml, text/yaml, etc.)
   - arb_filename(): random filenames with .json/.yaml/.yml extensions

3. Implement 6 property tests:

   a) format_detection_idempotent: detect_format(bytes, hint, ct) called twice with same args = same Format.

   b) json_sniffing_correct: For any bytes where first non-whitespace is '{' or '[', detect_format(bytes, None, None) returns Format::Json.

   c) content_type_overrides_sniffing: When content_type_hint is Some("application/json"), result is always Format::Json regardless of bytes content.

   d) normalize_json_roundtrip: For valid JSON bytes, normalize_to_json(bytes, Format::Json) returns (original_bytes_semantically, parsed_value) where serde_json::to_value(parsed) == original parsed value.

   e) build_index_endpoint_count: For a generated OpenAPI spec with N paths and M methods per path, build_index produces sum(methods_per_path) endpoints.

   f) content_hash_deterministic: compute_hash(bytes) called twice = identical result. (This tests the indexer pipeline hash, distinct from the existing property_test.rs hash tests which test raw SHA-256.)

## Acceptance Criteria
- tests/property_indexer_test.rs exists with 6 proptest tests
- All 6 tests pass with default case count (256)
- No new dependencies
- cargo clippy --all-targets -- -D warnings passes

## Files
- CREATE: tests/property_indexer_test.rs

## TDD Anchor
These ARE the tests. Write them and verify they pass.
RED: If format_detection or build_index behaves unexpectedly, fix in src/core/indexer.rs
VERIFY: cargo test property_indexer

## Edge Cases
- YAML bytes that start with '{' (valid YAML but looks like JSON to sniffer) -- detect_format returns Json, which is correct since valid JSON is valid YAML
- Empty bytes [] -- detect_format should return Yaml (no '{' or '[' found), normalize_to_json may error
- OpenAPI spec with no paths -- build_index should produce 0 endpoints
- Spec with duplicate path+method combinations -- build_index should handle gracefully

## Dependency Context
No dependencies. Fully independent. Tests existing code without modifications.