Status: DESIGN (docs-first; pressure-test before engine code). Author: HD. Review: Cx (shape), then code review. Founder-directed (2026-06-29): fix the SHAPE, do not append rules to handle a misshapen set of information.
PB is the object architecture (the substrate: entities, frames, as-of, the structural ops —
reconcile/candidate surface, merge/reject, a future retype op). Construct is the engine that
USES it. Therefore the object-graph shape during play — how mentions are coreferenced, typed,
created, and presented — is the engine's responsibility, i.e. ours. The fix is not "wait for
PB to add a merge pass"; it is the engine producing a clean graph, using PB's object primitives.
Canon entities are created through several uncoordinated write paths. The clean ones are the
engine's deliberate ingest_structured decisions (movement/take/drop commits, clue/whereabouts,
fallout). The misshapen ones are the three free-text p.ingest(<text>) calls:
| site | turnloop.py | what it mints from |
|---|---|---|
| player-input extraction | ~1415 | the player's raw sentence |
| NPC action extraction | ~1780 | an NPC's narrated act |
| settle narrator-prose extraction | ~3094 | the rendered prose (the original sin) |
Each free-mints a new entity per surface mention, with no shared identity authority and no type discipline. PB faithfully stores the result; projection and narration faithfully propagate it. Observed live (bodycase):
- Fragmentation: one pencil →
obj:pencil,obj:pencil_1(take-mint twin),obj:plain_pencil. - Typing slip: "street" → both
place:streetANDobj:street;person:X in obj:street. - Voice/deixis phantoms:
person:unknown_speaker,person:narrator,person:/you,place:/coffee_house.
The current host band-aids (promote-gate guards for person-in-object / malformed :/ /
*narrator* holders; render directives exclusive-carry + drop-ack) cope with the misshapen graph
after it forms. That is the rule-stack the founder is rejecting (= Kernos 078's "rule-stack is the
symptom, shape is the bug"). They must be retired once this lands (Kernos 084: verified-then-strip).
Map governs narration; narration never writes the map. Canon mutates only through structured engine decisions. New diegetic objects/places enter canon ONLY through one typed, coreference-aware minter shared by every path. Re-mining structured facts out of rendered prose is the original sin.
resolve_or_mint(world, mention, *, channel, scene_ctx, protagonist, at) -> entity_id | None
A single seam every entity-write consults. In order:
- Malformed (empty / leading-
/local-part) → reject (None). (was bucket 4a) - Voice / non-referent (narration voice: narrator/speaker/unknown-voice) → None; never an entity. Voice is a frame, not a thing. (was bucket 4b)
- Deixis ("you"/"I"/"me"/"myself"/"self") → the protagonist. (was bucket 4c)
- Coreference — bind to a KNOWN entity. Match the mention against the live candidate set
(scene entities ∪ protagonist-held ∪ present cast ∪ recently-introduced) via the identity matcher.
A unique high-confidence match binds; the existing entity's
kindis authoritative (no retype). - Typed mint — only if genuinely novel AND plausibly present. Mint ONE entity with the
kindthe channel dictates (move →place; take/drop/handle →obj; cast →person). One referent, one id, correct kind. (killsplace:streetvsobj:street) - Else → None: no canon write; the narrator handles it diegetically (improv that isn't acted on stays prose, not canon).
The matcher (step 4) reuses the cheap whole-token _names_entity against the live set; the harder
cross-chunk cases are reconciled AFTER THE FACT by PB's reconcile() — a host-invoked global
finalize pass, never a per-mention lookup (NOT reimplemented, and never called live).
Bind requires a UNIQUE match — ambiguity yields NO canon write (never a sibling), per the LOCKED
DECISIONS below. Zero candidates + a sanctioned kind → typed mint; multiple → drop.
Two candidates — recommend (B):
- (A) Drop prose-extraction-as-canon entirely. Cleanest map-governs, but loses passive improv permanence ("the drawer's papers" the narrator mentions never persist unless acted on). Too lossy.
- (B) Keep prose-extraction, but route every proposed (entity,attr,value) through the resolver before the promote gate: entities bind-to-known or mint-typed, voice/deixis resolved, malformed dropped. The promote gate keeps ONLY its arc-key contradiction guard (protects the hidden answer); the pattern band-aids (person-in-object / malformed / narrator-phantom) become unreachable and are deleted. Preserves "a mug from the bar exists" while killing fragmentation at the source.
_grant_taken_object / _grant_moved_place currently mint independently → twins. They call
resolve_or_mint: a take/move first binds an existing scene entity when one matches (no
obj:pencil_1), mints typed only when novel. The take-mint and prose-extraction now share the
resolver → one pencil. _mint_held_object becomes the resolver's mint step (kind from channel).
- PB provides: entity storage, frames/as-of,
reconcile+ candidate structure,merge/reject, (future) retype. The engine USES these. - Construct owns: WHEN to resolve, the coreference decision against the live scene set, the typed mint, deixis/voice handling — i.e. the use and the meaning. This honors Kernos 083's split (structure-decidable → PB primitives; meaning/use → engine) and 084 (voice-suppress is engine-use). Because the engine mints correct-from-the-start, a retype op is not needed at the write boundary.
STRIPPED from the promote gate (the resolver subsumes them upstream — live-proven quarantined
empty on all 7 bodycase turns): _malformed_id, _is_narrator_phantom/_VOICE_PHANTOM_TOKENS, the
pronoun-phantom check, and the person-in-object quarantine. The predicates now live solely in
construct/resolve.py (is_malformed/is_voice/is_deixis).
KEPT (NOT resolver-subsumed, Cx 313): the held-object guard (the resolver does not know live
inventory or same-turn drop licensing); the arc-key/contradiction gates (concealment/canon-diff
policy, a different layer); the render directives (WHAT YOU ARE CARRYING exclusivity + JUST SET DOWN — prose-input discipline, not canon truth; kept while the residual cross-scene pencil twins
persist, pending PB bucket 1). _NARRATOR_PHANTOM/_PRONOUN_PHANTOM constants stay — the
deterministic take/drop guards still use them. Plus the deterministic player actions (now twin-free)
and the bucket-3 semantic-residue seam.
Over-binding (mis-merging a genuinely-new entity into an existing one). Mitigation: bind only on a UNIQUE high-confidence match; ambiguity → NO canon write (never a sibling, never a fresh mint). The cohesion scenarios (pencil twin, street typing, voice phantom) become the regression set, unit + live.
- Ambiguity NEVER mints. unique high-confidence match → bind; zero candidate + channel sanctions novelty + plausibility → typed mint; multiple candidates → no canon write (drop/underdetermined; narrator handles). Definite/ambiguous mentions ("the pencil", "the office") must never mint a third sibling — only a zero-candidate indefinite introduction ("a mug") mints.
extract → resolve → ingest_structured, neveringest → fix receipt. Use PB's read-onlyporcelain.extract(text, scene, extract="lean")(confirmed present, no write) → the resolver rewrites/drops rows →ingest_structured(resolved_rows). Construct is the identity/type authority BEFORE rows touch canon; PB stays the append-only gate. (Post-render may still stage in_PROPOSEDfor the contradiction diff, but extraction is read-only first.)- Resolve BOTH row positions with kind expectations. The resolver rewrites
row["entity"]AND, whenvalue_type=="entity"or the attribute is entity-valued,row["value"]. Kind constraint by subject·attribute:person:*.inexpectsplace:/person:, neverobj:;obj:*.inmay be protagonist/place/real-container by channel. If EITHER side resolves to None → drop the whole row (no half-repaired facts). This is what makes the person-in-object/malformed/voice guards unreachable, not relocated. - Map-governs = option B, tightened: rendered prose is NOT authority; extracted/render-delta rows are PROPOSALS; only the resolver + promote gate turn them into canon.
- Matcher = hybrid, bounded: cheap whole-token (
_names_entity) over a DELIBERATELY BOUNDED live set first (scene/place-chain, scene contents, scene features, protagonist-held, present cast, recent same-turn intros); PBWorld.refer(scope=bounded_ids, as_of=_h)only where the channel tolerates tier-2 (player take/move/examine targets). Neverporcelain.reconcile()per mention (it's a global finalize pass, not a live row resolver). - Locus = new
construct/resolve.py, returning(entity_id|None, reason)where reason ∈ {bound, minted, dropped_voice, dropped_malformed, deixis_bound, ambiguous, novel_denied, kind_mismatch, …} for live debugging.turnloop.pyassemblesscene_ctxand calls it. - Confidence: bind only on a unique deterministic match OR PB
referresolved above its floor. Ambiguity calls no model and never mints. Post-render narrator proposals: deterministic bind/drop in v1 (model-aided disambiguation deferred until proven necessary + traceable). - No PB retype dependency. PB lacks a first-class pre-write retype API; mint-correct-from-start
or drop. (PB provides identity closure,
refer,reconcile, proposals, guardedmerge,reject.) - Verified-then-strip, staged: FIRST move malformed/voice/deixis/held-ownership logic INTO the resolver and invert the old guard tests around the new path; DELETE the promote-gate guards + render directives (exclusive-carry/drop-ack) only after focused tests + one live bodycase run show the resolver is why they no longer fire (Kernos 084).
- Ambiguous take never mints: the take caller mints ONLY on
bind_or_mint→_why == "mint";ambiguous/dropped → no canon write + atrace.resolverreceipt. - No retype of an existing entity: a
kindrow whose subject BOUND to a known entity is dropped (bound_kind_skipped) — kills the cross-prefix{place:street, kind, object}retype. - Free-text never mints a place:
_FREE_TEXT_MINT_KINDS = {obj, person, fact, event, doc}— a novelplace:from prose is dropped (novel_denied); the deterministic MOVE channel is the sole place-minting authority (bind_or_mint(kind="place")still mints). - A place-like
obj:is a mistyped place → denied (Cx 308): a zero-candidate free-textobj:whose declared KIND is in_PLACE_LIKE_KINDS(street/road/room/office/…) is dropped (place_like_obj_denied), soobj:street kind streetcan't mint and reform the split. Judged on the KIND value only (NOT id-stem —obj:office_keyis a real object). Interim host typing signal pending PB's engine retype path (Kernos 083 bucket 2). Together with the above, the place/obj split cannot form from free text. - Same-kind bind guard (Cx 308):
_match_onecompares the bare namespace (person/obj/place, no colon — the colon comparison was dead).obj:↔place:cross-binds (the typing-slip retype);obj:→person:does NOT (would write object facts onto a person). trace.resolverAPPENDS across all sites (player-input/NPC/settle), never overwrites.
The founder's robot-vacuum ruling: a proper-named detail the WORLD establishes ("The Hart and Bell") becomes solid immediately as a minimal STUB; engagement paints the rest.
is_proper_named(name)— resolver-side predicate (NOT the session display helper): strip an optional leading article, then every significant token must be capitalized (connectivesand/of/de/…may stay lower). Case is the evidence → a lowercasing extractor fails CLOSED. Accepts "The Hart and Bell"/"John Johnson"/"Dr. Ames"; rejects "the street"/"the wrapped crown"/"the landlord of the Hart and Bell".- Narration channel only: the settle site passes
_NARRATION_MINT_KINDS(obj/fact/event/doc — person LEFT the free set on this channel) +_NARRATION_STUB_KINDS({place, person}). Player-input (Bradford Clemensepin) and NPC-action channels untouched. - Stubs are minimal, non-present (
_STUB_ATTRS): place → kind/name/(iniff the container BINDS to an existing place); person → kind/name/role/title, neverin. Everything else trims (stub_trimmed). A stub NEVER enters the VALUE position (stub_value_denied) — no relocation/presence effects through the stub gate. - Roster-wide bind-before-mint: the settle candidate set unions every NAMED canon
place:/person:(horizon-safe), so a second mention scenes away BINDS instead of minting a twin. Ambiguity still drops. - Live probe (2026-07-04, letter 442): the lean extractor DOES surface the
mentions (no PB routing needed) but emits NO
namerows — the cased evidence never arrived. Fixed host-side:resolve.reconstruct_names(rows, prose)recovers the id-stem's cased span from the prose verbatim and injects a SYNTHETIC name row;is_proper_namedjudges the real casing (fail-closed: no span → no stub). Synthetic names commit ONLY with a stub mint (synthetic_name_skippedon bound entities). The probe's own over-production validates the gate: "Brackenmere village"/"coach yard" reconstruct lowercase and stay denied.
- player-input names "plain pencil" while scene
obj:pencilexists → binds, noobj:plain_pencil. - take after passive prose "a mug" → binds the same mug, not
obj:mug_1. - move vs take channel mints
place:vsobj:for "street", never both. - entity-valued repair/drop:
obj:pencil in person:unknown_speaker,person:/you in place:/coffee_house,person:you in obj:streetnever reach canon. - ambiguous known candidates → no fresh third entity.
- protected arc-key quarantine still wins after resolver rewriting.
- terminal/curtain prose still mints nothing.
- (B) vs (A) for narration-as-canon-source — confirm (B) (preserve improv permanence, route through resolver) is the right call, or is full map-governs (A) cleaner long-term?
- Matcher: cheap token
_names_entityagainst the live set vs per-mention PBreconcile(heavier). Hybrid (token first, reconcile on ambiguity)? Latency vs accuracy. - Resolver locus: new
construct/resolve.pyconsulted by every write, vs inline in the loop. - Bind confidence threshold (when does a mention bind vs mint) — and whether ambiguity should ever ask the model (bucket-3-style) or always mint-fresh.
- Anything PB-architecture-side this assumes that doesn't exist yet (so we route it correctly).