diff --git a/docs/design/native-sol-client/00-prep-findings.md b/docs/design/native-sol-client/00-prep-findings.md new file mode 100644 index 000000000..c36a894b2 --- /dev/null +++ b/docs/design/native-sol-client/00-prep-findings.md @@ -0,0 +1,277 @@ +# Native `sol` Client Prep Findings + +## 1. VPE Frozen-Input Availability (P0) + +### Decision Verdict + +| Frozen input | Required pin / shape | Search result | Verdict | +|---|---:|---|---| +| `sol-call-grammar-v1` grammar oracle | 174 entries; canonical length 113,106 bytes; SHA-256 prefix `c61078f0...dd117` | No artifact, ref, blob, tag, note, stash, branch, working-tree file, or `~/.hopper/` file found by the exact searches below. | **OPEN BLOCKER: missing.** | +| 20-file Python-oracle tree digest | SHA-256 `1d14f01a819f2f44bfe229603aa38861cda3460ff1ca66b9593a33b6172a772d` | No explicit manifest found. Plausible 20-file sets did not reproduce the pin. | **OPEN BLOCKER: unreproducible.** | +| Parity corpus | argv/help/stdout/stderr/exit/request-shape vectors plus permitted normalizations | No target corpus found. Existing parity material is unrelated ad hoc test/fixture material, not the requested fixed VPE corpus. | **OPEN BLOCKER: missing.** | + +### Search Evidence + +| Scope | Commands / recipe | Result | +|---|---|---| +| Git refs | `git branch --all --verbose --no-abbrev`; `git tag --list`; `git notes list`; `git stash list`; `git log --all --grep=...`; `git log --all --name-status -- '*grammar*' '*parity*' '*oracle*' '*corpus*' '*native-sol*' '*vpe*'` | No target artifact hits. `git notes list` and `git stash list` were empty. | +| Exact all-ref greps | `git for-each-ref --format='%(refname)' refs/heads refs/remotes refs/tags refs/stash refs/notes | xargs ... git grep -n -I -F ` for `sol-call-grammar-v1`, `c61078f0`, full `1d14...a772d`, and `request-shape` | No output. | +| Named branches | `git merge-base --is-ancestor hopper-yhtv3ubz-grammar-prep main`; `git log main..hopper-yhtv3ubz-grammar-prep`; same for `vpe/finding1-recovery-ui` | Both branches are ancestors of `main`; no unique commits. | +| Working tree | `git ls-files | rg 'grammar|parity|oracle|corpus|native-sol|vpe|request-shape|openapi|contract'`; `rg -n -S --glob '!.git/**' --glob '!.venv/**' --glob '!core/target/**' `; `find . ...` excluding `.git`, `.venv`, `core/target` | Only unrelated files such as existing markdown parity fixtures and normal contract/openapi files. | +| Hopper storage | `rg -n -S ~/.hopper`; `find ~/.hopper -iname '*sol-call-grammar*' -o -iname '*grammar*oracle*' -o -iname '*parity*corpus*' -o -iname '*request*shape*' -o -iname '*native-sol*' -o -iname '*vpe*'` | No output. | +| Unreachable Git objects | `git fsck --full --no-reflogs --unreachable`, then exact grep across unreachable blobs for the same needles | No target hits. | + +### 20-File Digest Reconstruction + +| Attempt | Manifest / recipe | SHA-256 result | +|---|---|---:| +| Initial plausible 20-file set | `printf '%s\n' | sort > /tmp/native_sol_candidate_paths.txt`; `xargs git ls-tree HEAD -- < /tmp/native_sol_candidate_paths.txt | sort | sha256sum` | `36c2cb291fc62a6da917ca3d17d53eddde2683492e1451d0deb025a86ae82e63` | +| Serialization variants on initial set | sorted raw `git ls-tree`; sorted-by-path `git ls-tree`; `path sha`; `sha path`; `sha` only; path only | None matched; examples included `43f82dc6...`, `e48ab2fa...`, `1a9a52c...`. | +| Brute force | Fixed likely base: `solstone/think/convey_client.py`, `solstone/think/utils.py`, `solstone/think/service.py`, `solstone/apps/activities/call.py`, `solstone/apps/support/call.py`, `solstone/think/tools/health.py`, `solstone/think/pipeline_health.py`, `solstone/think/chat_cli.py`, `solstone/think/call.py`; chose 11 from 18 plausible production dependencies; checked 31,824 combinations. | No match in raw sorted `git ls-tree` format. Closest arbitrary prefix: `1d14714d5545f5e9f1817fe5ecf0be17df0f16e3172d201a886205bd5bea5f28`. | +| Brute-force serialization variants | Same 31,824 combinations across six recipes: raw sorted `git ls-tree`, path-sorted `git ls-tree`, `path sha`, `sha path`, sha-only, path-only. | No match. | + +**Decision question:** VPE's frozen inputs are **not present** and the 20-file digest is **not reproducible to pin** from plausible current-tree candidates. Because the oracle canonical serialization is nowhere specified and the digest cannot be reproduced, this is an **OPEN BLOCKER** for senior escalation. + +## 2. Grammar-Oracle Format Reconnaissance (P1) + +### Current CLI Discovery Shape + +| Fact | Evidence | +|---|---| +| `sol call` mounts `solstone/apps/*/call.py` modules that export a Typer `app`; app directory names become subcommands. | `solstone/think/call.py:33`, `solstone/think/call.py:53`, `solstone/think/call.py:60`, `solstone/think/call.py:62`, `solstone/think/call.py:73` | +| The only app-name override is `network` -> `link`. | `solstone/think/call.py:22`, `solstone/think/call.py:24`, `solstone/think/call.py:73` | +| Built-in `sol call` Typer apps are mounted for `health`, `journal`, `ledger`, and `profile`; moved stubs are mounted for `navigate` and `identity`. | `solstone/think/call.py:89`, `solstone/think/call.py:120`, `solstone/think/call.py:125` | +| Moved stubs accept extra args/options, print `Moved to \`journal {cmd}\` — run that instead.` to stderr, and exit 2. | `solstone/think/call.py:96`, `solstone/think/call.py:102`, `solstone/think/call.py:114`, `solstone/think/call.py:115` | +| Nearest precedent extracts Typer command names only using `typer.main.get_command(app)`. | `solstone/think/skills_build.py:156`, `solstone/think/skills_build.py:159`, `solstone/think/skills_build.py:160` | +| Top-level `sol chat` is outside `sol call`; it is registered as an access command. | `solstone/think/sol_cli.py:200`, `solstone/think/sol_cli.py:204`; argparse grammar is in `solstone/think/chat_cli.py:244`, `solstone/think/chat_cli.py:252` | + +### Full `sol call` Surface Today + +Live Typer introspection of `solstone.think.call:call_app` returns **172 leaves**. The grouped leaves: + +| App | Leaves | +|---|---| +| `activities` | `create`, `get`, `list`, `mute`, `unmute`, `update` | +| `awareness` | `imports`, `log`, `log-read`, `status` | +| `body` | `day`, `status`, `window` | +| `chat` | `start` | +| `entities` | `accept-merge-candidate`, `aka`, `ambiguities`, `attach`, `detect`, `dismiss-merge-candidate`, `entity-history`, `history`, `list`, `merge`, `merge-candidates`, `move`, `network`, `observations`, `observe`, `overview`, `record-merge-candidate`, `resolve-ambiguity`, `restore-version`, `search`, `undo-merge`, `update` | +| `facets` | `accept`, `dismiss`, `list-candidates` | +| `health` | `for-range`, `full`, `pipeline`, `summary` | +| `identity` | moved stub | +| `import` | `list-staged`, `resolve-config`, `resolve-config-all`, `resolve-entity`, `resolve-staged-facet` | +| `journal` | `agents`, `export`, `facet create`, `facet delete`, `facet doctor`, `facet merge`, `facet mute`, `facet rename`, `facet show`, `facet unmute`, `facet update`, `facets`, `import`, `imports`, `merge`, `news`, `read`, `retention config`, `retention purge`, `search`, `storage-summary` | +| `ledger` | `close`, `decisions`, `get`, `list` | +| `link` | `authorized-clients`, `list`, `observer-pause`, `pair`, `private-link disable`, `private-link setup`, `private-link status`, `status`, `unpair` | +| `navigate` | moved stub | +| `profile` | `brief`, `cadence`, `full`, `list-active` | +| `settings` | `convey status`, `identity set`, `identity show`, `keys clear`, `keys set`, `keys show`, `keys validate`, `observer set`, `observer show`, `processing set`, `processing show`, `show`, `transcribe set-backend`, `transcribe show` | +| `sol` | `reset`, `set-name`, `set-owner`, `sol-init` | +| `speakers` | `attribute-segment`, `backfill`, `backfill-last-seen`, `bootstrap`, `build-from-tags`, `confirm-owner`, `correct`, `day-segments`, `detect`, `discover`, `dismiss-cluster`, `dismissals`, `identify`, `identify-operation`, `identify-operations`, `identify-undo`, `keep-separate-list`, `link-import`, `merge-names`, `owner-ready`, `presence`, `propagate-correction`, `rebuild-owner`, `reject-owner`, `resolve-names`, `seed-from-imports`, `sentences`, `status`, `suggest`, `tag-owner`, `wipe` | +| `support` | `announcements`, `article`, `attach`, `create`, `diagnose`, `feedback`, `list`, `register`, `reply`, `search`, `show` | +| `thinking` | `clear-local-endpoint`, `keys clear`, `keys set`, `keys show`, `keys validate`, `local availability`, `local bootstrap`, `local bootstrap-status`, `local models`, `local readiness`, `local status`, `providers set-active`, `providers show`, `scout check`, `scout disable`, `scout enable`, `scout refresh`, `scout status`, `set-local-endpoint` | +| `transcripts` | `read`, `scan`, `segments`, `speakers`, `stats` | + +**174 decomposition:** 174 is not current `sol call` leaves alone. Plausible decompositions are `172` current `sol call` leaves + top-level `sol chat` + one root/help/callback grammar entry, or `172` current leaves + two moved-stub special entries. The frozen format is missing, so this cannot be decided. + +### Lead Inventory Confirmation + +| Slice | Expected verbs | Actual source | +|---|---|---| +| activities (6) | `list`, `get`, `create`, `update`, `mute`, `unmute` | `solstone/apps/activities/call.py:240`, `solstone/apps/activities/call.py:332`, `solstone/apps/activities/call.py:371`, `solstone/apps/activities/call.py:510`, `solstone/apps/activities/call.py:627`, `solstone/apps/activities/call.py:657` | +| support (11) | `register`, `search`, `article`, `create`, `list`, `show`, `reply`, `attach`, `feedback`, `announcements`, `diagnose` | `solstone/apps/support/call.py:175`, `solstone/apps/support/call.py:185`, `solstone/apps/support/call.py:205`, `solstone/apps/support/call.py:223`, `solstone/apps/support/call.py:343`, `solstone/apps/support/call.py:370`, `solstone/apps/support/call.py:412`, `solstone/apps/support/call.py:460`, `solstone/apps/support/call.py:552`, `solstone/apps/support/call.py:607`, `solstone/apps/support/call.py:632` | +| health (4) | `summary`, `full`, `for-range`, `pipeline` | `solstone/think/tools/health.py:128`, `solstone/think/tools/health.py:146`, `solstone/think/tools/health.py:164`, `solstone/think/tools/health.py:185` | +| moved stubs (2) | `identity`, `navigate` | `solstone/think/call.py:123`, `solstone/think/call.py:125` | +| top-level chat | `sol chat` | `solstone/think/sol_cli.py:204`, `solstone/think/chat_cli.py:244` | + +**Delta:** the lead inventory contains 21 `sol call` leaves, but only 20 are HTTP leaves: `health pipeline` is a local wrapper around `solstone.think.pipeline_health.summarize_pipeline_day`, not a Convey HTTP request. Evidence: `solstone/think/tools/health.py:198`, `solstone/think/tools/health.py:211`; function owner `solstone/think/pipeline_health.py:255`. + +## 3. Leaf -> Route -> Reason-Code Table (P2) + +### Shared Activity Grammar + +| Mechanism | Exact behavior | +|---|---| +| Env defaults | `SOL_DAY` and `SOL_FACET` are read by `_get_sol_day()` / `_get_sol_facet()`. Required resolvers print `Error: day is required (pass as argument or set SOL_DAY).` or `Error: facet is required (pass as argument or set SOL_FACET).` and exit 1. `list` defaults day to `SOL_DAY` or today's `YYYYMMDD`. `solstone/apps/activities/call.py:30`, `solstone/apps/activities/call.py:35`, `solstone/apps/activities/call.py:38`, `solstone/apps/activities/call.py:45`, `solstone/apps/activities/call.py:48`, `solstone/apps/activities/call.py:54`, `solstone/apps/activities/call.py:57`, `solstone/apps/activities/call.py:66` | +| Stdin JSON mode | Empty/non-object stdin prints `Error: expected JSON object on stdin.`; invalid JSON prints `Error: invalid JSON on stdin: {exc}`; exit 1. `update` may pass `allow_empty=True`. `solstone/apps/activities/call.py:81`, `solstone/apps/activities/call.py:94`, `solstone/apps/activities/call.py:97` | +| Participation validation | `participation` must be a list of objects; each entry requires non-empty string `name`, role `attendee|mentioned`, source `voice|speaker_label|transcript|screen|other`, numeric non-bool `confidence`, string `context`; `entity_id` is dropped before send. `solstone/apps/activities/call.py:132`, `solstone/apps/activities/call.py:193` | +| Rendering | `--json` prints `record` payload via `json.dumps(..., indent=2, ensure_ascii=False)`; human mode prints server-provided markdown. `solstone/apps/activities/call.py:102`, `solstone/apps/activities/call.py:236`, `solstone/apps/activities/call.py:324`, `solstone/apps/activities/call.py:329` | +| Routes | Flask blueprint prefix is `/app/activities`; CLI routes live under `/app/activities/api/day//...`. `solstone/apps/activities/routes.py:56`, `solstone/apps/activities/routes.py:181`, `solstone/apps/activities/routes.py:206`, `solstone/apps/activities/routes.py:291`, `solstone/apps/activities/routes.py:353`, `solstone/apps/activities/routes.py:359` | + +### Activities + +| Leaf | Argv grammar | Request | Reason-code / stderr / exit | Render | +|---|---|---|---|---| +| `activities list` | Options `--day/-d`, `--from`, `--to`, `--facet/-f`, `--activity/-a`, `--entity`, `--source`, `--all`, `--json`. `--day` conflicts with range; `--source` must be `anticipated|cogitate|user`. | For each resolved day: `GET /app/activities/api/day/{day}/records` with params `include_hidden=1|0` and optional `facet`; `urlencode(..., doseq=True)` is available in shared client. `solstone/apps/activities/call.py:240`, `solstone/apps/activities/call.py:318`; client encoding `solstone/think/convey_client.py:129`, `solstone/think/convey_client.py:132`. | Local validation exits 1 with exact errors at `solstone/apps/activities/call.py:290` and `solstone/apps/activities/call.py:304`; route emits no activity-specific errors for this leaf. | JSON: list of records; human: `No activities found.` or markdown joined by blank lines. `solstone/apps/activities/call.py:324`, `solstone/apps/activities/call.py:329` | +| `activities get SPAN_ID` | Positional `span_id`; options `--facet/-f`, `--day/-d`, `--json`; day/facet required via args or env. | `GET /app/activities/api/day/{day}/record/{span_id}` params `facet`. `solstone/apps/activities/call.py:332`, `solstone/apps/activities/call.py:358` | `activity_not_found` -> `activity not found: {span_id}`, exit 1. Route returns `ACTIVITY_NOT_FOUND` when missing. `solstone/apps/activities/call.py:359`, `solstone/apps/activities/routes.py:201`, `solstone/convey/reasons.py:235` | JSON record or markdown. `solstone/apps/activities/call.py:365`, `solstone/apps/activities/call.py:368` | +| `activities create` | Options `--facet/-f`, `--day/-d`, `--since-segment`, `--source=user`, `--title`, `--activity`, `--description`, `--details`, `--json`. Argv mode requires `--title` and `--activity`; otherwise stdin JSON requires `title` and `activity` and may include `description`, `details`, `participation`. | `POST /app/activities/api/day/{day}/records` params `facet`; JSON includes `title`, `activity`, `source`, optional `description`, `details`, `participation`, `since_segment`. `solstone/apps/activities/call.py:371`, `solstone/apps/activities/call.py:488`; route validates body and writes record. `solstone/apps/activities/routes.py:206`, `solstone/apps/activities/routes.py:288` | Local errors: bad source, bad segment, missing title/activity. Server mappings: `activity_not_found` -> `Error: unknown activity for facet '{facet}': {activity}`; `activity_already_exists` -> `Error: activity already exists: {detail}`; `activity_invalid` -> `Error: {detail}`; all exit 1. Route also may emit `activities_busy`. `solstone/apps/activities/call.py:419`, `solstone/apps/activities/call.py:431`, `solstone/apps/activities/call.py:490`, `solstone/apps/activities/routes.py:216`, `solstone/apps/activities/routes.py:277`, `solstone/apps/activities/routes.py:280`; reasons `solstone/convey/reasons.py:230`, `solstone/convey/reasons.py:240`, `solstone/convey/reasons.py:450` | JSON record or markdown. `solstone/apps/activities/call.py:504`, `solstone/apps/activities/call.py:507` | +| `activities update SPAN_ID` | Positional `span_id`; options `--facet/-f`, `--day/-d`, `--note`, `--title`, `--description`, `--details`, `--json`. If no payload flags, stdin JSON may contain only `title`, `description`, `details`. Empty patch is rejected. | `POST /app/activities/api/day/{day}/record/{span_id}/update` params `facet`; JSON `{"patch": patch, "note": note_text}`; default note is `updated fields: {sorted fields}`. `solstone/apps/activities/call.py:510`, `solstone/apps/activities/call.py:584` | Local extra fields -> `Error: disallowed update fields: {extra}`; empty patch -> `Error: update payload must include at least one mutable field.`; `activity_not_found` -> `activity not found: {span_id}`; route may emit `activities_busy`. `solstone/apps/activities/call.py:566`, `solstone/apps/activities/call.py:575`, `solstone/apps/activities/call.py:586`, `solstone/apps/activities/routes.py:291`, `solstone/apps/activities/routes.py:313`, `solstone/apps/activities/routes.py:315` | JSON record or markdown. `solstone/apps/activities/call.py:591`, `solstone/apps/activities/call.py:594` | +| `activities mute SPAN_ID` | Positional `span_id`; options `--facet/-f`, `--day/-d`, `--reason`, `--json`. | `POST /app/activities/api/day/{day}/record/{span_id}/mute` params `facet`; JSON `{"reason": reason}`. `solstone/apps/activities/call.py:627`, `solstone/apps/activities/call.py:613`, `solstone/apps/activities/routes.py:353` | `activity_not_found` -> `activity not found: {span_id}`; route may emit `activities_busy`. `solstone/apps/activities/call.py:616`, `solstone/apps/activities/routes.py:340`, `solstone/apps/activities/routes.py:342` | JSON record or markdown. `solstone/apps/activities/call.py:621`, `solstone/apps/activities/call.py:624` | +| `activities unmute SPAN_ID` | Positional `span_id`; options `--facet/-f`, `--day/-d`, `--reason`, `--json`. | `POST /app/activities/api/day/{day}/record/{span_id}/unmute` params `facet`; JSON `{"reason": reason}`. `solstone/apps/activities/call.py:657`, `solstone/apps/activities/call.py:613`, `solstone/apps/activities/routes.py:359` | Same as `mute`. `solstone/apps/activities/call.py:616`, `solstone/apps/activities/routes.py:340`, `solstone/apps/activities/routes.py:342` | JSON record or markdown. `solstone/apps/activities/call.py:621`, `solstone/apps/activities/call.py:624` | + +### Support + +Shared support behavior: support CLI uses `ConveyClient(require_service=False)`; `_support_cli` maps `ConveyUnreachableError` to two-line support fallback and exit 1, and maps other `ConveyClientError` to `err.error` and exit 1. `_check_enabled` calls `GET /app/support/api/config` and exits 1 with `Support agent is disabled in settings.` when disabled. Evidence: `solstone/apps/support/call.py:39`, `solstone/apps/support/call.py:51`, `solstone/apps/support/call.py:61`, `solstone/apps/support/call.py:71`, `solstone/apps/support/call.py:75`. + +| Leaf | Argv grammar | Request | Reason-code / stderr / exit | Render / consent | +|---|---|---|---|---| +| `support register` | No args/options. | `_check_enabled`; `POST /app/support/api/register`. `solstone/apps/support/call.py:175`, `solstone/apps/support/call.py:182`; route `solstone/apps/support/routes.py:168` | Routes: `feature_unavailable`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:171`, `solstone/apps/support/routes.py:179`; reasons `solstone/convey/reasons.py:290`, `solstone/convey/reasons.py:315` | `Registered as: {handle or ?}`. `solstone/apps/support/call.py:182` | +| `support search QUERY` | Positional `query`. | `_check_enabled`; `GET /app/support/api/articles` params `q=query`. `solstone/apps/support/call.py:185`, `solstone/apps/support/call.py:193`; route `solstone/apps/support/routes.py:370` | Routes: `feature_unavailable`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:373`, `solstone/apps/support/routes.py:383` | `No articles found.` or ` [{slug}] {title}` plus count/instruction. `solstone/apps/support/call.py:194`, `solstone/apps/support/call.py:201` | +| `support article SLUG` | Positional `slug`; option `--json`. | `_check_enabled`; `GET /app/support/api/articles/{slug}`. `solstone/apps/support/call.py:205`, `solstone/apps/support/call.py:214`; route `solstone/apps/support/routes.py:386` | Routes: `feature_unavailable`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:389`, `solstone/apps/support/routes.py:397` | JSON raw, or `# {title}` then content / `(no content)`. `solstone/apps/support/call.py:216`, `solstone/apps/support/call.py:220` | +| `support create` | Required `--subject/-s`, `--description/-d`; options `--product/-p=solstone`, `--severity=medium`, `--category`, `--skip-kb`, `--submit` default false, `--yes/-y`, `--anonymous`. | Dry run: `GET /app/support/api/diagnostics`, then dormant `POST /app/support/api/draft`. Submit: optional `GET /app/support/api/articles?q=subject`, `GET /app/support/api/diagnostics`, then `POST /app/support/api/tickets` JSON `{subject, description, product, severity, category, user_context, auto_context:false, anonymous}`. `solstone/apps/support/call.py:223`, `solstone/apps/support/call.py:257`, `solstone/apps/support/call.py:267`, `solstone/apps/support/call.py:287`, `solstone/apps/support/call.py:326`; routes `solstone/apps/support/routes.py:64`, `solstone/apps/support/routes.py:219` | Routes: draft `feature_unavailable`, `missing_required_field`, `invalid_request_value`; ticket `feature_unavailable`, `missing_required_field`, `support_portal_failed`. Shared CLI prints `err.error`. `solstone/apps/support/routes.py:72`, `solstone/apps/support/routes.py:143`, `solstone/apps/support/routes.py:149`, `solstone/apps/support/routes.py:222`, `solstone/apps/support/routes.py:230`, `solstone/apps/support/routes.py:251` | Dry run prints exact dry-run preview and captures draft. Submit path KB-confirm prompt `Still want to file a ticket?`; draft confirm `Submit this ticket?`; cancellation text as coded. `solstone/apps/support/call.py:92`, `solstone/apps/support/call.py:106`, `solstone/apps/support/call.py:299`, `solstone/apps/support/call.py:320`, `solstone/apps/support/call.py:322`, `solstone/apps/support/call.py:340` | +| `support list` | Options `--status`, `--json`. | `_check_enabled`; `GET /app/support/api/tickets` with optional `status`. `solstone/apps/support/call.py:343`, `solstone/apps/support/call.py:353`; route `solstone/apps/support/routes.py:188` | Routes: `feature_unavailable`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:191`, `solstone/apps/support/routes.py:201` | JSON raw, `No tickets found.`, or formatted ticket rows and count. `solstone/apps/support/call.py:354`, `solstone/apps/support/call.py:367` | +| `support show TICKET_ID` | Positional int `ticket_id`; option `--json`. | `_check_enabled`; `GET /app/support/api/tickets/{ticket_id}`. `solstone/apps/support/call.py:370`, `solstone/apps/support/call.py:379`; route `solstone/apps/support/routes.py:204` | Routes: `feature_unavailable`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:207`, `solstone/apps/support/routes.py:216` | JSON raw or ticket markdown with messages and attachment line `📎 {filename} ({size})`. `solstone/apps/support/call.py:381`, `solstone/apps/support/call.py:409` | +| `support reply TICKET_ID` | Positional int `ticket_id`; required `--body/-b`; option `--submit/--no-submit` default true; `--yes/-y`. | Dry run: dormant `POST /app/support/api/draft` JSON `{verb:"reply", payload:{ticket_id, content}}`. Submit: `POST /app/support/api/tickets/{ticket_id}/reply` JSON `{"content": body}`. `solstone/apps/support/call.py:412`, `solstone/apps/support/call.py:438`, `solstone/apps/support/call.py:452`; route `solstone/apps/support/routes.py:254` | Routes: `feature_unavailable`, `missing_required_field`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:257`, `solstone/apps/support/routes.py:263`, `solstone/apps/support/routes.py:271` | Dry run text `DRY RUN — nothing was sent. Re-run with --submit to actually send this.`; submit confirm `Send this reply?`; false -> `Cancelled.`; success -> `Reply sent to ticket #{ticket_id}.` `solstone/apps/support/call.py:435`, `solstone/apps/support/call.py:448`, `solstone/apps/support/call.py:449`, `solstone/apps/support/call.py:457` | +| `support attach TICKET_ID FILE...` | Positional int `ticket_id`; one or more file paths; option `--submit/--no-submit` default true; `--yes/-y`. | Dry run: exactly one file, multipart `POST /app/support/api/draft` with `file` field and form `verb=attach`, `ticket_id`. Submit: multipart `POST /app/support/api/tickets/{ticket_id}/attachments` for each file. `solstone/apps/support/call.py:460`, `solstone/apps/support/call.py:491`, `solstone/apps/support/call.py:540`; routes `solstone/apps/support/routes.py:64`, `solstone/apps/support/routes.py:277` | Local dry-run multi-file error `Attach one file at a time when preparing a draft for review.`; missing local file `Error: file not found: {f}`. Routes: `feature_unavailable`, `missing_required_field`, `invalid_request_value`, `support_portal_failed`; per-file submit failures print `Skipped {name}: {err.error}` and continue. `solstone/apps/support/call.py:480`, `solstone/apps/support/call.py:518`, `solstone/apps/support/call.py:547`; route validation `solstone/apps/support/routes.py:75`, `solstone/apps/support/routes.py:89`, `solstone/apps/support/routes.py:98`, `solstone/apps/support/routes.py:283`, `solstone/apps/support/routes.py:298` | Dry run text `DRY RUN — nothing was sent. Re-run without --no-submit to upload this.`; submit review `--- Attachment Review (ticket #{id}) ---`; prompt `Upload these files?`; false -> `Cancelled — nothing was sent.`; success `Attached: {name} (id: {id or ?})`. `solstone/apps/support/call.py:510`, `solstone/apps/support/call.py:522`, `solstone/apps/support/call.py:535`, `solstone/apps/support/call.py:537`, `solstone/apps/support/call.py:549` | +| `support feedback` | Required `--body/-b`; options `--product/-p=solstone`, `--anonymous`, `--submit` default false, `--yes/-y`. | Dry run: `GET /app/support/api/diagnostics`, then `POST /app/support/api/draft` with `{body, product, anonymous}`. Submit: `POST /app/support/api/feedback` JSON `{body, product, anonymous}`. `solstone/apps/support/call.py:552`, `solstone/apps/support/call.py:574`, `solstone/apps/support/call.py:584`, `solstone/apps/support/call.py:599`; route `solstone/apps/support/routes.py:334` | Routes: `feature_unavailable`, `missing_required_field`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:337`, `solstone/apps/support/routes.py:343`, `solstone/apps/support/routes.py:364` | Dry run uses `FEEDBACK_SUBJECT="feedback"`, severity `low`, category `feedback`; submit prompt `Submit this feedback{anon_note}?`; false -> `Cancelled.`; success `Feedback submitted: #{id or ?}`. `solstone/apps/support/copy.py:6`, `solstone/apps/support/call.py:572`, `solstone/apps/support/call.py:595`, `solstone/apps/support/call.py:596`, `solstone/apps/support/call.py:604` | +| `support announcements` | Option `--json`. | `_check_enabled`; `GET /app/support/api/announcements`. `solstone/apps/support/call.py:607`, `solstone/apps/support/call.py:615`; route `solstone/apps/support/routes.py:400` | Routes: `feature_unavailable`, `support_portal_failed`; shared CLI prints `err.error`. `solstone/apps/support/routes.py:403`, `solstone/apps/support/routes.py:412` | JSON raw; `No active announcements.`; otherwise icon/title/content excerpt and count. `solstone/apps/support/call.py:616`, `solstone/apps/support/call.py:629` | +| `support diagnose` | Option `--json`. No `_support_cli`, no `_check_enabled`. | `GET /app/support/api/diagnostics`; on unreachable, local fallback calls `_local_build_identity()`. `solstone/apps/support/call.py:632`, `solstone/apps/support/call.py:639`, `solstone/apps/support/call.py:640`; route `solstone/apps/support/routes.py:418` | Unreachable prints local diagnostics, then exact support fallback and exits 1. Other client errors print `err.error`, exit 1. `solstone/apps/support/call.py:641`, `solstone/apps/support/call.py:654`, `solstone/apps/support/call.py:656` | JSON raw or human diagnostics: version/platform/python/services/brain/recent errors. `solstone/apps/support/call.py:660`, `solstone/apps/support/call.py:698` | + +### Health + +| Leaf | Argv grammar | Request | Reason-code / stderr / exit | Render | +|---|---|---|---|---| +| `health summary` | Options `--day`, `--json`. | `GET /api/health/summary` params optional `day`. `solstone/think/tools/health.py:128`, `solstone/think/tools/health.py:137`; route `solstone/convey/health.py:27` | `_handle_health_error` prints `err.detail or err.error`, exit 1. Route maps `ValueError` -> `invalid_request_value` detail `str(exc)`, broad failure -> `health_report_failed` detail `health report unavailable`. `solstone/think/tools/health.py:29`, `solstone/convey/health.py:31`, `solstone/convey/health.py:35`; reasons `solstone/convey/reasons.py:65`, `solstone/convey/reasons.py:282` | JSON indent 2 or `_render_summary`. `solstone/think/tools/health.py:140`, `solstone/think/tools/health.py:143` | +| `health full` | Options `--day`, `--json`. | `GET /api/health/full` params optional `day`. `solstone/think/tools/health.py:146`, `solstone/think/tools/health.py:155`; route `solstone/convey/health.py:39` | Same as `summary`. `solstone/think/tools/health.py:156`, `solstone/convey/health.py:43`, `solstone/convey/health.py:47` | JSON indent 2 or `_render_full`. `solstone/think/tools/health.py:158`, `solstone/think/tools/health.py:161` | +| `health for-range` | Options `--day-from`, `--day-to`, `--json`. | `GET /api/health/range` params optional `day_from`, `day_to`. `solstone/think/tools/health.py:164`, `solstone/think/tools/health.py:176`; route `solstone/convey/health.py:51` | Same as `summary`. `solstone/think/tools/health.py:177`, `solstone/convey/health.py:58`, `solstone/convey/health.py:62` | JSON indent 2 or `_render_full`. `solstone/think/tools/health.py:179`, `solstone/think/tools/health.py:182` | +| `health pipeline` | Options `--day`, `--yesterday`; mutually exclusive. | **No HTTP.** Calls `summarize_pipeline_day(target)` and prints JSON. `solstone/think/tools/health.py:185`, `solstone/think/tools/health.py:198`, `solstone/think/tools/health.py:211` | Local conflict prints `--day and --yesterday are mutually exclusive`, exit 1. `solstone/think/tools/health.py:200`, `solstone/think/tools/health.py:202` | Always JSON `json.dumps(summary, indent=2, sort_keys=False)`. `solstone/think/tools/health.py:212` | + +### Moved Stubs and Top-Level Chat + +| Leaf | Grammar | Request | Exit/render | +|---|---|---|---| +| `sol call navigate ...` | Stub sub-app ignores unknown options and allows extra args. | No HTTP. | stderr `Moved to \`journal navigate\` — run that instead.`, exit 2. `solstone/think/call.py:96`, `solstone/think/call.py:115`, mounted at `solstone/think/call.py:123` | +| `sol call identity ...` | Same stub mechanics. | No HTTP. | stderr `Moved to \`journal identity\` — run that instead.`, exit 2. `solstone/think/call.py:96`, `solstone/think/call.py:115`, mounted at `solstone/think/call.py:125` | +| `sol chat MESSAGE... [--facet FACET] [-v/--verbose] [-d/--debug]` | Argparse positional `message` `nargs="*"`; option `--facet`; `setup_cli` adds shared flags. No message prints help and returns. | Opens `GET /sse/events` stream; posts `POST /api/chat` JSON `{"message": message}` plus `facet` only if truthy; degraded poll `GET /api/chat/session`. `solstone/think/chat_cli.py:95`, `solstone/think/chat_cli.py:117`, `solstone/think/chat_cli.py:155`, `solstone/think/chat_cli.py:250`, `solstone/think/chat_cli.py:252` | Detailed in section 4. | + +## 4. Edge-Case Catalog: Chat State Machine + Support Diagnose/Consent (P3) + +### Chat Degraded-Progress State Machine + +| Area | Exact behavior | +|---|---| +| Constants | `POST_TIMEOUT_SECONDS=10`, `POLL_SECONDS=2`, `IDLE_CEILING_SECONDS=240`; messages: service down, queued, live-progress-unavailable, lost-contact, empty-answer, malformed chat response, composing. `solstone/think/chat_cli.py:30`, `solstone/think/chat_cli.py:44` | +| Client / post contract | `_TimeoutSession` sets request timeout default to 10 seconds; `_post_chat` sends `POST /api/chat` and requires dict response with non-empty `use_id`; malformed 2xx/non-dict/missing use_id -> `I couldn't read the chat response.` ValueError. `solstone/think/chat_cli.py:47`, `solstone/think/chat_cli.py:130` | +| SSE open | `_open_sse` does `requests.get(base_url + "/sse/events", stream=True, timeout=(10, None))`; request exceptions return `None`; content iterator uses `chunk_size=None`. `solstone/think/chat_cli.py:95`, `solstone/think/chat_cli.py:105` | +| SSE frame parser | UTF-8 decode with replacement; CR stripped; comment lines ignored; blank line flushes accumulated `data:` lines joined by `\n`; invalid JSON and non-dict frames are ignored. `solstone/think/chat_cli.py:61`, `solstone/think/chat_cli.py:93` | +| Progress rendering | Non-verbose cortex `start`/`thinking` -> `CHAT_LIVENESS_THINKING` (`sol is thinking…`); `tool_start` -> `· {tool}`. Verbose cortex `start` -> `Provider: {provider}; model: {model}`, `thinking` -> `Thinking: {summary}`, `tool_end` -> `· {tool} done`. Chat `sol_message` with valid target uses `talent_label_for(target, "running") + task suffix`; `talent_finished` -> `Composing your answer…`. `solstone/think/chat_cli.py:192`, `solstone/think/chat_cli.py:227`; labels in `solstone/apps/chat/copy.py:8`, `solstone/apps/chat/copy.py:26`, `solstone/apps/chat/copy.py:88` | +| Deduped progress | `emit_progress` suppresses consecutive duplicate progress lines via `state["last_progress"]`. `solstone/think/chat_cli.py:281`, `solstone/think/chat_cli.py:290` | +| Terminal correlation | Cortex terminal events count only when `tract=="cortex"`, `chat_proxy is True`, and event `use_id` equals the posted logical use id. `finish` stores `result`; `error` stores reason/provider/detail. `solstone/think/chat_cli.py:303`, `solstone/think/chat_cli.py:329` | +| Fold-terminal message | A chat `sol_message` is terminal when `requested_target is None` and `origin.logical_use_id == use_id`; `_session_terminal` applies the same rule and rejects latest messages with non-null `requested_target`. `solstone/think/chat_cli.py:148`, `solstone/think/chat_cli.py:168`, `solstone/think/chat_cli.py:342`, `solstone/think/chat_cli.py:355` | +| Queued progress | If POST response has truthy `queued`, stderr emits `Sol is busy right now — your message is queued.` once. `solstone/think/chat_cli.py:37`, `solstone/think/chat_cli.py:378`, `solstone/think/chat_cli.py:379` | +| Degraded poll recovery | Reader thread sets `sse_ended` if it exits before terminal. Main loop polls every 2s; if SSE ended or idle >= 240s, it calls `GET /api/chat/session`. If terminal is recovered, stderr prints `Live progress was unavailable.` and exits through normal finish. `solstone/think/chat_cli.py:381`, `solstone/think/chat_cli.py:405` | +| Lost contact | If terminal is absent or explicit `lost_contact`, stderr prints `sol: Lost contact with Sol before it finished — check 'journal doctor'.` and exits 1. `solstone/think/chat_cli.py:414`, `solstone/think/chat_cli.py:416` | +| Empty answer | Terminal `finish` with blank/whitespace result prints `sol: Sol returned an empty answer.` and exits 1. `solstone/think/chat_cli.py:418`, `solstone/think/chat_cli.py:423` | +| Provider-readiness terminal error | Terminal `error` passes reason/provider through `solstone.convey.provider_readiness.chat_view`; stderr is `sol: {message}`, exit 1. `solstone/think/chat_cli.py:230`, `solstone/think/chat_cli.py:237`, `solstone/think/chat_cli.py:426`, `solstone/think/chat_cli.py:431` | +| Post-time failures | POST unreachable -> `sol: solstone isn't running. Start it with 'journal up' and retry.`, exit 1; other `ConveyClientError` -> `sol: {err.error}` and optional `sol: {detail}`, exit 1; malformed -> `sol: I couldn't read the chat response.`, exit 1. `solstone/think/chat_cli.py:133`, `solstone/think/chat_cli.py:138`, `solstone/think/chat_cli.py:363`, `solstone/think/chat_cli.py:373` | +| KeyboardInterrupt | Prints `\nInterrupted.` to stderr and exits 1. `solstone/think/chat_cli.py:407`, `solstone/think/chat_cli.py:409` | +| Contract fragments already exist | `chat.postMessage` covers `POST /api/chat`; `chat.session` covers `GET /api/chat/session`; root SSE covers `GET /sse/events`. `solstone/convey/chat_contract.py:29`, `solstone/convey/chat_contract.py:93`, `solstone/convey/root_contract.py:35`, `solstone/convey/root_contract.py:45` | + +### Support Diagnose and Consent + +| Area | Exact behavior | +|---|---| +| Local build fallback seam | `support diagnose` catches `ConveyUnreachableError`, calls `_local_build_identity()`, prints JSON or human local diagnostics, emits support fallback notice, exits 1. `_local_build_identity()` reads package version, then runs `git rev-parse --short HEAD` with `cwd=Path(__file__).parents[2]`, `capture_output=True`, `text=True`, `timeout=5`; failure returns `revision=None`. `solstone/apps/support/call.py:139`, `solstone/apps/support/call.py:167`, `solstone/apps/support/call.py:632`, `solstone/apps/support/call.py:655` | +| Required fallback string | Unreachable support prints exactly `I couldn't reach support because solstone isn't reachable right now.` and `To file a support ticket, visit https://support.solstone.app`. `solstone/apps/support/call.py:43`, `solstone/apps/support/call.py:49` | +| Portal default/env | External portal default is `https://support.solstone.app`; `SOLSTONE_SUPPORT_URL` overrides, then journal config `support.portal_url`, then default. `solstone/apps/support/portal.py:37`, `solstone/apps/support/portal.py:667`, `solstone/apps/support/portal.py:685` | +| Dry-run preview | `_print_dry_run_preview` prints `DRY RUN — nothing was sent. Re-run with --submit to actually file this.`, build identity, `--- Would send ---`, payload fields, JSON `user_context`, `Would POST to: {portal_url}`, and `--- End dry run ---`. `solstone/apps/support/call.py:79`, `solstone/apps/support/call.py:106` | +| Dormant draft-capture route | Dry-run/no-network capture posts exact submit-path payload to `/app/support/api/draft`; route comment says it emits backend-only `support_draft` and sends nothing to support. CLI failure is non-fatal and prints `(Draft not captured — solstone wasn't reachable to save it for review.)`. `solstone/apps/support/call.py:109`, `solstone/apps/support/call.py:136`, `solstone/apps/support/routes.py:64`, `solstone/apps/support/routes.py:71` | +| Create consent | `create` defaults to dry run. Submit path optionally does KB search, prompts `Still want to file a ticket?`, presents `--- Ticket Draft ---`, prompts `Submit this ticket?`, and false response prints `Cancelled — nothing was sent.` `solstone/apps/support/call.py:240`, `solstone/apps/support/call.py:299`, `solstone/apps/support/call.py:308`, `solstone/apps/support/call.py:323` | +| Reply consent | `reply` defaults to submit; `--no-submit` captures draft and prints `DRY RUN — nothing was sent. Re-run with --submit to actually send this.` Submit path prompts `Send this reply?`, false -> `Cancelled.` `solstone/apps/support/call.py:417`, `solstone/apps/support/call.py:435`, `solstone/apps/support/call.py:448`, `solstone/apps/support/call.py:450` | +| Attach consent | `attach` defaults to submit; `--no-submit` is single-file only and uploads to draft endpoint. Submit path validates all files, shows attachment review, prompts `Upload these files?`, false -> `Cancelled — nothing was sent.`, per-file upload failures are non-terminal. `solstone/apps/support/call.py:465`, `solstone/apps/support/call.py:480`, `solstone/apps/support/call.py:522`, `solstone/apps/support/call.py:549` | +| Feedback consent | `feedback` defaults to dry run; submit path prompts `Submit this feedback{anon_note}?`, false -> `Cancelled.`, success -> `Feedback submitted: #{id}`. `solstone/apps/support/call.py:558`, `solstone/apps/support/call.py:595`, `solstone/apps/support/call.py:604` | + +## 5. Reused-Mechanism Map: Contract/OpenAPI, Staleness, Allowlist Idioms (P4) + +### Contract / OpenAPI + +| Mechanism | Current implementation | +|---|---| +| DSL | Dataclass specs define fields, parameters, requests, responses, operations. `solstone/convey/contract/spec.py:12`, `solstone/convey/contract/spec.py:64` | +| Fragment assembly | `FRAGMENT_MODULES` is an explicit hand-list: network, observer, home, push, chat, root, voice, import. Activities/support/health are not listed. `solstone/convey/contract/assemble.py:18`, `solstone/convey/contract/assemble.py:27` | +| Reason-code vocabulary | `all_reason_codes()` scans `solstone.convey.reasons` for `Reason` instances; the OpenAPI `Error.reason_code` enum uses it. `solstone/convey/contract/assemble.py:91`, `solstone/convey/contract/assemble.py:96`, `solstone/convey/contract/assemble.py:313`, `solstone/convey/contract/assemble.py:317` | +| Build document | `build_document()` imports each fragment module and assembles operations into OpenAPI paths. `solstone/convey/contract/assemble.py:329`, `solstone/convey/contract/assemble.py:376` | +| Generated artifact | `scripts/build_openapi_contract.py` renders `build_document()` to `docs/openapi/convey-clients.json` and generated Callosum registry docs; check mode compares and prints stale-path guidance. `scripts/build_openapi_contract.py:23`, `scripts/build_openapi_contract.py:32`, `scripts/build_openapi_contract.py:57`, `scripts/build_openapi_contract.py:108` | +| Breaking-change check | `scripts/check_openapi_contract.py` compares current vs committed using `classify_changes`; failures instruct to run `make openapi` and notify native-client owners. `scripts/check_openapi_contract.py:18`, `scripts/check_openapi_contract.py:33` | +| Diff classifier | Breaking classes include removed endpoints, removed/renamed operation ids, removed request fields, new required request fields, removed parameters, removed response fields, and removed reason codes/SSE reason codes. `solstone/convey/contract/diff.py:22`, `solstone/convey/contract/diff.py:58`, `solstone/convey/contract/diff.py:239`, `solstone/convey/contract/diff.py:269` | +| Error emitter | `error_response()` returns JSON with `error`, `reason_code`, `detail`, plus optional extras, using the reason's default status unless overridden. `solstone/convey/utils.py:289`, `solstone/convey/utils.py:319` | +| Already contracted chat/root | `chat.postMessage`, `chat.session`, and `callosum.rootEvents` already exist as fragments. `solstone/convey/chat_contract.py:31`, `solstone/convey/chat_contract.py:91`, `solstone/convey/root_contract.py:37` | + +### Staleness Target Pairs + +| Pair | Makefile evidence | +|---|---| +| OpenAPI | `openapi` -> `scripts/build_openapi_contract.py`; `check-openapi` -> `scripts/check_openapi_contract.py`, build `--check`, observer-client bundle check. `Makefile:718`, `Makefile:724` | +| Journal resolution vectors | `journal-resolution-vectors`; `check-journal-resolution-vectors --check`. `Makefile:729`, `Makefile:733` | +| Journal format contract | `contract`; `check-contract` runs `contract_cli check` and build `--check`. `Makefile:735`, `Makefile:740` | +| Core fixtures | `core-fixtures`; `check-core-fixtures --check`. `Makefile:742`, `Makefile:746` | +| Skills | `skills` builds/installs router skills; `check-skill-references` runs `sol skills build --check`. `Makefile:180`, `Makefile:183`, `Makefile:714`, `Makefile:716` | +| Install checks wiring | `install-checks` includes `check-call-http-only`, `check-cogitate-prompts`, skill refs, OpenAPI, contract, core fixtures, journal vectors, and Rust gates; no `.github/workflows` files exist in this worktree. `Makefile:479`, `Makefile:571` | + +### Allowlist Self-Check Idiom + +| Gate | Pattern | +|---|---| +| `check_call_http_only` | Scans direct `solstone/apps/*/call.py`; `ALLOWLIST={}` and excluded sets are empty; `evaluate()` unions live keys and allowlist keys. Count above allowed is `NEW violations`; count below allowed is `STALE allowlist entries`; pass prints `call-http-only: pass`. `scripts/check_call_http_only.py:129`, `scripts/check_call_http_only.py:141`, `scripts/check_call_http_only.py:158`, `scripts/check_call_http_only.py:172`, `scripts/check_call_http_only.py:303`, `scripts/check_call_http_only.py:341`, `scripts/check_call_http_only.py:381` | +| `check_cogitate_prompts` | Same allowlist-by-`(file, kind)` count shape for cogitate prompt command spans; `ALLOWLIST={}`; live/allowlist union detects over-count and stale entries; pass prints `cogitate-prompts: pass`. `scripts/check_cogitate_prompts.py:12`, `scripts/check_cogitate_prompts.py:16`, `scripts/check_cogitate_prompts.py:87`, `scripts/check_cogitate_prompts.py:318`, `scripts/check_cogitate_prompts.py:355`, `scripts/check_cogitate_prompts.py:387` | + +## 6. Native Surfaces + Rust Constraints + Port-Reader Default Semantics (P5, P6) + +### Native Rust Surface + +| Area | Current state | +|---|---| +| Native root binary | `main()` collects args, delegates to `evaluate_args`, dispatches `--version`, `journal-path`, and `indexer`. Usage errors print `USAGE` to stderr and exit 64. `core/crates/solstone-core/src/main.rs:35`, `core/crates/solstone-core/src/main.rs:57`; `EXIT_USAGE=64` at `core/crates/solstone-core/src/main.rs:19` | +| CLI parser crate | `USAGE` names only `--version`, `journal-path`, and `indexer`; `Command` enum has `Version`, `JournalPath`, `Indexer`; parser rejects unknown forms with `UsageError`. `core/crates/solstone-core-cli/src/lib.rs:6`, `core/crates/solstone-core-cli/src/lib.rs:13`, `core/crates/solstone-core-cli/src/lib.rs:34`, `core/crates/solstone-core-cli/src/lib.rs:44` | +| Journal resolver | `Source` enum is `Env|Config|Source|Default`; `ResolvedJournal` carries `path` and `source`; `resolve_journal_path()` precedence is non-empty env, stripped non-empty config, checkout root `journal`, then home default. `core/crates/solstone-core-journal/src/lib.rs:14`, `core/crates/solstone-core-journal/src/lib.rs:37`, `core/crates/solstone-core-journal/src/lib.rs:70`, `core/crates/solstone-core-journal/src/lib.rs:104` | +| Config/home helpers | `read_config_journal()` reads `$HOME/.config/solstone/config.toml`, returns `Decode` on invalid UTF-8 and `None` on missing/invalid TOML/non-string; `discover_home()` uses `HOME` then passwd fallback. `core/crates/solstone-core-journal/src/lib.rs:106`, `core/crates/solstone-core-journal/src/lib.rs:122`, `core/crates/solstone-core-journal/src/lib.rs:124`, `core/crates/solstone-core-journal/src/lib.rs:135` | +| Existing tests | Native tests cover version/usage/journal path and indexer behavior. `core/crates/solstone-core/tests/version.rs:20`, `core/crates/solstone-core/tests/version.rs:39`, `core/crates/solstone-core/tests/version.rs:57`, `core/crates/solstone-core/tests/indexer.rs:54`, `core/crates/solstone-core/tests/indexer.rs:73` | +| Missing native transport pieces | No native HTTP client dependency or Convey-port reader exists today. `core/Cargo.toml` workspace dependencies list only path crates plus chrono/glob/md5/pulldown-cmark/rapidfuzz/rusqlite/serde_json/toml_edit/unicode-normalization/unidecode. `core/Cargo.toml:20`, `core/Cargo.toml:35` | + +### Port Reader / Transport Contract + +| Python contract | Exact semantics to preserve | +|---|---| +| Port file reader | `read_service_port(service)` reads `/health/{service}.port`, returns `int(text.strip())`, and returns `None` only for `FileNotFoundError` or `ValueError`. `solstone/think/utils.py:1214`, `solstone/think/utils.py:1227` | +| Default port | Installed service default is 5015; constant `DEFAULT_SERVICE_PORT = 5015`. `solstone/think/service.py:19`, `solstone/think/service.py:48` | +| Base URL | `resolve_base_url()` uses `read_service_port("convey") or DEFAULT_SERVICE_PORT` and returns `http://localhost:{port}`. Missing/empty/malformed port therefore falls back to 5015. `solstone/think/convey_client.py:82`, `solstone/think/convey_client.py:84` | +| Timeout policy | API timeout `connect=2, read=20, total=30`; upload timeout `connect=2, read=120, total=180`. `solstone/think/convey_client.py:36`, `solstone/think/convey_client.py:39` | +| Error strings | `MALFORMED_RESPONSE_MESSAGE = "I couldn't read the journal response."`; `SERVER_ERROR_MESSAGE = "The journal returned an unreadable error."`; `UNREACHABLE_MESSAGE = "I couldn't reach the journal over HTTP."`; `TIMEOUT_MESSAGE = "The journal didn't answer in time."` `solstone/think/convey_client.py:31`, `solstone/think/convey_client.py:34` | +| Error classes | `ConveyClientError`, `ConveyUnreachableError`, and `ConveyTimeoutError`; timeout reason code is `local_convey_timeout`. `solstone/think/convey_client.py:44`, `solstone/think/convey_client.py:79` | +| Decode rules | 2xx + parsed JSON returns parsed; 2xx malformed raises malformed response; non-2xx parsed JSON with `error`/`reason_code` raises `ConveyClientError(error, reason_code, detail, status, payload)`; other non-2xx raises server-error message. `solstone/think/convey_client.py:194`, `solstone/think/convey_client.py:222` | +| Query encoding | Request params are appended with `urlencode(params, doseq=True)`. `solstone/think/convey_client.py:129`, `solstone/think/convey_client.py:132` | + +### Rust Constraints / Gates + +| Constraint | Evidence | +|---|---| +| Workspace | Rust workspace resolver 3, package version 1.0.12, edition 2024, Rust version 1.95, `unsafe_code = "forbid"`. `core/Cargo.toml:1`, `core/Cargo.toml:18` | +| Toolchain | `rust-toolchain.toml` pins channel `1.97.1`, minimal profile, rustfmt/clippy, and musl/iOS/darwin targets. `rust-toolchain.toml:1`, `rust-toolchain.toml:10` | +| Dependency policy | `cargo-deny` license allowlist, wildcard deny, banned `pyo3`/`pyo3-ffi`/`cpython`, graph includes iOS, crates.io-only sources. `core/deny.toml:1`, `core/deny.toml:34` | +| Rust gates | `check-rust-fmt`, `check-rust-msrv`, `check-rust-clippy -D warnings`, `check-rust-test`, `check-rust-ios`, `check-rust-deny`, `audit`. `Makefile:146`, `Makefile:178`; `install-checks` wires them at `Makefile:555`, `Makefile:571` | +| iOS gate exclusions | `check-rust-ios` runs workspace lib check for `aarch64-apple-ios` and excludes `solstone-core` and `solstone-core-indexer-store`. `Makefile:163`, `Makefile:167` | + +## 7. Test Baseline Results (H) + +All requested gates were run through `hop check -- ` on the untouched tree. + +| Command | Result | One-line tail / note | +|---|---:|---| +| `hop check -- make check-rust-fmt` | PASS | `hop check: \`make check-rust-fmt\` exited 0` | +| `hop check -- make check-rust-clippy` | PASS | `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 6.34s` | +| `hop check -- make check-rust-test` | PASS | Doc-tests complete; unit output included `63 passed` and `17 passed`; `hop check` exit 0. | +| `hop check -- make check-rust-msrv` | PASS | `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 5.95s` | +| `hop check -- make check-rust-deny` | PASS | `bans ok, licenses ok, sources ok`; emitted existing unmatched-license allowance warnings for `BSD-2-Clause`, `ISC`, `Unicode-3.0`, `Unicode-DFS-2016`. | +| `hop check -- make check-openapi` | PASS | `observer-client-contract: pass for docs/openapi/observer-client-contract` | +| `hop check -- make check-contract` | PASS | `.venv/bin/python -m solstone.think.contract_cli build --check`; `hop check` exit 0. | + +## 8. Open Design Questions + +| Question | Why it remains open | +|---|---| +| What is the canonical serialization format for `sol-call-grammar-v1`? | The pinned grammar artifact and parity corpus were not found, and no format spec was found in refs, worktree, unreachable blobs, or `~/.hopper/`. | +| What exact 20 Python files define the frozen oracle digest? | The explicit manifest is not present. Plausible manifests and serializations did not reproduce `1d14f01a819f2f44bfe229603aa38861cda3460ff1ca66b9593a33b6172a772d`. | +| Is `health pipeline` in or out of the native HTTP-client lead slice? | It is listed in the lead inventory, but current Python is a local non-HTTP wrapper around `pipeline_health`; only 20 of the 21 lead `sol call` leaves emit HTTP. | +| Should the native client use the existing OpenAPI DSL or a new grammar fixture as the source of truth? | Contract infrastructure exists and already covers chat/root, but activities/support/health fragments do not exist yet; VPE grammar oracle format is missing. | +| Should native request-shape parity include support dry-run draft capture and local diagnostics fallback? | Current Python behavior sends dormant draft-capture requests during dry runs and locally spawns `git rev-parse` during unreachable `diagnose`; both are byte-visible behavior in the fixed slice. | +| Which Rust HTTP client is allowed? | No HTTP dependency exists today; deny policy is crates.io-only, license restricted, pyo3/cpython banned, and iOS is in the dependency graph. | diff --git a/docs/design/native-sol-client/01-oracle-repro.md b/docs/design/native-sol-client/01-oracle-repro.md new file mode 100644 index 000000000..675acb46d --- /dev/null +++ b/docs/design/native-sol-client/01-oracle-repro.md @@ -0,0 +1,76 @@ +# Native `sol` Grammar Oracle Reproduction + +## Reproduction Result + +| Check | Expected | Reproduced | Verdict | +|---|---:|---:|---| +| entries | `174` | `174` | PASS | +| serialized bytes | `120632` | `120632` | PASS | +| SHA-256 | `cfa8c95c25e14937e5f616027bd2f15d610c0fa86d542b7adc4eb5da39409ce2` | `cfa8c95c25e14937e5f616027bd2f15d610c0fa86d542b7adc4eb5da39409ce2` | PASS | + +Command run from current worktree: + +```text +.venv/bin/python scratch/native-sol/gen_grammar.py +entries=174 bytes=120632 sha256=cfa8c95c25e14937e5f616027bd2f15d610c0fa86d542b7adc4eb5da39409ce2 +``` + +Independent file check: + +```text +120632 scratch/native-sol/sol-call-grammar-v1.json +cfa8c95c25e14937e5f616027bd2f15d610c0fa86d542b7adc4eb5da39409ce2 scratch/native-sol/sol-call-grammar-v1.json +``` + +No isolated `c3eb` worktree was needed; the current tree and current `.venv` reproduced the corrected pin. + +## Entry Rules That Yield 174 + +| Rule | Reproduced detail | +|---|---| +| Root command | `root = typer.main.get_command(solstone.think.call.call_app)`; root itself is not emitted because its callback is null. Generator: `scratch/native-sol/gen_grammar.py:59`, `scratch/native-sol/gen_grammar.py:61`. | +| Path representation | Entry `path` excludes the root `call` command and is the command-name path from root, e.g. first entry `["activities","create"]`, last entry `["transcripts","stats"]`. Generator: `scratch/native-sol/gen_grammar.py:38`, `scratch/native-sol/gen_grammar.py:43`. | +| Command leaves | Non-group leaves emit `kind="command"`; reproduced count is `170`. Generator: `scratch/native-sol/gen_grammar.py:55`. | +| Group callbacks | Click groups with non-null `.callback` emit their own `kind="callback"` entry before recursing into sorted children; reproduced count is `4`. Generator: `scratch/native-sol/gen_grammar.py:47`, `scratch/native-sol/gen_grammar.py:54`. | +| Callback paths | The four callback entries are `["identity"]`, `["journal"]`, `["journal","facet"]`, and `["navigate"]`. | +| Ordering | Group children recurse in lexicographic command-name order via `sorted(command.commands)`. Generator: `scratch/native-sol/gen_grammar.py:52`. | + +Breakdown: + +```text +commands=170 +callbacks=4 +total=174 +``` + +Callback details: + +```text +identity params=1 help='Moved to `journal identity`.' +journal params=0 help='Journal search and browsing.' +journal/facet params=0 help='Facet management.' +navigate params=1 help='Moved to `journal navigate`.' +``` + +## Environment + +| Tool | Version | +|---|---:| +| Click | `8.3.1` | +| Typer | `0.21.1` | +| Source field | `c3eb606395862edf34d19865a2341f6e7f538edc` | + +Generated files are under `scratch/native-sol/`, which is ignored by `scratch/.gitignore`. + +## Minimal Generator Logic + +The throwaway generator that reproduced the pin is `scratch/native-sol/gen_grammar.py`. + +| Contract piece | Generator location | +|---|---| +| `source` and schema constants | `scratch/native-sol/gen_grammar.py:15`, `scratch/native-sol/gen_grammar.py:16` | +| Param object fields in Click declared order | `scratch/native-sol/gen_grammar.py:20`, `scratch/native-sol/gen_grammar.py:35` | +| Entry object fields: `path`, `kind`, `help`, `params` | `scratch/native-sol/gen_grammar.py:38`, `scratch/native-sol/gen_grammar.py:44` | +| Click group recursion and callback emission | `scratch/native-sol/gen_grammar.py:47`, `scratch/native-sol/gen_grammar.py:56` | +| Canonical JSON serialization with `ensure_ascii=False`, `sort_keys=True`, compact separators, and one trailing LF | `scratch/native-sol/gen_grammar.py:63`, `scratch/native-sol/gen_grammar.py:66` | +| Oracle bytes written to `scratch/native-sol/sol-call-grammar-v1.json` | `scratch/native-sol/gen_grammar.py:17`, `scratch/native-sol/gen_grammar.py:67` | diff --git a/docs/design/native-sol-client/02-design.md b/docs/design/native-sol-client/02-design.md new file mode 100644 index 000000000..861425cf3 --- /dev/null +++ b/docs/design/native-sol-client/02-design.md @@ -0,0 +1,319 @@ +# Native `sol` Client Spine + VPE-Pinned Lead Slice Design + +This design builds on: + +- `docs/design/native-sol-client/00-prep-findings.md` +- `docs/design/native-sol-client/01-oracle-repro.md` +- `docs/PORTING.md` + +No installed `sol` entry point changes in this lode. The native surface is built and verified, but Python remains the owner default. + +## 1. Crate Topology, App Ownership, Generated Aggregate + +### Decision + +Use one shared native client library, one small CLI adapter library, and one non-installed process shell. + +| Crate / area | Owns | Does not own | +|---|---|---| +| `solstone-core-sol-client` | Shared transport, journal port reader, decode/error taxonomy, request/response structs, fakeable seams, and generated aggregation mechanics under `src/generated/`. | App command vocabularies, grammar/defaults, request paths, renderers, process env, stdout/stderr, exit codes, installed Python entry points. | +| `solstone-core-sol-client-cli` | Argv-to-typed invocation adapter for the native client. It consumes the generated aggregate and returns typed outcomes such as migrated invocation, top-level chat invocation, moved stub, unsupported. | Env reads, printing, exits, HTTP, journal resolution. | +| `solstone-core-sol` process shell | Non-installed binary for test/dev execution. Reads argv/env/stdin, resolves journal with `solstone-core-journal`, reads the Convey port, builds real seams, writes stdout/stderr, returns exit codes. | App vocabularies, parsing rules, request construction, rendering logic. | +| Existing `solstone-core-journal` | Reused journal path resolver: `resolve_journal_path`, `Source`, `ResolvedJournal`. | Convey port reading; that belongs to the new client library. | + +The new native port reader lives in `solstone-core-sol-client`, takes a resolved journal path, reads `/health/convey.port`, and returns 5015 for missing, unreadable, empty, or malformed content. The process shell obtains the journal path through `solstone-core-journal`; no native layer sets `SOLSTONE_JOURNAL`. + +Do not create per-app crates for this lead slice. They add package churn without changing ownership. App-local ownership is enforced by placing native command source and declarative authority beside the real Python owners: + +| App module | Owns | +|---|---| +| `solstone/apps/activities/native/` | Activities grammar/defaults, stdin JSON validation, request construction, reason-specific stderr mapping, markdown/JSON rendering. | +| `solstone/apps/support/native/` | Support grammar, enabled precheck flow, dry-run/consent behavior, multipart metadata, support-specific unreachable text, local diagnose fallback rendering. | +| `solstone/think/tools/native/health/` | Health grammar, date selection, health rendering, local mutual-exclusion error, HTTP operation bindings including pipeline. | +| `solstone/think/native/moved/` | `identity` and `navigate` moved-stub grammar and exact exit-2 stderr, beside the `sol call` owner. | +| `solstone/think/native/chat/` | Top-level `sol chat` argparse-equivalent grammar, chat state machine, progress rendering, terminal handling, beside `chat_cli.py`. | + +Generated Rust aggregation in the shared client crate `#[path]`-includes these real-adjacent sources. Shared code may expose generic building blocks only: transport, ordered JSON value handling, query/multipart encoding, decode, errors, clock/build-identity/process-spawn traits, root dispatch mechanics, and aggregate mechanics. + +### Generated Aggregate + +Use code generation, not runtime registration. + +| Decision | Detail | +|---|---| +| App-owned authority file | Each real-adjacent native directory contains an `authority.toml` beside its Rust module. It declares command path, kind, help, params, operation id, route method/path, contract operation id, handler symbol, and whether the entry is HTTP, moved-stub, local-only, or top-level chat. | +| Generated source | `scripts/build_native_sol_inventory.py` scans `solstone/**/native/authority.toml` in lexicographic app/path order and emits `core/crates/solstone-core-sol-client/src/generated/inventory.rs`. | +| No hand-maintained central list | `lib.rs` includes only shared modules and `generated`; generated source declares path-based app modules and handler bindings. Adding a test app authority requires no shared module edit; stale generated output fails the `--check` target. | +| Loud failure | Malformed authority, duplicate path, duplicate operation id, missing handler symbol, noncanonical route path, or unsupported param shape fails generation. Missing handler symbols also fail Rust compilation because generated bindings call them directly. | +| Why not `inventory`/`linkme` | Runtime/life-before-main registration adds dependency and initialization behavior risk, conflicts with `unsafe_code=forbid` expectations, complicates iOS eligibility, and makes staleness checks weaker. Codegen matches the repo's existing build/check generated-artifact idiom. | +| God-registry lint | A new architecture lint rejects app command vocabularies, route paths, mirrored `core/crates/*/src/apps/` trees, and app switchboards in shared modules outside generated aggregate output and approved test fixtures. It uses the allowlist self-check pattern: new hits fail, stale allowlist entries fail. | + +The compatibility dispatcher is generated from the same aggregate. It may classify unported inputs as unsupported, but it must never spawn Python and must not be installed as `sol`. + +## 2. HTTP Client Dependency + +### Decision + +Use `ureq` 3.3.0 with `default-features = false` as the mature maintained synchronous HTTP client/parser for loopback HTTP. It passed the required implementation diligence gates with the new client crate still included in the iOS library graph: + +- `hop check -- make check-rust-deny`: pass (`bans ok, licenses ok, sources ok`; license-allowance warnings pre-existing style). +- `hop check -- make check-rust-msrv`: pass on Rust 1.95.0. +- `hop check -- make check-rust-ios`: pass with `solstone-core-sol-client` included. +- `hop check -- make check-rust-fmt`: pass. +- `hop check -- make check-rust-clippy`: pass. + +### Justification + +| Requirement | HTTP library decision | +|---|---| +| Localhost-only, no TLS | `ureq = { version = "3.3.0", default-features = false }`; reject non-loopback URLs at the native client boundary. | +| Dependency footprint | `ureq` passed first; no fallback candidate was evaluated. With default features disabled, TLS and compression features are not enabled for the native client crate. | +| MSRV / iOS / deny | `ureq` 3.3.0 declares Rust 1.85 and passes this workspace's Rust 1.95 MSRV, deny/license/source policy, and iOS `--lib` gate. | +| Timeout observability | Transport records timeout policy and phase: `api`, `upload`, `chat-post`, `sse-open` x `connect`, `read`, `total`. `ureq::Agent::config_builder()` exposes `timeout_connect`, `timeout_recv_response`, `timeout_recv_body`, and `timeout_global`; rendering still maps to Python's single timeout owner message where parity requires it. | +| API timeout | Connect timeout 2s, read timeout 20s, wall-clock total 30s. | +| Upload timeout | Connect timeout 2s, read timeout 120s, wall-clock total 180s. | +| Chat POST timeout | Single 10s policy matching Python's `_TimeoutSession`; exposed as the chat-post policy. | +| SSE timeout | Connect timeout 10s; no read timeout after headers by setting receive/body read timeouts to `None`. The SSE stream reader has no socket read deadline; chat's fake clock controls degraded progress. | +| Response body support | Framing, connection semantics, `Content-Length`, chunked, and connection-close body handling come from `ureq`/`ureq-proto`; `ureq::Body` documents those body-length modes. | +| Multipart | Multipart body construction remains ours so request shape is deterministic and parity-testable; `RequestBuilder::send` accepts a custom body and caller-supplied `Content-Type`. | +| SSE | The pure SSE event parser remains ours. `ureq::Body::as_reader` exposes a readable response body stream for SSE mode. | + +## 3. Grammar Oracle, Native Inventory, Four-Way Join + +### Frozen Grammar Oracle + +| Item | Decision | +|---|---| +| Fixture path | Commit the exact 120632-byte oracle at `core/fixtures/native-sol/sol-call-grammar-v1.json`. | +| Fixture guard | `scripts/check_native_sol_grammar_oracle.py` asserts schema, source hash, entries count `174`, byte length `120632`, and SHA-256 `cfa8c95c25e14937e5f616027bd2f15d610c0fa86d542b7adc4eb5da39409ce2`. | +| Production generator | Move the scratch generator logic into `scripts/build_native_sol_grammar_oracle.py`. It can reproduce the fixture from Python source for maintainers, but CI's hard guard is the frozen fixture self-check. | +| Regeneration rule | The fixture is independent evidence and is never generated from Rust. A fixture update requires explicit senior approval and a new pinned hash. The old `c61078f0` / 113106-byte pin is dead. | + +### Native Lead Inventory + +The native lead inventory is generated from app-local authority files and must equal the pinned lead subset: + +- 21 `sol call` leaves: activities 6, support 11, health 4. +- 2 moved stubs: `identity`, `navigate`. +- Top-level `sol chat` is tracked separately because it is not in the `sol call` oracle. + +| Inventory field | Purpose | +|---|---| +| `surface` | `sol-call` or `sol-chat`. | +| `path` | Command-name path, root excluded for `sol-call`; `["chat"]` for top-level chat. | +| `kind` | `command`, `callback`, or `top-level`. | +| `help` | Must match oracle for `sol-call` paths. | +| `params` | Click-compatible param objects for oracle join: name, kind, type, required, nargs, multiple, default, options, secondary, hidden, is_flag, count, flag_value. | +| `handler` | Rust handler symbol in the app module. | +| `operation_id` | Native operation id, stable across route/contract/corpus joins. | +| `http` | Method, route template, query/body/multipart model, or `null` for moved/local-only. | +| `rendering` | Named renderer owned by the app module. | + +The oracle join compares, for the 23 `sol call` entries, `path`, `kind`, `help`, and every `params` field. Chat grammar parity is enforced by the parity corpus, not the `sol call` oracle. + +### Four-Way Conformance Join + +Use a lead-slice manifest for operation-level joins. Keep the 20-file digest guard separate because the established 20-file Python manifest intentionally excludes `health.py`, `think/call.py`, and `pipeline_health.py`. + +| Join side | Key | +|---|---| +| Lead-slice manifest | `operation_id` plus `surface` and `path`. | +| App-local authority | Same `operation_id`; declares handler, grammar, route, and contract operation. | +| Server route | Canonical `(method, path_template)` plus Flask endpoint name. | +| Contract fragment | OpenAPI `operation_id`, method, path, reason codes, request/response shape. | + +Failure conditions: + +- Lead manifest entry has no app-local authority. +- App-local migrated authority is not in the lead manifest. +- HTTP authority has no matching server route. +- HTTP authority has no matching contract fragment. +- Contract fragment marked native-client lead has no matching route or authority. +- Server route marked native-client lead has no matching contract fragment or authority. +- Method/path/operation id/reason-code sets disagree across authority, route, and contract. +- App-local authority names a Python manifest file not covered by the separate 20-file digest guard, except for explicitly tagged health-route additions needed for the pipeline delta. + +## 4. Parity Corpus Format + Dual Execution + +### Corpus Location and Schema + +Commit vectors under `core/fixtures/native-sol/parity/`, grouped by app. Use JSONL for diffable append-only vectors. + +| Vector field | Meaning | +|---|---| +| `id` | Stable vector id, e.g. `activities.list.default_today.json`. | +| `surface` | `sol-call` or `sol-chat`. | +| `argv` | Full user-facing argv, including `sol`, `call` where applicable. | +| `env` | Explicit env map; missing means unset. Includes `SOL_DAY`, `SOL_FACET`, support URL env, and flags needed by the Python side. | +| `stdin` | UTF-8 text or structured file reference for stdin JSON payload mode. | +| `files` | Fixture files visible to attach flows: path, bytes or fixture ref, size, filename. | +| `clock` | Fixed wall date/time, monotonic script, and timezone where relevant. | +| `transport` | Ordered script of expected requests and canned responses/faults. | +| `expected` | stdout, stderr, exit status, and captured request sequence. | +| `normalizations` | Explicit per-vector permitted differences. Empty list means byte-exact old behavior wins. | + +Transport request shape: + +| Field | Meaning | +|---|---| +| `method` | HTTP method. | +| `path` | Path without query. | +| `query` | Ordered list of `[key, value]` pairs so repeated keys and `doseq` cases are exact. | +| `headers` | Asserted subset only; incidental headers are ignored unless listed. | +| `json` | Expected JSON body as a value. | +| `multipart` | Field names, filenames, content types, byte lengths, and form fields. Boundary bytes are normalized unless a vector pins them. | +| `timeout_policy` | `api`, `upload`, `chat-post`, or `sse-open`. | +| `response` | Status, headers, body, SSE chunks, or named transport fault. | + +### Dual Execution + +| Side | Runner design | +|---|---| +| Python | `tests/native_sol/run_python_parity.py` runs the current c3eb-equivalent Python tree. It monkeypatches Convey client construction, chat `requests.get`, clock/date seams, support build identity, and subprocess spawn. It captures stdout/stderr/exit and request shapes. | +| Native | Rust tests in `solstone-core-sol-client` load the same vectors, run through `solstone-core-sol-client-cli` plus fake seams, and capture stdout/stderr/exit/request shapes. | + +Both sides assert the same vector expectations. Python is the byte-for-byte oracle unless a vector lists a permitted normalization. + +### Determinism + +| Source | Seam | +|---|---| +| Activities default today | App date provider from the fake clock. | +| Health pipeline default today/yesterday | App date provider from the fake clock. | +| Chat `POLL_SECONDS` and `IDLE_CEILING_SECONDS` | Fake monotonic clock and fake wait primitive; no real sleeping in tests. | +| Chat SSE stream | Fake transport supplies byte chunks and EOF/interruption points. | +| Support diagnose build identity | Fake build-identity provider; no real `git rev-parse` in parity tests. | +| Support attach files | Fixture file provider controls existence, filename, byte length, and bytes. | +| Multipart boundary | Fake transport can force a stable boundary or normalize boundary bytes explicitly. | +| Process spawning | Spawn-fail seam is installed in all parity tests. Any spawn attempt fails the vector. | + +## 5. Seam Inventory + +| Seam | Rust shape | Fake injection | +|---|---|---| +| HTTP transport | `HttpTransport` trait with `send(request, policy)` and `open_sse(request, policy)`. | Scripted transport validates method/path/query/body/multipart/headers and returns canned responses or faults. | +| Clock | `Clock` trait with wall date/time, monotonic time, and wait/advance operations. | Fixed date and scripted monotonic clock; chat tests advance without sleeping. | +| Process spawn | `ProcessSpawner` trait. | Default test seam fails every spawn; migrated handlers receive no real spawner. Unsupported commands return explicit unsupported outcomes without spawning. | +| Build identity | `BuildIdentityProvider` trait. | Deterministic version/revision/platform provider for support diagnose. Real provider may read package metadata and git only in process shell context. | +| File provider | `FileProvider` equivalent for local attach paths. | Fixture-backed file existence, size, name, and byte source. | +| SSE parser | Pure parser/iterator over byte chunks into JSON object events. | Unit vectors cover comments/heartbeats, `event:` plus `data:`, multiline data joined by `\n`, one leading-space strip after `data:`, blank-line flush, clean EOF, interruption, malformed JSON ignored, non-object frames ignored. | +| Ordered JSON | Ordered value abstraction for server responses that must preserve insertion order. | Corpus asserts health pipeline pretty output after parse/re-dump. | +| Output sink | Process shell concern only. Library returns typed output buffers. | Tests capture buffers directly. | + +### Native Error Taxonomy + +| Native error | Python target | Owner-facing message | +|---|---|---| +| `Unreachable` | `ConveyUnreachableError` | `I couldn't reach the journal over HTTP.` | +| `Timeout` | `ConveyTimeoutError` | `The journal didn't answer in time.` | +| `MalformedSuccess` | malformed 2xx path | `I couldn't read the journal response.` | +| `UnreadableServerError` | non-2xx unreadable error path | `The journal returned an unreadable error.` | +| `ReasonRejected` | non-2xx reason-coded JSON | Server `error`, `reason_code`, `detail`, `status`, payload. | + +App modules may render app-specific wrappers over these errors only where Python does today, such as support unreachable and chat service-down messages. + +## 6. Health Pipeline Server Route + +### Decision + +Add exactly one server behavior: `GET /api/health/pipeline?day=YYYYMMDD`. + +| Requirement | Design | +|---|---| +| Explicit day | Server requires `day`. It does not compute today, yesterday, or read CLI flags. | +| Date validation | Missing or noncanonical `day` reuses existing health/convey reason-code behavior; the prior `INVALID_DAY` idea is retired for this route. | +| Calculation | Route delegates to `summarize_pipeline_day(day)`. | +| Calculation failure | Preserve owner-visible bytes/status/exit from the current Python pipeline path. Do not add new clean `ConveyClientError` handling around pipeline failures. | +| Client behavior | Python and native clients keep `--day` / `--yesterday` / default-today selection and exact mutual-exclusion stderr `--day and --yesterday are mutually exclusive`. | + +### Key-Order Landmine + +`sol call health pipeline` currently prints `json.dumps(summary, indent=2, sort_keys=False)` from the local `summarize_pipeline_day()` dict. The HTTP cutover must not reorder keys. + +Design: + +- The Flask route must not use `jsonify` for pipeline. +- It returns an `application/json` response built from Python `json.dumps(summary, ensure_ascii=False, separators=(",", ":"))` so the wire object order is `summarize_pipeline_day()` insertion order. +- Python client parses with `json.loads`, which preserves object insertion order, then prints `json.dumps(summary, indent=2, sort_keys=False)`. +- Native client parses into an ordered JSON value and pretty-prints in insertion order with Python-compatible two-space JSON formatting for this command. +- Contract and parity fixtures pin a representative pipeline summary to prove route wire order, Python post-cutover output, native output, and pre-cutover output remain byte-identical. + +Required fixtures: + +| Fixture | Proves | +|---|---| +| `health.pipeline.day` | Explicit `--day` request shape and byte-identical pretty output. | +| `health.pipeline.default_today` | Fixed-clock default date selection stays client-owned. | +| `health.pipeline.yesterday` | Fixed-clock yesterday selection stays client-owned. | +| `health.pipeline.mutual_exclusion` | Exact stderr and exit 1 without HTTP. | +| `health.pipeline.server_missing_day` | Direct route returns `missing_required_field`. | +| `health.pipeline.server_invalid_day` | Direct route returns `invalid_day`. | +| `health.pipeline.failure` | Direct route returns `health_report_failed`. | + +This is the only permitted Python behavior delta in the lode. + +## 7. Static Checks + `install-checks` Wiring + +### New Checks + +| Check | Asserts | Make target | +|---|---|---| +| Grammar oracle self-check | Fixture schema/source/count/bytes/SHA match the corrected pin. | `check-native-sol-grammar-oracle` | +| 20-file digest guard | `git ls-tree HEAD -- <20 files> | LC_ALL=C sort | sha256sum` equals `1d14f01a819f2f44bfe229603aa38861cda3460ff1ca66b9593a33b6172a772d`. This runs against HEAD. | `check-native-sol-python-manifest` | +| Native lead inventory staleness | Generated inventory source and JSON report match app-owned authorities; inventory equals pinned lead subset; test app fixture proves no central edit is needed. | `check-native-sol-inventory` | +| Oracle subset join | Native `sol call` lead entries match frozen oracle `path`, `kind`, `help`, and `params`; chat excluded and covered by parity. | part of `check-native-sol-inventory` | +| Four-way conformance join | Lead manifest, app-local authority, server route, and contract fragment agree; omissions from any side fail. | `check-native-sol-conformance` | +| Contract-route coverage | Activities/support/health fragments are present and their route method/path/reason codes match Flask routes. | `check-native-sol-contract-routes` and extended `check-openapi` | +| Architecture lint | No app vocab/paths/renderers in shared modules; no god registry; no app switchboard outside generated files; migrated handlers do not depend on compatibility dispatch or process spawn. Allowlist fails on new and stale entries. | `check-native-sol-architecture` | +| No Python spawn | Static lint plus spawn-fail seam tests prove migrated commands never spawn Python; unsupported commands return explicit unsupported outcomes. | `check-native-sol-no-python-spawn` | +| Parity corpus | Python and native runners execute the same vectors and match stdout/stderr/exit/request shapes modulo declared normalizations. | `check-native-sol-parity` | + +### Wiring Order + +Wire into `install-checks` before the existing Rust gates and before `check-openapi` where relevant: + +1. `check-native-sol-grammar-oracle` +2. `check-native-sol-python-manifest` +3. `check-native-sol-inventory` +4. `check-native-sol-conformance` +5. `check-native-sol-contract-routes` +6. `check-native-sol-architecture` +7. `check-native-sol-no-python-spawn` +8. `check-native-sol-parity` +9. Existing `check-openapi`, extended to include activities/support/health fragments and regenerated `docs/openapi/convey-clients.json` in implementation. +10. Existing Rust format/MSRV/clippy/test/deny/iOS gates. + +No `.github/workflows` changes are needed; repo CI flows through `make ci`, and `make ci` flows through `install-checks` plus tests. + +## Implementation Order + +1. Commit frozen fixtures and self-checks: grammar oracle fixture, 20-file manifest file, check scripts, and Makefile targets. +2. Add app-owned authority format and generator with a tiny test authority to prove zero central-list edits. +3. Add Rust crate scaffolding with SPDX headers and empty shared/app modules. +4. Add `ureq`-backed transport, port reader, decode/errors, ordered JSON, and SSE parser behind seams. +5. Add activities/support/health/moved/chat app modules in the lead-slice order, each with parity vectors before handler completion. +6. Add health pipeline server route and OpenAPI fragments for activities/support/health. +7. Add four-way conformance, architecture lint, no-spawn check, parity runners, and install-checks wiring. +8. Run the narrow and then full requested gates in implementation, not in this design stage. + +## Risks And Open Questions + +| Risk / question | Design response | +|---|---| +| Ordered JSON for health pipeline can drift in Rust. | Use an ordered value representation and parity fixtures; do not rely on default `serde_json::Map` ordering unless configured to preserve order. | +| HTTP transfer semantics may drift from Python. | Use `ureq`/`ureq-proto` for Content-Length, connection-close, and chunked body support; keep multipart construction and the SSE parser parity-tested in native code. | +| Authority TOML could become a second registry. | It is app-owned, per-app, and generated into aggregate source; central shared hand lists are forbidden by architecture lint. | +| 20-file digest does not cover health code. | Treat the 20-file digest as source-baseline evidence only. Operation coverage comes from the lead-slice manifest and four-way join, including health. | +| Support consent flows are easy to over-abstract. | Keep all support-specific copy, dry-run, draft capture, and diagnose fallback in `apps/support`; shared transport only returns generic errors. | +| Compatibility dispatcher could become a fallback. | It only classifies unsupported inputs and returns an explicit unsupported outcome. No Python spawn path exists in native migrated handlers. | + +## Closing Confirmation + +- The 20-file digest guard runs against HEAD and is designed to stay green. +- The installed Python `sol` default, packaging, entry points, and managed wrappers remain untouched. +- No Python fallback exists for migrated native commands. +- No second hand-maintained command registry is introduced. +- No native code sets `SOLSTONE_JOURNAL`. +- Every new Rust/Python/generated source file gets the repository SPDX header. +- No plugin system, runtime-loaded modules, network contract fetching, or telemetry is introduced. +- `health pipeline` is the only permitted Python behavior delta, and only to move its calculation behind `GET /api/health/pipeline?day=YYYYMMDD` while preserving CLI grammar, date selection, rendering, errors, and exit behavior.