feat(schemas): name-first relational layer for story + entity_observer
Add a bounded relational layer to two generation schemas. Models emit entity
names; Python resolves them to entity IDs after generation. An unresolved name
never becomes a fabricated ID.
story.schema.json gains a required top-level `relations` array
({from, to, kind, note, quote}) and a required nullable `counterparty` on
decisions. story.py resolves relation endpoints and decision counterparties
through the existing find_matching_entity(fuzzy_threshold=90) path, leaving
*_entity_id as None on a miss while the name and note/quote evidence survive
to disk. Relations with a kind outside the closed 8-value enum, or kind
"other" with no note, are skipped with a warning -- mirroring the existing
ALLOWED_RESOLUTIONS precedent.
entity_observer operations gain an optional nullable `relation` component with
a model-emitted target_name. An unresolvable target drops the whole op, logs a
warning, and increments a `relation_unresolved` counter surfaced in
<day>_observer_outcome.json. A relation-bearing op lands complete or not at
all.
Relation persistence stays with the L2 write owners: activity records via
activities.py::merge_story_fields, observations via
entities/observations.py::_new_observation. The app hook writes neither.
The relation-kind vocabulary lives in exactly one Python frozenset
(story.ALLOWED_RELATION_KINDS) plus the two JSON enums, with parity tests.
Both schemas are now fully bounded (maxItems on every array, maxLength on
every free-text string, including nullable ones), so their
check_schema_bounds.py allowlist entries are deleted -- the guard fails CI on
stale entries. max_output_tokens=12288 on the three story talents is derived
from the bounded schema's 31192-char theoretical maximum (~8912 tokens, within
0.8x the budget); the resulting LLM request timeout is 286s.
test_schema_prep.py's byte-identity snapshot asserted that no shipped schema
carries provider-stripped keywords -- a claim the schema-bounds ratchet is
designed to falsify. Replace it with the durable invariant: prep always yields
a provider-supported subset, is a no-op exactly when the schema has no
unsupported keywords, and demonstrably rewrites the schema when it does. Add
explicit provider-behavior tests for the newly bounded schemas (local keeps
bounds; openai/google lose maxLength; anthropic loses maxLength and maxItems),
guarded against passing vacuously.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>