You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Campaign titles cannot be resolved before a session exists, so the selection screen shows raw keys
Picking a campaign is the first thing a player does, and it is the one screen no client can
render properly. CampaignSummary carries a titleKey, and the only way to turn a key into
text is getStrings(sessionId) — but selection happens before a session exists. Every
client is left choosing between printing the key and reaching past the store for the string.
Reproduced on all three surfaces:
Text client.npm run demo from src/engine/ prints the key where the title belongs:
renderCampaignList (src/engine/src/clients/text/render.ts:87) already documents this
in a comment, and client.test.ts's first case is named "listCampaigns — returns the real campaign, unresolved titleKey (no session yet)" — the
gap is asserted as correct behaviour today.
MCP.list_campaigns (src/engine/src/mcp/server.ts:54) has the identical problem and
is arguably worse off: get_strings is the only resolution tool and it requires a sessionId, so an agent must either string-match the key or call start_game — a
side-effecting command — purely to read a title. server.test.ts:39 asserts the raw key.
Browser demo.site/src/play/composition.ts:158 resolves it by reaching into the
registry directly (registry.value!.strings.get(built.campaign.titleKey)), which is exactly
the boundary a client is not supposed to cross.
This is the same class as #278: a projection that
omits what a client needs, forcing the client to reach past it into content or registry. The
rule it erodes is 09-clients.md §2 — a client is a projection of the session store, never a
participant. Two different clients have now worked around it two different ways, which is
what makes it a contract question rather than a rendering bug.
This needs a signature decision and is deliberately not being made here. Two candidate
shapes, both viable:
CampaignSummary carries a resolved title: string, resolved by the store at listing
time. Keeps the surface at ten operations and ten MCP tools — the one-to-one count 09-clients.md §4 makes structural, and which §4 explicitly declines to add an eleventh
row to. The cost is that it puts resolved text inside a projection DTO, which 20-contract.md §7 rejected once already when it chose getStrings over resolving inside
the DTOs, on the grounds that it bakes a locale into the projection.
A session-free string-resolution operation on SessionStore. Consistent with the
existing "clients never string-match, they resolve keys" model, and cheap in
implementation: getStrings (src/engine/src/core/session/store.ts:351) already returns
the whole frozen registry table and uses its sessionId only as an existence check — the
session scoping is a validation, not a partition. The cost is the eleventh operation, and
the ten/ten counting property stops being the checklist it currently is.
Choosing between them is /contract's call, not an implementation detail. Recorded here so
whichever way it goes, the reasoning lands in design/20-contract.md rather than in a client.
Done when
The contract owner has chosen between the two shapes (or a third), and the choice with
its reasoning is recorded in design/20-contract.md §7.
A client can render the campaign-selection screen with human-readable titles calling
nothing but SessionStore.
The MCP list_campaigns surface resolves the same way, with no start_game call needed
to read a title.
09-clients.md §4's coverage checklist is reconciled with the outcome — either the
ten/ten count still holds, or the document says why it no longer does.
The text-client and MCP tests that currently assert an unresolved titleKey are updated
to assert the chosen behaviour.
The browser demo's direct registry read (site/src/play/composition.ts:158) is retired
in favour of the store operation.
Agent instructions
Run /contract. This is a signature decision, not a bug fix — do not change a signature
before the shape is chosen.
Authority:design/20-contract.md §7 (SessionStore, CampaignSummary, and the Why getStrings is a store operation note) @ 8e3cec0
Client rule at stake:09-clients.md §2 (the only surface) and §4 (the API coverage
checklist, and its explicit refusal to add an eleventh operation)
Same class:#278
— a projection omitting a field, forcing clients past the boundary
Provenance:design/90-decisions.md §2, Found by the first downstream host —
SubZeroDev.Adventures. Found the same way: by building, not by review
Current code:src/engine/src/core/session/types.ts:29 (CampaignSummary), :139
(getStrings), src/engine/src/core/session/store.ts:331 and :351 (both
implementations), src/engine/src/clients/text/render.ts:87, src/engine/src/mcp/server.ts:54
Stop if the chosen shape turns out to require changing getStrings' existing signature or
the StringTable type — that widens this past campaign selection and should be decided as its
own item.
Campaign titles cannot be resolved before a session exists, so the selection screen shows raw keys
Picking a campaign is the first thing a player does, and it is the one screen no client can
render properly.
CampaignSummarycarries atitleKey, and the only way to turn a key intotext is
getStrings(sessionId)— but selection happens before a session exists. Everyclient is left choosing between printing the key and reaching past the store for the string.
Reproduced on all three surfaces:
Text client.
npm run demofromsrc/engine/prints the key where the title belongs:renderCampaignList(src/engine/src/clients/text/render.ts:87) already documents thisin a comment, and
client.test.ts's first case is named"listCampaigns — returns the real campaign, unresolved titleKey (no session yet)" — the
gap is asserted as correct behaviour today.
MCP.
list_campaigns(src/engine/src/mcp/server.ts:54) has the identical problem andis arguably worse off:
get_stringsis the only resolution tool and it requires asessionId, so an agent must either string-match the key or callstart_game— aside-effecting command — purely to read a title.
server.test.ts:39asserts the raw key.Browser demo.
site/src/play/composition.ts:158resolves it by reaching into theregistry directly (
registry.value!.strings.get(built.campaign.titleKey)), which is exactlythe boundary a client is not supposed to cross.
This is the same class as
#278: a projection that
omits what a client needs, forcing the client to reach past it into content or registry. The
rule it erodes is
09-clients.md§2 — a client is a projection of the session store, never aparticipant. Two different clients have now worked around it two different ways, which is
what makes it a contract question rather than a rendering bug.
This needs a signature decision and is deliberately not being made here. Two candidate
shapes, both viable:
CampaignSummarycarries a resolvedtitle: string, resolved by the store at listingtime. Keeps the surface at ten operations and ten MCP tools — the one-to-one count
09-clients.md§4 makes structural, and which §4 explicitly declines to add an eleventhrow to. The cost is that it puts resolved text inside a projection DTO, which
20-contract.md§7 rejected once already when it chosegetStringsover resolving insidethe DTOs, on the grounds that it bakes a locale into the projection.
SessionStore. Consistent with theexisting "clients never string-match, they resolve keys" model, and cheap in
implementation:
getStrings(src/engine/src/core/session/store.ts:351) already returnsthe whole frozen registry table and uses its
sessionIdonly as an existence check — thesession scoping is a validation, not a partition. The cost is the eleventh operation, and
the ten/ten counting property stops being the checklist it currently is.
Choosing between them is
/contract's call, not an implementation detail. Recorded here sowhichever way it goes, the reasoning lands in
design/20-contract.mdrather than in a client.Done when
its reasoning is recorded in
design/20-contract.md§7.nothing but
SessionStore.list_campaignssurface resolves the same way, with nostart_gamecall neededto read a title.
09-clients.md§4's coverage checklist is reconciled with the outcome — either theten/ten count still holds, or the document says why it no longer does.
titleKeyare updatedto assert the chosen behaviour.
site/src/play/composition.ts:158) is retiredin favour of the store operation.
Agent instructions
Run
/contract. This is a signature decision, not a bug fix — do not change a signaturebefore the shape is chosen.
design/20-contract.md§7 (SessionStore,CampaignSummary, and theWhy
getStringsis a store operation note) @8e3cec009-clients.md§2 (the only surface) and §4 (the API coveragechecklist, and its explicit refusal to add an eleventh operation)
— a projection omitting a field, forcing clients past the boundary
design/90-decisions.md§2, Found by the first downstream host —SubZeroDev.Adventures. Found the same way: by building, not by review
src/engine/src/core/session/types.ts:29(CampaignSummary),:139(
getStrings),src/engine/src/core/session/store.ts:331and:351(bothimplementations),
src/engine/src/clients/text/render.ts:87,src/engine/src/mcp/server.ts:54VisibleStatomits the declared range, so clients readCampaign.contentto get it #278'sVisibleStatrange, which is the same class but a separate decision
Stop if the chosen shape turns out to require changing
getStrings' existing signature orthe
StringTabletype — that widens this past campaign selection and should be decided as itsown item.