feat(talent): schema-bounded JSON for morning briefing
morning_briefing now emits schema-bounded JSON via the new morning_briefing.schema.json file. The schema is fully bounded and does not require any check_schema_bounds.py ALLOWLIST entry. A day-level formatter registry entry renders the JSON output back to markdown for the search index and the identity CLI, preserving the five ## headings and the morning_briefing indexer agent string that entities/context.py's NOISE_AGENTS depends on.
morning_briefing_path() previously hard-coded output_format="md", the single hard-coded lie in the path authority for this talent. It now derives the extension from the talent's declared output format through a cached _briefing_output_format() helper, so briefing_cmd's day_dirs() loop does not re-read the talent config once per day.
The consumer migration is a clean break, not a compatibility shim. _load_briefing_md and _BRIEFING_SECTIONS are deleted, and Home, voice, and the identity CLI all read through the new shared solstone/think/briefing.py loader and renderers. This incidentally removes the solstone.think.voice.tools -> solstone.apps.home.routes import, a think-to-apps dependency inversion logged as a follow-up during recon.
A post_process hook was considered and declined. The empirical finding was that _run_post_hooks runs before _write_output on both talents.py:1375 and talents.py:1673, while _schema_validation_clean at talents.py:1087 rejects on the provider's gen_result["schema_validation"] before re-validating the post-hooked string, so a post-hook cannot repair a required key the model omitted. Because every schema root is additionalProperties: false, a post-hook cannot inject an undeclared key either. OpenAI strict mode forbids optional properties, so "declared but not required" is unavailable; the model must emit the key regardless, leaving the hook's only residual value as overwriting values the model was already forced to produce. talents.py:1822 also pops template_vars off modifications and never stores it on config, so a post-hook cannot even see the pre-hook packet without inventing a new pre-hook return-key convention that exists nowhere else in the repo. Instead, the pre-hook collapses generated, model, source_counts, source_gaps, and coverage_preamble into one $briefing_metadata template var built with json.dumps(..., indent=2), retiring the two-space YAML fragment _render_source_counts used to emit and removing the JSON-escaping hazard for the preamble prose. date is dropped entirely because the path encodes the day.
Only lowercase $briefing_metadata is safe in the prompt. _apply_template_vars at talents.py:852-861 registers a .capitalize() alias that would lowercase the rest of the JSON string, so the prompt uses the lowercase token only and a test pins that constraint.
tests/eval_schemas.py now runs schema_path cases through hydrate_runtime_enums so the eval validates the runtime schema the provider actually receives. Without this, the reading[].facet __RUNTIME_FACETS__ sentinel would force the eval model to emit that literal string and the new golden case could never pass. reading[].facet carries both the sentinel enum and a maxLength because hydrate_runtime_enums drops the enum key on a zero-facet journal and the string would otherwise ship to the provider unbounded.
_strip_outer_markdown_fence runs only for output_format == "md" at talents.py:1658-1668, so JSON output is not fence-stripped. The prompt's explicit no-fence instruction is the only mitigation, matching every other output: json talent. This is not a new condition, but it is recorded here so the next reader does not assume the runner strips JSON fences.
max_output_tokens: 8192 now matches documents; the talent previously inherited the 8192 * 6 default silently. degradation_check: true is retained for the same reason 9654c0d0 retained it on documents: four other json-output talents set it, so briefing is not special. A near-empty briefing sits under the MIN_OUTPUT_TOKENS = 300 floor and could be falsely flagged degraded on a sparse day; this remains a follow-up risk.
The two API baselines flip legitimately. tests/baselines/api/sol/talents-day.json moves output_format from md to json. tests/baselines/api/stats/stats.json moves output from md to json and additionally gains schema and max_output_tokens because that API exposes raw prompt frontmatter and every other talent with those fields already shows them there. Nothing else in either file moved.
Behavior is otherwise held constant across the web contract. briefing_sections stays a dict of markdown strings and briefing_needs_deduped stays an array of strings, so home.js and workspace.html are untouched. Needs items are now objects carrying text plus a sol:// source_id, which needs_dedup_key resolves by identity; inline [label](sol://...) links stay inside text so the existing parse_sol_sources fallback keeps working. _briefing_summary now counts meetings from structured your_day items with a non-empty time rather than regexing - **HH:MM**, and produces a byte-identical string for equivalent content.
One deliberate behavior change remains: reading links now resolve to facet=<slug>, for example facet=work, where the model previously wrote **Work** and produced facet=Work. No test pinned the old casing, and the slug is what /app/search expects.
The local Qwen provider is not installed on this machine, so the eval was not executed and no pass is claimed. The command below was run with this output.
```
$ make eval-schemas; echo "exit=$?"
.venv/bin/python tests/eval_schemas.py
Local schema eval requires the bundled local provider. Run `journal install-provider local`, then start it with `journal start` (or `journal service start` for an installed service).
make: *** [Makefile:234: eval-schemas] Error 2
exit=2
```
Follow-ups deliberately not done: pulse.schema.json still has 5 unbounded nodes and steward.schema.json still has 2, both allowlisted under their "morning_briefing follow-on lode" reason, following the convention that the string names the lode a following lode will bound. solstone/apps/speakers/status.py's meetings_files counter still has the same wrong-path bug 9654c0d0 fixed for screen_files. docs/design/yesterdays-processing-card.md and docs/design/voice-server.md still describe frontmatter-based briefing reads in narrative prose beyond the one-line path references corrected here. No confidence field was added to briefing items; evidence strength stays prose hedging because a field would change rendering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>