Pouchy World API — versions and compatibility
The world plane is consumed by code other people wrote. This is the promise that code is entitled to rely on, and the record of what each version added.
The machine-readable document lives at GET https://pouchy.ai/v1/world/openapi
— public, unauthenticated, no project named in it. Its info.version is
WORLD_API_VERSION, and the response also carries it as
x-pouchy-world-api-version.
The promise
Additive only, within a major. A minor bump may add a field, a query parameter, an endpoint, or a member to an open set. It may never remove or rename a field, narrow a type, or change what an existing value means.
Every field added since 1.0 is optional. That is what makes a rollback survivable: the previous build reads rows the newer one wrote and ignores what it does not know. The first non-optional addition is the one that turns a rollback into an outage, so there has not been one.
Absent is UNKNOWN, never a value. A field missing from a record written
before its version is not false, not [], and not "none". code on a
rejected effect, fallback on a line, and the four turn-time facts below all
follow this rule. Reading absence as a value is how a defect gets recorded as
drama.
Closed unions are closed. completionStatus, WorldDeliveryStatus,
WorldRejectionCode and WorldRehydrateFailure are exhaustive: a client may
switch on them. Adding a member is a minor bump AND a changelog note, because a
client's default branch will start receiving it.
Idempotency keys are the identity. turnId for a turn, eventId for an
event, request_id for a session mint, content bytes for a publish. Re-sending
one is always safe and never doubles anything.
Version history
| world API | SDK | added |
|---|---|---|
| 1.0.0 | 0.1.0 | authoring, running, reading, script drafts, conformance |
| 1.1.0 | 0.2.0 | editorial drafts over the deterministic evidence draft |
| 1.2.0 | 0.3.0 | metrics, quality evals, copy-first ledger archival |
| 1.3.0 | 0.4.0 / 0.5.0 | dead-letter inspection, requeue, bounded drain; rejection taxonomy |
| 1.4.0 | 0.6.0 | approved script export, Story Package candidate derivation |
| 1.5.0 | 0.7.0 / 0.7.1 / 0.8.0 | dead-letter rehydrate, gap resolution, receipt + cleanup counters |
| 1.6.0 | 0.9.0 | turn read-back parity, sinceSeq resume cursor, public OpenAPI |
| 1.7.0 | 0.10.0 | the world reads mirrored on a long-lived project admin key |
| 1.8.0 | 0.11.0 | provider proposedPatches on both lanes; world coordinates on the event-reply webhook |
| 1.9.0 | — | the signature-refusal feed (/preflight), owner plane only |
| 1.10.0 | — | per-experience cost (/cost), owner plane and admin mirror |
| 1.11.0 | — | the Story Package body published as a real schema (documentation only — no endpoint moved) |
| 1.12.0 | — | the Story Package contract-version reader gate; the revision listing says when it is partial |
| 1.13.0 | — | Story Package contract v2: initialRelations, behind a per-environment write switch |
| 1.14.0 | — | script → Story Package candidate (proposal only; writes nothing) |
| 1.15.0 | — | optional clientRequestId makes Story Package CREATION idempotent; /validate answers 200 either way |
| 1.16.0 | — | story deliberation: bounded read-only candidates + a signed envelope |
| 1.17.0 | 0.14.0 | selecting a candidate commits it through the ordinary turn door |
| 1.18.0 | 0.15.0 | the derived progress checkpoint (/progress), owner plane and admin mirror |
| 1.19.0 | 0.16.0 | the deterministic consistency scan (/consistency), owner plane and admin mirror |
| 1.20.0 | 0.17.0 | the model-assisted consistency layer, opt-in behind three gates, in its own response field |
| 1.21.0 | 0.18.0 | ApprovedScriptExportV2 on the production hand-off, opt-in via version: 2; V1 frozen and unchanged |
| 1.22.0 | 0.19.0 | GET …/approved-export/{exportId} — the approved script body, mirrored on the admin key; creating one stays owner-plane |
| 1.23.0 | 0.19.0 | /timeline newest-first is genuinely newest-first, and says hasMore; evidence drafts refuse a partial history |
| 1.24.0 | 0.19.0 · 0.20.0 | the script-draft LISTING carries the summary fields it always implied — title, ledgerRange, sourceStoryPackage, scenes, cast, all optional |
| 1.25.0 | 0.20.0 | DELETE …/environments/{envId} for an experience that has never been run; the per-project ceiling now counts ENABLED experiences, so disabling one frees its slot |
| 1.26.0 | 0.21.0 | Story Contract v3: series — episodes and the deterministic rules that end them; the progress checkpoint gains an optional episode |
| 1.27.0 | 0.21.0 | script drafts can be scoped to ONE ended episode via episodeRunId, so a serial worldline stays draftable after it outgrows the instance-wide evidence cap |
| 1.28.0 | 0.22.0 | POST …/instances/{wi}/episodes/next — start the next episode and receive the carryover; a finished episode now also refuses deliberation |
| 1.29.0 | 0.23.0 | trigger.focusRoles on the turn door — narrow one beat to the named roles, shrink-only; skipped roles read not focused |
| 1.30.0 | 0.24.0 | the machine lane (Secret Key ∧ provider signature, id slot epn:{requestId}) can start the next episode; the owner lane stays |
| 1.34.0 | 0.31.0 | …/instances/{id}/state publishes its response SHAPE instead of anyObj, on both planes, with the envelope's environmentId / storyPackageRef named — a census derives the field list from the projection itself |
| 1.33.0 | 0.30.0 | x-pouchy-cost-model on skippedRoles[].code — the free/spent grouping published with a version and a digest, so a consumer diffs membership instead of trusting an SDK bump |
| 1.32.0 | 0.29.0 | skippedRoles[].code — the machine half of why a role was passed over, as a closed 12-value enum; reason stays the human line and nothing should key off it |
| 1.31.0 | 0.27.0 | Story Contract v4: public: true on a declared flag, behind a per-environment write switch; the progress checkpoint gains publicFlags / publicFlagsTruncated (always present) |
1.6.0 in detail
The read-back now answers what the live result answered
GET …/instances/{wi}/turns/{turnId} exists so a backend that lost a response
can recover the turn. It was not fully doing that job: four fields the live
result carried never appeared in it.
| field | why a read-back could not have it before |
|---|---|
selectedRoles |
a casting decision, made before the commit, leaving no trace in patches |
skippedRoles |
same — who was passed over, and why. Since world API 1.32.0 each entry also carries code, a closed WorldSkipCode enum; reason stays the human line and nothing should key off it |
repairs |
same — which roles needed a correction round |
nextOptions |
derivable, but only from the state at THAT revision, which a later reader no longer has |
All four are now recorded by the single writer at commit time and echoed by the
read-back. nextOptions is computed inside the commit transaction from the
state it just produced, and is deliberately not accepted from the caller —
so the options filed against a revision are the ones that revision offered.
Turns committed before 1.6.0 carry none of the four. Absent means UNKNOWN.
Two deliberate divergences remain, and are pinned rather than fixed:
- a read-back's
completionStatusandexecutionStatusare the constant'committed', which is not a member of the livecompletionStatusunion. A turn that never committed has no entry and answers 404, so there is no status to report. Re-labelling it would break anyone already reading the current value; - a read-back's
deliveryStatusadds'unknown'. Once settled rows have been reaped, the absence of a queue row is not proof of delivery, and sayingdeliveredthere would be a nicer-looking lie.
world-readback-parity.drift.test.ts derives the read-back's key set from the
route source and fails if it drops a live field. The gap existed for four
batches because each side had its own tests and nothing compared the two.
A resume cursor on the timeline
GET …/instances/{wi}/turns?sinceSeq=N returns beats with seq > N, oldest
first, plus nextSinceSeq to continue with. Without the parameter the
response is the newest-first window it has always been; either way the response
names its own order.
This is what makes resume work. The newest-first view answers "what happened lately" and is capped — it cannot be paged past that cap, so a backend that was down longer than the window could not catch up without re-reading everything and diffing.
Filtering and ordering happen server-side. Doing it in memory would mean fetching a window and hoping the requested rows are in it, and a cursor that silently skips beats is worse than no cursor.
SDK: listTurns(...) for one page, listTurnsSince(...) to follow the cursor
to the end. The latter is bounded by maxPages — an unbounded follow loop
against a busy world never returns.
The API document is reachable
WORLD_OPENAPI has described this surface accurately since 1.4, guarded by a
route-walk drift gate, and was imported only by its own test. It is now served
at GET /v1/world/openapi, public and unauthenticated: it describes shapes, not
data, and every operation it lists still demands its own credential. It does not
describe itself — the drift gate derives the documented surface from the world
route tree, so a self-reference would register as a phantom endpoint.
1.7.0 in detail — a machine may read its own world
The world reads lived only behind requireProjectAccess, which resolves through
requireFirebaseUser to a Firebase ID token: about an hour of life, minted by a
browser sign-in, with no API-key path anywhere. So a backend could DRIVE a turn
with credentials it holds (a Secret Key and a provider signature) and then
needed a person at a browser to find out what the turn did. Resuming after a
crash was the same story, and so was checking whether a line had been delivered.
That is a CREDENTIAL gap, not a missing capability — every read already existed. It is also what made the sixty-minute integration unreachable: not slow, but unoperable, since somebody had to copy an hour-lived token onto a server, hourly.
GET /v1/admin/environments/{envId}/** now mirrors those reads on a project
admin key (pchy_admin_…), minted once from the dashboard.
| mirrored | not mirrored, and why |
|---|---|
overview |
POST …/turns — an admin key proves the project, never the Provider; the turn door needs both proofs |
instances/{wi}/state |
deliveries/{id}/requeue, /rehydrate, /resolve-gap, /drain — each decides what happens to a reader who is missing a beat |
instances/{wi}/turns (with sinceSeq) |
ledger/archive — prunes committed history |
instances/{wi}/turns/{turnId} |
the content loop (script drafts, editorial) — human review is the point of it |
instances/{wi}/metrics |
|
instances/{wi}/deliveries (view only) |
Three properties make the mirror a mirror rather than a second API, and each is held by a test rather than by this paragraph:
- every mirrored route is GET. A mutation appearing on one fails the gate;
- the project comes from the KEY, not the path. There is no
projectIdparameter to substitute, so a caller cannot address another project's world by editing a URL; - neither door holds the logic. Both call one shared read, so the answer cannot depend on which credential you came in with. Two handlers with copy-pasted bodies diverge on the first bug fix, and then a backend and a dashboard disagree about the same world.
The rejected alternative was teaching requireProjectAccess to accept an admin
key. That is two lines and it opens all 217 /v1 routes at once — billing,
member management, GDPR wipe. Explicit mirrors cost more files and make each
widening reviewable one route at a time.
Pre-existing, and worth stating plainly: POST /v1/admin/environments/{envId}/disable
has been reachable with an admin key since Batch 1. It lands on the world head,
so it blocks every EXISTING session, not just new ones. Batch 10 did not add it
and does not extend it; a census test now fails if a fourth mutating verb
appears on that plane, so nothing else joins it by accident.
1.8.0 in detail — telling the world what you already know
Your backend holds facts the model can only guess at: the payment cleared, the shipment arrived, the player really does have the key. Until now those could only WAKE the characters, and whether the world actually recorded them depended on a character proposing the right effect.
proposedPatches is the provider's own write. It rides the coordinator's single
commit alongside the roles' settled effects, so the fact becomes something the
world HOLDS.
The server has accepted the field on POST …/turns since Batch 5; what was
missing was the SDK surface for it and any path at all through the event lane.
Both are here now.
Bounds, unchanged from every other write: the closed StatePatch union, the
pinned story package, and validateStatePatches at the single writer —
all-or-nothing, so a batch with one bad op commits nothing. A provider passes no
actorRoleId, so it cannot write a role's private notes. The ingress adds only
a cheap shape gate (array, ≤ 20 entries, each a JSON object) so a malformed
batch fails at the door rather than after a coordinator run.
Coordinated worlds only, and this is a refusal rather than a silence. An actor world wakes each subscribed role on its own; there is no single commit for a deterministic write to ride. Sending patches to one answers 422 and names the mode the world is in. Accepting them and dropping them would be the failure this project has recorded three times — instructing against a structurally empty channel, then reading the silence as success.
The event-reply webhook gained its world coordinates
A coordinated beat fans one agent.event_reply per role line. Each carried the
instance and the role and nothing else, so a consumer could not join a reply
back to the ledger turn, could not tell whether the world had moved, and could
not order two lines of the same beat — webhook delivery is per-message and
promises no ordering.
The world block now also carries turnId (the join key, and it is the same id
the coordinator committed under), stateRevision (what the beat committed at)
and sequence (order within the beat). All three are additive.
1.9.0 — why the door is shut
GET /v1/projects/{projectId}/environments/{envId}/preflight returns the recent
signature refusals: which door, which reason, when.
Both signed doors answer one uniform 403 on a bad signature, and that stays true — the wire must not become an oracle telling an attacker which of four credentials to keep guessing at. This is the other half of that decision: the project's own admin, and nobody else, can read the reason out of the trail the doors have been writing since Batch 3.
Reasons are a closed vocabulary — missing, malformed, unknown_key,
stale, bad_signature, no_keys — and those audit rows carry no key
material. An unrecognised code is dropped rather than returned, because a
consumer has no explanation to attach to one.
The response reports how many audit entries it scanned. An empty feed is not a clean bill of health: a backend that has never called produces exactly the same emptiness as one that works.
Owner plane only, and deliberately not on the /admin mirror. A machine
credential reading a stream of "which credential is wrong" is a different risk
from one reading world state. No SDK method either: this answers a question a
developer asks while looking at the dashboard, not one a backend asks in a loop.
1.10.0 — what one experience costs
GET …/environments/{envId}/cost returns tokens and model hops per month, on
both the owner plane and the /admin mirror.
Usage was dimensioned by project and by stage. A project running three experiences could see its bill and could not see which one was making it — the first question anyone asks before putting a second experience in front of real users.
One write, two views, never a second charge. The per-experience counters
ride the same Firestore merge as the project totals, exactly as the per-stage
byTag counters have since they were added. There is no window in which an
experience's tokens are counted and the project's are not, and no path on which
the two could be added to each other. Two mutation checks hold that: adding the
world counters to the totals fails, and splitting them into a second write
fails.
The dimension comes from the signed principal, not from a request field. A cost dimension a caller can set is a cost dimension a caller can move.
Keyed by environment, never by instance. A month document has a hard 1 MB ceiling; a project has a handful of experiences and one instance per player. So "what did this experience cost" is answered and "what did this player's story cost" is not — that needs a different storage decision, and this version makes none rather than approximating one.
Unreadable months are reported, not counted as zero. "We could not read it" and "nothing was spent" are different answers, and only one of them is safe to bill against.
1.11.0 — the Story Package body, spelled out
POST /projects/{projectId}/story-packages and PATCH …/{packageId} now
publish a real request schema instead of additionalProperties: true with no
properties at all. No endpoint, field, or validation rule moved — this is
strictly the document catching up to a contract that has been stable since 1.0.
Until now the only way to learn the field names was to POST something and read the validator's complaints back. The errors are good errors, but they were doing the documentation's job, and an integrator was reverse-engineering a shape from refusals.
Every bound in the published schema is IMPORTED from the pure contract
(src/lib/world/story-package.ts), never restated, so a cap that moves in the
validator moves in the spec in the same commit or a gate fails. A second gate
compares the schema's property names to the exported interfaces in both
directions: a contract field the spec never learned about is an integrator
writing to a surface that exists, and a spec field the contract dropped is one
writing to a surface that does not.
Three things the schema deliberately does not claim. The referential rules
(a node's sceneId must be a declared scene, a branch must connect declared
nodes, visibility.factAllow may only name declared worldRoleIds and declared
factIds, a node may not be its own prerequisite) and per-array id uniqueness
are stated in the field descriptions and enforced by validateStoryPackage,
which answers with the exact failing id rather than a schema path.
And additionalProperties is true at every level, because that is what the
server does: the validator reads the fields it knows and DROPS the rest. An
unknown field is neither an error nor stored — it is absent from the content and
from the content hash, so a round-trip will not match what you sent. Writing
false there would document a rejection that never happens, which is the more
expensive lie: you would trust a 400 to catch a typo and instead lose the field
in silence.
Also in this version, on the same "the document was the defect" footing: the
SourceSignature scheme description named the id slot as "turnId or eventId"
and omitted the third door. POST /sessions signs the body's own
world.request_id, and it is the FIRST door an integrator reaches. A canonical
string is unforgiving — an undocumented id slot is indistinguishable from a
wrong secret, because both come back as bad_signature. See
docs/world-sdk-errors.md, which is new in this version and maps every refusal
code to what to change.
1.12.0 — a build that cannot read a row now says so
The reader gate, and why an integrity check was not already one
canonicalizeStoryPackage is a generic sorted-key serializer, not a field
allow-list: it hashes whatever object it is handed. So a Story Package revision
written under a future contract re-hashes true on an older build. The
integrity check passes, the row is served, and the fields that build has never
heard of are dropped in silence — a world runs with part of its story missing
and nothing anywhere records it.
From 1.12.0 every read of a Story Package revision first resolves the row's
contractVersion against an explicit supported set (today: {1}) and
refuses anything outside it, before touching the content or computing a
hash. A missing stamp still reads as the historical v1; null, 0, negatives,
fractions, strings, booleans and unknown future versions are all refused.
Deliberately a set and not version <= current: a comparison silently welcomes
every version anyone ever adds, which is the opposite of what the gate is for.
The refusal is its own word
unsupported_contract is distinct from gone throughout, because the two ask
for opposite responses: gone means the data moved out from under a pin and
someone must fix the pin; unsupported_contract means the data is fine and the
deployment is behind it. At the session/turn/event gates it surfaces as
story_contract_unsupported and behaves like unavailable — retryable,
loss-free — because a completing rollout clears it, and a rollback below the
floor is exactly when discarding work would be worst. Public responses keep the
generic "world not ready" error; which contract versions a build carries is
never disclosed.
The revision listing admits when it is partial
GET …/story-packages/{packageId}/versions (both lanes) now answers with
versions (readable only), plus unsupported (revision number and claimed
contract version — never content), unsupportedCount, hasUnsupported, and
latestRevision read from the head.
Do not treat versions[0] as the newest revision. When hasUnsupported is
true the newest revision may be one this deployment cannot read; latestRevision
is the head's own answer. For the same reason a package whose every revision is
unreadable answers 200 with an empty versions, never 404 — it exists.
Rollback floor
Production must not be rolled back below 1.12.0. A build older than this accepts a future-contract row and silently ignores what it does not understand, which is the entire failure this version exists to prevent. A rollback to 1.12.0 or later is safe: every field added here is optional and additive.
1.13.0 — story contract v2, and why reader-ready is not writer-enabled
initialRelations
A Story Package may declare the relations its story opens with. They are seeded into a new world instance's shared state and nowhere else — an instance already under way is never back-filled, because seeding over live relations would rewrite history that players made.
It is a set, and it is the only set-normalized field in the contract:
[a,b] and [b,a] are one relation; a pair declared twice is refused, not
merged (differing descriptors are a contradiction, identical ones are still two
rows for one key — merging either would be guessing which the author meant);
and the array is sorted before hashing, so authoring order cannot change the
content hash. Every other array keeps declaration order, because narrative
order is content.
Every relation is public. Relations live in the shared block of world
state, so anything that must stay hidden belongs in the authorised role's
secrets. Nothing tries to infer privacy from the words used — a keyword
heuristic deciding what is secret is how a secret leaks.
The version is the server's to decide
v1 head + no relations → v1 (bytes unchanged; the overwhelming case)
v1 head + relations → v2 (the only way to earn an upgrade)
v2 head + anything → v2 (a lineage never walks backwards)
A client-supplied contractVersion is refused, and there is deliberately
no "publish everything at the newest contract" rule: that would re-stamp every
untouched package on the deploy that shipped a new version. A v2 package that
drops its relations stays v2 — letting it fall back would make the head's
version depend on the newest content rather than on history.
Each row is canonicalized, hashed and re-verified under its own
contractVersion. A v1 row carrying initialRelations is refused even when
its sp1 hash is perfect: the hash proves the bytes were not edited, never
that a v1 reader understands them. contractVersion: 3 is still
unsupported_contract.
Reader-ready ≠ writer-enabled
These are two different states of an environment and this document names them separately:
| state | meaning |
|---|---|
| reader-ready | the build is ≥ 1.12.0, so it can READ a v2 row |
| writer-enabled | POUCHY_STORY_CONTRACT_V2_WRITE is set on this environment, so it may WRITE one |
The write switch is off by default and only 1 or true turn it on —
notably false does not, which is how a switch meant to be off turns itself on
under a truthy test. Writing a v2 row before every reader is confirmed deployed
would hand an older instance a row it must refuse, so the writer waits for the
reader.
With the switch off, v1 publishing and its idempotent replay are untouched, and
a publish carrying initialRelations is refused with a stable
code: "story_contract_v2_write_disabled" (HTTP 409, both lanes). The field is
never dropped so the publish can succeed as v1 — that would store a story
the author did not write, with no way for them to notice. The switch gates
writes only: a v2 row that already exists still reads with the switch off.
1.14.0 — a script becomes a proposal, and nothing more
POST /projects/{projectId}/story-packages/import-candidate reads a dramatic
script and returns a Story Package candidate. What it does not do is the
specification:
- No writes. No package, no world, no agent, no draft. Confirming a candidate is a separate human act that goes through the ordinary publish route — there is no second publish path, because a second path is a second set of validation rules to keep in sync.
- The script is not stored and not echoed. It goes to the provider and back to the caller who sent it. The audit row carries a length, a digest, the model and the prompt version — never the text, never a sample, never a filename. Provider error messages are not forwarded, because they can quote the input that caused them.
- The model output is untrusted input. It runs through the same total
validateStoryPackagea hand-written package faces, and a candidate that fails is returned with its errors rather than repaired. Quietly fixing a model's output is how a story nobody wrote gets published. - One bounded call. The non-companion utility path: no persona, no memory, no tools, no session, no retry, no database. Script length, output shape, token count, wall clock and per-project rate are all capped.
POST …/import-candidate/validate re-runs the full validator over an edited
candidate — no model call, no storage — and returns the normalized result
(relation pairs sorted, an empty relation list dropped), so a wizard shows what
will actually be published.
Both responses carry relationsWritable: whether THIS environment may publish
contract v2 (see 1.13.0). A candidate containing relations can be produced
where it cannot yet be published, and saying so early is better than a failed
publish at the end — the publish still refuses rather than dropping the field.
relationsArePublic is always true and is there to be shown: relations are
shared state, visible to every role.
1.15.0 — creating a package twice by accident
clientRequestId
POST /projects/{projectId}/story-packages takes an optional
clientRequestId. Supply one and the package id is derived from
(projectId, clientRequestId), so a creation whose response was lost can be
retried and returns the original package (created: false) instead of
making a second one.
The key alone is not enough to answer yes. A retry must match on both the
creation intent and the content: the first revision stores a digest of the key,
and a retry re-hashes the incoming body under that revision's own contract
version. Pointing an old key at new bytes and getting created: false would
report success for something never written, and the caller would carry on with
a package that does not say what it thinks it says. That case answers 409
idempotency_key_reused.
Three properties worth stating:
- The retry reads revision 1, never the head. What a caller is owed is the revision its own request produced, not whatever is newest — the package may have been published several times since.
- A refusal decided before the write leaves the key unused. The v2 write switch, the per-project package cap and a validation failure all mean nothing was stored, so the same intent can be retried after the problem is fixed.
- The key is never stored, logged, echoed or audited. Only a non-reversible digest of it reaches the row, and the field is absent entirely when no key was supplied — a package created without one is byte-identical to how it always was.
clientRequestId is transport, not story content: it is lifted out of the body
before validation and never becomes part of the package.
/import-candidate/validate answers 200 in both directions
It was 422 for an invalid candidate in 1.14.0. "This candidate is not valid" is
the ANSWER to the question the endpoint exists to ask — the request succeeded —
and an error status forced every caller to run its normal path through a catch
block. It now returns { valid: false, errors[] } with a 200. Corrected before
any consumer shipped against it.
1.16.0 / 1.17.0 — deliberation, and why it has no privileges
A coordinated world may preview a beat before taking it: POST …/deliberations
returns a couple of short PUBLIC directions plus a signed envelope, and
POST …/deliberations/select commits the one the player chose.
Off unless the world's published revision declares deliberation. Absent on
every existing world, dropped by the canonicalizer when undefined, so no
published content hash moves. A revision asking for more candidates than the
server cap is refused rather than clamped.
A candidate is five public fields — candidateId, title, direction,
shortTeaser, participatingRoles — and that is an allow-list, not a
redaction. No reasoning, no per-role output, no role secrets, no simulated
effects, no scores, no raw model response. Each role in the simulation sees only
its own authorized projection; the player sees neither.
Selecting commits through the ordinary coordinator. Same beat, same ledger,
same WorldTurnResult. The simulation's effects are never carried into the real
turn — roles re-propose their own and those take the usual path. The only thing
that crosses is direction, a narrative steer — caller-supplied (a request
field, at most 400 characters), not read from the envelope. The envelope
carries a candidateDigest over the candidates, but select does not re-prove
it, so the server does not bind the committed direction to a candidate the
deliberation produced; hold it to one of them on your own backend. Whatever it
says, it cannot widen the role allowlist, change canon, or become an effect.
Every claim in the envelope is re-proven against live reads before any model
call — membership, environment revision, story pin, stateRevision, provider
binding, actor binding and the live gate. A world that moved answers 409
stale_deliberation having spent and written nothing; deliberate again.
The envelope is server-side. It is opaque, it authorises a commit, and it
belongs nowhere near a browser. Its signing key is DERIVED from the master
(HMAC(master, 'pouchy.world.deliberation.v1')) or from a dedicated
POUCHY_WORLD_DELIBERATION_SECRET, so this feature rotates independently and a
compromised webhook verifier cannot mint one. Rotating either key invalidates
outstanding candidates — nothing is lost, because nothing was committed.
The turn id is fixed inside the envelope. Re-sending the same envelope retries; it cannot become a second beat.
Four conditions gate every deliberation, and all four are checked before a single model call, from one snapshot the request then carries:
| condition | refusal code | what to do |
|---|---|---|
| environment switch on | deliberation_disabled |
set POUCHY_WORLD_DELIBERATION_ENABLED |
| budget calibrated | deliberation_uncalibrated |
run the Ops calibration card, update the constants, ship |
| revision declares it | deliberation_not_enabled |
publish a revision with deliberation |
| within the server cap | deliberation_candidates_exceeded |
fix the revision |
Calibration is a CODE constant, not an environment variable: it is a property of
the numbers, so it cannot be switched on by configuration and no operator can
enable an unmeasured budget. select is gated identically — turning the feature
off stops outstanding envelopes, not merely new candidates.
Cost is bounded BY CONSTRUCTION, and the bound is derived. One model call
per candidate, each prompt truncated to MAX_INPUT_CHARS and each completion
capped at MAX_OUTPUT_TOKENS, so the worst case is a product of three server
constants no request can raise: 2 × (12,000 + 700) = 25,400 tokens, taking
characters at 1 per token because Chinese runs near that and deriving from
English's ~4 would understate a zh-CN world fourfold.
That figure sits just above ABS_MAX_TOKENS (24,000) and is meant to: the
running total is checked BEFORE each call, so the cap bounds where spending
stops, not where it lands.
A live run measured 2026-08-27 (2 roles, 2 candidates) spent 856 tokens in
5,596 ms — about 3% of the derived bound. The Ops Tools panel reports the
contract's own calibrated value alongside worstCaseTokens, so a reader can
compare a real run against the ceiling rather than take either on faith.
The original plan was a measured p95. That framing deadlocked — a p95 needs representative worlds, and those only exist once partners use the feature — and it was answering the wrong question: what protects a bill here is the derivation above, not a percentile.
1.18.0 — where the story is, without asking the story
GET …/instances/{worldInstanceId}/progress, on the owner plane and the admin
mirror, with byte-identical bodies. It answers the question a reader's client
actually has on reopening — where was I, and can I go on? — from facts that
already committed. Nothing is stored to serve it, no model is asked, and the
story package it reports is the one that INSTANCE pinned, at its exact revision
and contentHash.
The response is a discriminated union
{ "status": "ready", "resumable": true, "checkpoint": { … } }
{ "status": "unavailable", "resumable": false, "reason": "world_unavailable" }
The unavailable arm has no checkpoint key at all. That is the point: a
client that forgets to check status gets undefined and fails where the bug
is, instead of reading a completedNodeCount of 0 and telling a reader they
have not started a story they are halfway through. resumable is typed as the
literal false there, so "unavailable but resumable" is not constructible.
It answers 200 in both directions. "This world cannot be resumed" is an answer about the story, not an error about the request. Reserve your error handling for transport and auth.
The reason is coarse on purpose
world_unavailable covers, indistinguishably: no such instance, no access to
it, the world pins no story package, the package was revoked, the package was
deleted, and the package uses a contract this build cannot read. They are one
word because separating them would let a caller map which internal objects
exist by reading refusals — the same doctrine /turns/{turnId} follows when it
declines to say whether a turn id ever existed.
story_reference_invalid is the one distinct reason, and it reveals nothing
about existence: the pinned story and the state pinned to it disagree (a scene
or node the package does not declare). The read refuses rather than substituting
a scene, because naming a place the story never went is worse than saying so.
declaredNodeCount is not a denominator
The checkpoint reports completedNodeCount beside declaredNodeCount, and
deliberately ships no percentage. A branching story never visits every
declared node — the author wrote alternatives, not a queue — so
completed / declared is not "how far through" anything. Render two numbers.
The field is named declaredNodeCount rather than totalNodes so that the
temptation is at least visible in the type.
recentProgressRecords is not a story summary
Each record is { seq, kind, at }: a beat committed, at that revision, at that
time. No patches, no lines, no actors, no trace. Naming it "recent beats" would
have implied narrative content it does not carry. For what was actually said,
read /turns — that surface is authenticated for it and this one is not.
Both bounded windows report their own truncation (recentProgressTruncated,
publicRelationsTruncated) rather than letting a capped list read as a
complete one.
What it shares with the live turn
nextOptions is the same { branchId, condition } the turn result carries, from
the same derivation over the same pinned story — not a similar expression. A
checkpoint can never offer a direction the live turn would not, and neither is
ever model-generated.
Determinism
Two reads of an unchanged world return byte-identical documents: no timestamp is
taken at read time (every at comes from the committed row), and every list is
explicitly ordered. State and ledger are read inside one read-only transaction,
so a checkpoint can never pair a state from before a beat with a ledger row from
after it.
1.19.0 — what a world's own record proves about itself
GET …/instances/{worldInstanceId}/consistency, on the owner plane and the
admin mirror, with byte-identical bodies. It reports the contradictions a world
can be shown to contain: a beat committed against a package the instance is
not pinned to, an effect accepted for a role that was never granted it, a node
complete while its prerequisite is not, a line quoting another character's
declared secret verbatim. Every one is a comparison between committed facts and
the pinned story package. Nothing here is a judgement about writing, and nothing
here repairs anything.
The code set is closed, and it may grow
code is an enum. Members may be added in a later minor. A published member
is never removed, never renamed, and its meaning never widens — a criterion
later found to be wrong is stopped and deprecated, with a replacement added
beside it. So treat an unknown code as informational rather than failing on
it; that is what makes a minor bump safe to take.
One candidate did not ship for exactly this reason. cross_instance_reference
was specified against the ledger entry's refs, which carries turnId,
eventId and traceId and no instance id — the criterion cannot be built.
Matching a wi_-shaped string in dialogue would have put a text shape where a
fact belongs, and once in the enum it could only ever have been deprecated,
never removed. Cutting it before the first release was the only clean moment.
Read coverage before trusting an empty findings
Some checks read fields that older ledger rows do not carry. Absent means
UNKNOWN, never "nothing to see", so those entries are counted into coverage
instead of being skipped in silence:
{ "code": "relation_change_without_cause", "skipped": 12, "reason": "evidence_field_absent" }
A clean report with a non-empty coverage is clean about less than the whole
world. Absence of a finding is not proof of absence, and the response says so
rather than letting you assume otherwise.
incomplete is a status, not an empty result
A walk is capped at a thousand entries and twenty seconds. When it stops early
the status is incomplete and a cursor comes back — pass it to continue. On
that arm the state-level and chain-level checks do not run at all, because they
answer for the world as it stands and a partial walk has not established the
history the state is meant to summarise.
entriesScanned stays honest even when findingsTruncated is true: the report
stops growing at two hundred findings, but the scan keeps counting what it read.
Rate limited on two axes, and never cached
Thirty per minute on the project and thirty per minute on the client IP; both
must pass. The project axis stops a caller rotating IPs to escape a cost
ceiling, the IP axis stops one source sweeping many projects — either alone
leaves the other way open. A refusal is status: "rate_limited" with HTTP 429
and Retry-After.
If the shared limiter is unreachable the scan still runs. This is a read-only diagnostic, and taking the instrument away exactly when a world is being investigated is the worse failure; the scan's own caps still bound it. The degradation is recorded server-side.
Responses carry Cache-Control: no-store. A scan is a point-in-time reading of
a world that keeps moving, and this is the surface you reach for when you
already suspect something is stale.
1.20.0 — the layer that judges, kept apart from the layer that proves
The deterministic scan reports contradictions a world can be shown to contain. This adds the other half — a line against canon, a character back from the dead, a persona that does not hold, a secret leaked by implication, a proper noun from somebody else's IP — and keeps it structurally separate.
It arrives in its own field, and the default response did not move
WorldConsistencyFindingCode is frozen. Not one suspected_* member was
added to it, and suspicions never appear in findings.
A request without layers is byte-identical to 1.19: no modelAssistedFindings
key, no modelAssistedNote key, zero model calls. Upgrading changes nothing
until you ask.
The reason is a specific footgun. Code written against 1.19 reasonably does
if (f.severity === 'blocking') fail() over findings. Folding suspicions in
would let "the model thinks this character is out of voice" break somebody's
build — the exact failure the never-gate-CI rule exists to prevent, happening in
your pipeline instead of ours. Model-assisted severity has no blocking
member at all, at the type level as well as on the wire.
Three gates, all required
- a server-side flag on the deployment;
- the published world revision declaring
modelAssistedConsistency; ?layers=deterministic,model_assistedon the request.
They are not redundant. The flag is the operator's. The declaration is the world
author's consent — this layer forms opinions about their writing, and an
operator should not be able to enable that over their heads. The parameter is
yours. Miss any one and you get a deterministic scan; modelAssistedNote says
which gate was shut.
Judgements are potential until a person decides
POST …/consistency/reviews/{findingId} records a verdict — owner plane only,
and deliberately not mirrored on the admin key. Reading a scan is something
a backend can do unattended; deciding that a suspicion about someone's writing
is real is not.
The body is a claim. The server re-normalises your evidence refs, checks each
against that instance's committed ledger, and recomputes the findingId before
writing, so a finding cannot be reviewed by naming an id from a world you cannot
read. Writes are idempotent on the same decision and take expectedRevision for
CAS.
A verdict changes the verdict. It does not touch the Story Package, canon, the ledger, or world state.
findingId is stable so a dismissal sticks
Derived from the code and the server-normalised evidence refs, and from nothing else — not the model's wording, not the array indices it happened to use. A model that rephrases itself does not mint a new finding, which is what makes "I looked at this and set it aside" hold across scans.
The budget, and what a short run says
At most five model calls per scan, one per code, with no retry: a second opinion
at a second price is not a more likely truth. There is a wall-clock deadline and
a character bound on the material offered. An early stop is reported as
modelAssistedNote: 'incomplete' — a short run must never read as a quiet
world.
Rate limited fail-closed here, unlike the deterministic scan: that one bills nothing and should survive a limiter outage, this one bills per call and an unbounded fail-open on a billed path turns an outage into an invoice. One scan per minute per project, five per minute per source.
suspected_cross_ip_contamination is an editorial opinion
Even confirmed. It is not evidence of isolation and must not gate CI, block an export, reject content, repair anything, or trigger a security response. Real isolation waits on world-scoped knowledge allowlisting.
What this document does NOT promise
Ordinary rate limits, latency, and model behaviour are not covered here. The OWNER lane's remaining
endpoint — the content loop — still takes a signed-in project admin's Firebase
ID token, which is short-lived and browser-minted. That one is deliberate: its
review step is a human decision. Authoring was on that list until SDK 0.12.0
pointed it at the /admin equivalents, which had existed since Batch 1 and
which the SDK had simply never used.
1.21.0 — a second export contract, and a first one that did not move
The production hand-off gains what a production workflow actually needs beyond the scenes: a synopsis, character notes, an episode split, the beats where the plot moved, the relationships that changed, and where the run left the story it started from.
V1 is frozen, and this release is mostly the proof of that
exportId is a content hash. Integrators key on it for idempotency — replay an
export, get the row you already have. So a field added to V1, a key reordered in
its canonical string, or a change to its approvedscript1 domain prefix would
silently re-mint the id of every export produced from an approval that had
already been exported, and the two would stop matching.
Nothing in a type system prevents that, so three gates do: golden vectors (fixed input, literal expected digest), the canonical string asserted byte for byte, and the complete V1 key set. They were generated on a clean tree before a line of V2 existed — vectors written afterwards would record what the changed code does, which says nothing about whether it still does what the old code did.
No stored row is rewritten, re-digested or migrated. A row written as V1 stays V1 forever.
Asking for V2
POST …/approved-export takes an optional version, and defaults to 1. A
caller written before this release sends no version and receives a
byte-identical export.
version: 2 returns ApprovedScriptExportV2. Any other value — 3, '2',
null — is a 400. "The default is V1" must not also mean "a typo gets V1":
a caller who believes they asked for V2 and silently received V1 would ship
against the wrong contract.
The two contracts hash under separate domains
V1 uses the prefix approvedscript1, V2 uses approvedscript2. One approval
exported both ways therefore yields two different ids, and both are stable.
That is correct — the two carry different content — and it is asserted with
fixed vectors rather than left as a claim about hash functions.
To be precise about what domain separation is: it does not make a collision mathematically impossible. It means the same approval, read under the two contracts, never shares a preimage, so a V2 id cannot be mistaken for the V1 id of the same approval.
Every V2 field is derived, and no model is called
synopsis— the surviving scene headings, in order. Assembled, not written. If you want authored prose, that is a person's job or a later generator designed as such.characterNotes— counts, scene ids, and the character's first committed line. A character who only ever spoke suggested lines gets a note with no quote: a model's connective sentence must not read as characterisation.episodes— a mechanical split at a fixed number of scenes. A reproducible starting point for a producer to move, not an editorial judgement dressed up as one.keyMoments— the beats that completed at least one story node, i.e. where the plot provably moved. Not "moments somebody found dramatic".relationChanges— committedset_relationchanges in the evidence layer's own wording, with the turn they came from. Deliberately not parsed back into a role pair: the structured pair is not in the evidence draft, and recovering it would mean parsing prose that can legitimately contain the separator.divergence— nodes never completed, and branch conditions the run reached. Facts about the run; never what "should" have happened.review.approvalTimeSource— whetherapprovedAtwas stamped on enteringapprovedor stood in from the row's last-touch time. In V1 this lives on the storage row only, because adding it to V1 would have changed every digest ever minted. V2 has no such history, so it sits in the content, and inside the digest.
A rejected scene stays rejected in every derived field. keyMoments and
relationChanges are restricted to turns belonging to surviving scenes —
otherwise a reviewer's rejection would be cosmetic, with the rejected beat
walking back in under a different field name. divergence and lineage are
not filtered, because they are facts about the run: rejecting a scene does not
un-complete a node.
What V2's digest covers, and why the rule differs from V1's
V1 excludes characters, stateChanges and suggestionRatio because they are
pure restatements of scenes — hashing them adds nothing.
V2's added fields are not restatements. They are selections and groupings
produced by rules (which beats count, where an episode boundary falls), so they
are hashed: a change to one of those rules changes what a production system
acts on, and a consumer keyed on exportId must not silently receive a
different episode split under an id it already has. V1's three rollups stay out,
for V1's original reason.
The Story Package candidate stays version 1 only
POST …/approved-export/candidate refuses a V2 export with 422. This is the
one path from an export into published canon, and which V2 fields may become
canon has not been decided. There is no cast and no automatic narrowing — export
the approval as version 1 and derive from that.
The webhook
world.script_approved gains contractVersion. Additive, and still only
identifiers and lineage — never the script.
1.22.0 — the approved script becomes readable by a machine
The production hand-off had an open last link. Everything upstream of it worked unattended; the script itself did not.
What was actually wrong
Nothing returned an export body. The collection GET strips content before
answering, so the only response that had ever carried a script was the POST
that minted it — which meant reading required the create verb, and required a
browser-minted token that expires in about an hour. In practice a person had to
fetch the file and forward it.
GET …/approved-export/{exportId}
A new item read, returning one export with its body, and mirrored on the
project admin key at the matching /v1/admin/... path. An unattended backend
holding a long-lived pchy_admin_ credential can now fetch an approved script
with no browser anywhere in its deployment. In the SDK this is
getApprovedExport(...), which picks the admin plane automatically when
adminKey is set — the same mechanism as getProgress and getTurn.
Served Cache-Control: no-store. The body is the script; it should not sit in a
shared cache.
The webhook already closes the loop
Every field the read needs is in world.script_approved: exportId at the top
level, and environmentId, worldInstanceId, evidenceDraftId and
editorialDraftId inside lineage. No additive change was needed — that
was verified by building the read request from the payload alone in a test,
rather than by inspection. The payload still carries no script.
What is deliberately NOT on the machine lane
POST …/approved-export— minting an export is a reviewer signing off on a specific text. A machine credential that could mint one could sign on their behalf.POST …/approved-export/candidate— the one path from an export into published canon.
Both are absent from the mirror roster, and the mirror gate asserts every mirrored path is GET-only, so this cannot be widened by adding a file.
One 404 for every kind of miss
Wrong project, wrong environment, wrong instance, wrong draft, wrong editorial, or simply no such id — all answer the same 404 with the same sentence. Telling them apart would let a caller map which objects exist by reading refusals.
The collection response did not move
It still answers summaries, with the same key set as before. listApprovedExports
is now typed as ApprovedScriptExportSummary[] rather than unknown[], and the
summary type has no content field — so treating a listing row as a full row
is a compile error instead of a crash reading content.exportDigest off
undefined. That is not a hypothetical: it is what the dashboard did until the
next release fixed it.
1.23.0 — the newest beats are now the newest beats
A correctness fix with a visible edge, so it gets a version of its own.
What /timeline used to answer past 200 beats
The newest-first read fetched an unordered page capped at 200 and sorted
that in memory. Firestore serves an unordered window by __name__, and this
collection's document id is the entryId — derived from a caller-proposed turn
key, with no relation to seq.
So on an instance with more than 200 committed beats, "the newest 50" was the newest 50 of an arbitrary 200. It looked right, because the rows came back sorted. Ordering now happens in the index.
If you have been reading /timeline on a long instance, you were seeing a
window rather than the tail. You will now see different beats — the correct
ones.
hasMore, additive
The newest-first response gains hasMore. It comes from fetching one extra row,
never from comparing a page's length to its limit — that comparison cannot tell
a page ending exactly on the last row from one with more behind it.
It is absent when the read failed, because absent is UNKNOWN. A caller told
hasMore: false would stop looking.
?sinceSeq= is untouched. It has been ordered and filtered server-side since it
was added, and it remains the way to walk a long ledger.
Evidence drafts now refuse a history they cannot see whole
This is the part that matters more than the timeline.
POST …/script-drafts and POST …/eval were reading 200 rows of that same
unordered path. An evidence draft's ledgerRange says "the committed range this
draft reads" — and on a long instance it was describing a window the draft had
not read. Editorial, the approval, and the exported script's lineage all
inherited the claim.
The Evidence contract does not change: no truncated field, nothing added to
ApprovedScriptExportV1/V2, no digest touched. Instead the request is refused,
because a draft that cannot see the whole run is not a smaller draft — its
central promise is false.
Two stable codes, both 409, both before any write:
| code | meaning |
|---|---|
evidence_history_exceeds_window |
more committed beats exist than one read covers. The instance is fine; the Evidence layer has no windowed form yet. |
evidence_history_gap |
the seqs are not contiguous. seq advances by exactly one per committed beat, so a hole means a row is missing — and drafting across it would invent history. |
The refusal carries the range, the count and the ceiling. It carries no row content: an error message is not a place dialogue may end up.
/eval refuses on the same terms, for the same reason. A quality score computed
over part of a run is not a lower score; it is a wrong one, reported with the
same confidence as a right one.
A windowed evidence draft — explicit fromSeq/toSeq, or a cursor — is the
natural next design for long instances. It is deliberately not in this release.
The World Definition contract reader gate — a rollback floor
Not an API version bump: nothing observable moved, and no field was added. It is an operational constraint, recorded here because that is where rollback floors live.
What it closes
readEnvironmentRevision verified a stored revision as
worldContentHashAt(definition, row.contractVersion ?? 1). That takes the row's
OWN stamp as a domain prefix, over canonicalizeWorldDefinition — a generic
sorted-key serializer, not a field whitelist.
So a row written by a future build re-hashes true on an older one: the integrity check passes, the row is accepted, and the fields the older build cannot read are dropped in silence. This is the identical hole PR-4A closed on the Story Package plane, and it is the more dangerous of the two, because a world is stateful — one run with half its definition is not something a later deploy can take back.
Reads now resolve contractVersion against an explicit supported set {1, 2}
and refuse outside it, before the content is hashed. The order is the gate:
reversed, the hash check would pass for an unsupported row and the refusal would
never be reached.
The rollback floor
Production must not be rolled back below the deployment that carries this gate. A build without it accepts a future row and drops what it cannot read.
That floor is a commit, 8ead67e, and it cannot be stated as an API
version. The gate shipped without a version bump — nothing observable moved —
so the deployment before it and the deployment carrying it both report World
API 1.23.0. An operator checking 1.23.0 therefore learns nothing about
whether the gate is present, and the honest check at rollback time is
/api/version's runtime.sha: safe if it is 8ead67e or a descendant of it.
This is the one floor in this document that a version number cannot express,
which is exactly why it is written down.
No V3 writer may exist until this reader is deployed and confirmed — the same sequencing PR-4A/4B established, and for the same reason. This release adds no V3, no field and no new contract.
world_contract_unsupported
resolveEnvironmentPinnedLive gains the refusal reason, and the live-assertion
chain maps it to its own word rather than letting it fall into the world_gone
catch-all. That distinction is load-bearing: world_gone is terminal downstream
— it stops sessions and suppresses event wakes — whereas this is retryable, and
a rolling deploy clears it. Both event paths treat it exactly as they treat
story_contract_unsupported.
Existing V1 and V2 rows, and legacy rows carrying no stamp at all, read exactly as before.
1.30.0 — the machine lane can start the next episode
Additive: a second accepted credential pair on an existing path. No new path, no new field in the response, no behaviour change for any existing caller.
Why the owner-only choice did not hold
1.28.0 made POST …/episodes/next owner-plane only, reasoning that "the
mirror carries reads, and this is the write that decides a story moves on".
That reasoning conflated two different machine credentials. An admin key
proves the project and carries no provider identity — it stays refused, and
there is still no /admin mirror. But the machine lane (a Secret Key AND
a POUCHY-SOURCE-V1 provider signature) is the credential pair already trusted
to author every beat of the story, including the final one that ends an
episode, and to spend model money on turns and deliberations. The episode
pointer is less powerful than the beats that lane can already commit — while
refusing it made serial UX impossible for an unattended integrator: the
backend driving the whole story could take its player to an ending and then
strand them there (#2998).
The mechanics
Same dual proof as the turn door, in its own id namespace: the body carries a
requestId and the signature's id slot is epn:{requestId}, so an
episode-start signature can never be replayed onto the turn door, the events
ingress, a session mint or a deliberation — and none of theirs can start an
episode. requestId is a signature slot, not an idempotency key: the run
id stays server-derived from the worldline, the episode and the revision it
starts at, so a retry under any lane still lands on the same run and answers
created: false.
The owner lane is unchanged (empty body, signed-in project admin), and the rate limit on the machine lane fails OPEN — nothing here bills a model, the write is idempotent, and stranding a player at an ending is the worse failure.
Rollback
A previous build refuses the machine lane with the owner-plane 401/403 it always answered — an integrator falls back to exactly the pre-1.30 state, with nothing stored in a shape the older build misreads.
1.29.0 — focusing a beat
Additive: one optional request field on the turn door.
trigger.focusRoles on POST …/instances/{worldInstanceId}/turns
Before this, a caller beat ran every bound story role (in declaration order, up to the server cap) — there was no relevance filter and no caller input to narrow the cast, so a one-on-one scene was impossible to express through the API: every interview billed the whole ensemble and the whole ensemble answered.
focusRoles is the caller lane's narrowing input, mirroring the event lane's
subscription routing (subscribedRoleIds), with the same one-way property:
Shrink-only. The focus is intersected with the bound story cast. It cannot add a role the story does not declare, cannot bypass the actor-binding requirement, and cannot raise the per-turn cap. An id outside the cast simply selects nothing.
Skipped is recorded, not silent. Roles outside the focus are not run and
not billed, and each appears in skippedRoles with the fixed reason
not focused — a new member of the skip vocabulary, so a reader that switches
on reasons will start receiving it (the vocabulary has always been open in
that direction; treat unknown reasons as "passed over for a reason this build
does not know").
A focus that selects nobody refuses. Same answer as a cast with no bound
actor: no selectable role for this trigger, before any model call, so a typo
costs nothing.
Absent or empty is the old behaviour. No existing call changes; a request written against 1.28.0 runs identically on 1.29.0.
Rollback
A previous build ignores the field and runs the full cast — the pre-1.29
behaviour, with the pre-1.29 bill. Nothing stored changes shape; skippedRoles
rows with the new reason are ordinary strings to an older reader.
1.28.0 — the next episode
Additive: one new path, one new refusal on an existing one.
POST …/instances/{worldInstanceId}/episodes/next
Starts the next episode of a worldline that just finished one.
The worldline does not restart. Its worldInstanceId, membership, state and
ledger all continue — that is what "the world goes on" means — and this moves
the episode field on. Nothing is copied and nothing is deleted, so there is no
migration to get wrong and no second copy of the world's canon to drift.
It writes ONE field. storyState is untouched, so
commitWorldStoryPatches remains the single writer of story state. A second
writer would make "the ledger is the audit truth" false the first time the two
disagreed.
Idempotent. The run id is derived from the worldline, the episode and the
revision it starts at, so a retry lands on the same run and answers
created: false. Once the new episode has actually played a beat it is a LIVE
episode, and the same call answers episode_not_ended — a retry and "start the
next one" are only distinguishable because something says the episode has done
nothing yet.
The carryover
The response carries a projection of what the next episode gets to stand on: canon the last episode established (ids and the author's text), where the characters stand, the threads it left hanging, and the setups it never paid off. Every list is explicitly sorted and separately truncation-flagged.
Role-private notes are never in it. rolePrivate is the layer stripped from
every non-system viewer, and carrying it across an episode boundary would leak a
secret at exactly the moment nobody was looking.
No memory is carried, because the world runtime writes none — a world role's durable memory of the story IS the ledger and the state. If world story memory is ever built, its provenance and its carryover policy are that change's problem; nothing here invents a second Memory writer to have something to inherit.
A finished episode refuses deliberation too
POST …/deliberations now answers 409 episode_ended before any model call.
Deliberation produces narrative directions rather than simulated state, so there
is no branch to pin to an episode — the boundary that applies is simply that a
finished story costs nothing to ask about, and offering a menu of directions
none of which could be committed would be worse than refusing.
Rollback
The path is new, so a previous build does not serve it — a 405, not a wrong
answer. The episode field it writes is read by this version and ignored by
older ones, which means a rolled-back build would let a finished episode keep
taking beats. That is the same exposure as rolling back below 1.26.0 and is why
the writer flag gates the whole feature.
1.27.0 — a script for one episode
Additive: one optional request field, and two refusals on a path that already refused for other reasons.
The collision this resolves
An evidence draft's ledgerRange is a claim that everything downstream inherits
— editorial, the approval, the exported script's lineage. So the read has always
been "whole, or not at all", and a history longer than WORLD_LEDGER_LIST_CAP
is refused rather than drafted from its tail.
That is right, and it collides with a serial. A worldline is meant to outlive its episodes — that is what "the world goes on" means — so after enough episodes it exceeds the cap and could never produce a script again. The refusal's own message has always named the fix: "Archive or split the instance, or ask for a windowed evidence draft."
An episode is that window, and not by coincidence: turnBudget is capped at the
evidence cap precisely so one episode always fits one read.
What changed
POST …/script-drafts accepts an optional episodeRunId. With it, the
draft covers that episode's committed range instead of the whole instance.
The range comes from the episode's own terminal record, never from the
caller. A caller-supplied fromSeq/toSeq would be a second, unverified
account of what the episode was, and the first time it disagreed with the
snapshot nobody would know which to believe.
Only an ended episode can be drafted. A running one has no terminal record, and drafting it would stamp a range that is still growing.
Nothing else moves: scriptDraftJobId already hashes fromSeq/toSeq, so
per-episode drafts are distinct and idempotent with no change to storage, and
ApprovedScriptLineage.ledgerRange — already in the frozen V1 export contract —
already distinguishes them downstream.
Refusals
| status | code | |
|---|---|---|
| 404 | (none) | no ENDED episode by that run id. A 404 already says what it means, so it carries no code — that is the whole reason the code vocabulary exists for 409s |
| 409 | episode_history_incomplete |
the beats the episode declares could not all be read. Refused rather than shortened: a short draft would claim a range it does not contain |
Rollback
The field is optional and defaults to the previous behaviour exactly, so a
rollback below this version answers every existing request the same way. A
caller passing episodeRunId to an older build gets a whole-instance draft
rather than an error — worth knowing, though the field cannot reach one until
episodes are enabled at all.
1.26.0 — an episode that has to end
Additive: one optional field on the Story Package, one optional field on the progress checkpoint, and a new refusal on a path that already refused for other reasons.
What v3 adds
series — a list of episodes, each with a turn budget, an author-declared
fallback ending, and ending rules. A rule is a conjunction over a closed
condition vocabulary (node_completed, fact_revealed, flag_equals,
scene_is, clock_at_least), and every id it names must be declared in the
same package. Priority decides between rules that hold at once; ruleId breaks
a tie.
Supplying series is what makes a package v3, exactly as initialRelations
makes one v2. Lineage never walks backwards: a v3 head stays v3.
Reordering ending rules changes nothing. That is the opposite of every other array in this contract, where declaration order is narrative order — and it is deliberate. An author reshuffling a list must not silently change which ending a finished episode gets.
How an episode ends
Server-side, after a beat commits, over the state that beat produced. No
model, client or prompt supplies an endingId. A model that writes "and so it
ended" has narrated an ending, not caused one.
The evaluation, the episode.status = 'ended' stamp and the immutable terminal
record are one transaction — the same one that commits the beat. An episode
marked over with no record of how, or a record with no ending on the instance,
are not states this can reach.
Once an episode has ended, further story-mutating commits are refused. The turn path reports it; the single writer enforces it.
The turn budget is capped at the evidence window
turnBudget may not exceed 200, which is the evidence read's own ceiling. An
episode longer than one evidence read could never be turned into a script, and
an episode nobody can film is not a feature.
Rollback
series is optional and no package has one unless an author wrote it, so a
rollback below this version reads every existing row unchanged. A v3 row, if one
exists, is refused by the previous build rather than misread — which is what
the contract gate is for.
The writer is default OFF. POUCHY_STORY_CONTRACT_V3_WRITE must be set to
1 or true per environment before a v3 package can be published; until then a
publish carrying series is refused with code: "story_contract_v3_write_disabled"
and the field is never dropped to let it succeed as v2.
1.25.0 — an experience you can put down
Additive: one new verb on an existing path, plus a refusal that stops firing in a case where it used to.
The ratchet this closes
A project could hold at most MAX_ENVIRONMENTS_PER_PROJECT experiences, and
that ceiling counted every document under the collection. disabled wrote a
flag and freed nothing. There was no delete on any plane, owner or admin. So a
project that reached the ceiling — by shipping twenty, or by seeding fixtures
while exploring — was there permanently, and nobody could undo it, support
included. Nothing was corrupt; the door was simply one-way.
What changed
The ceiling counts ENABLED experiences. Disabling one gives the slot back,
which is what the flag always read as if it meant. The count is total minus
explicitly-disabled rather than a filtered query, because Firestore's == and
!= both skip documents where the field is ABSENT, and every experience created
before the flag existed carries no disabled field at all — filtering would
under-count exactly the oldest rows.
DELETE …/environments/{envId} removes an experience that has never been
run, taking its versions and compositions with it. Owner plane, admin level,
no admin-key mirror: the mirror carries reads.
It refuses with 409 and code: 'has_instances' the moment any instance
exists, and reports how many. A membership parents the story ledger; evidence
drafts, editorial drafts and approved exports descend from those rows, and an
approved export is content a person reviewed and signed off. Cascading through
that is a decision this endpoint does not make on anyone's behalf — disable
instead, which now frees the slot regardless.
Rollback
The verb is new, so a previous build simply does not serve it — a 405, not a wrong answer. The enabled-only count is a refusal that fires less often; rolling back restores the stricter ceiling without touching any stored row. Deletes already performed are not recoverable by rollback, which is true of any delete.
1.24.0 — the draft listing says what it lists
Additive, and a bug fix with an observable edge, so it gets a version of its own on the same reasoning as 1.23.0.
What the listing answered before
GET …/instances/{worldInstanceId}/script-drafts has always been "metadata
only, never the draft bodies" — it strips content and answers a beat count.
Correct, and one field short of usable: a caller that wanted to show which
committed range a draft covers, or what it was titled, had to fetch every
draft's full body to find out. The dashboard did not do that. It assumed the
listing still carried content, read four paths off it, and threw on every
render.
What it answers now
Five optional fields, derived server-side from the content it already had in hand:
| field | |
|---|---|
title |
the draft's own title |
ledgerRange |
{fromSeq, toSeq} — the committed range this draft reads |
sourceStoryPackage |
{packageId, revision} — no contentHash; a listing pins nothing |
scenes |
count |
cast |
count |
Counts, not material. No beats, no dialogue, no cast list, not even the scene titles. Reading a draft's script stays a separate, addressed GET, exactly as it is for an approved export.
Every one of them is optional, deliberately
This listing has always answered for a row whose content is missing or comes
from a future shape — an operator page that throws on one odd row is how a
plane becomes invisible exactly when someone needs to look at it. Making the
derived fields required would move that throw from the server into the browser.
So an absent field means the server could not derive it, and it is absent
rather than zero: scenes: 0 and "I could not tell" are different answers.
A half-formed ledgerRange is left out entirely rather than passed through.
SDK 0.20.0 rides this same API version
world-sdk 0.20.0 adds seven methods — getWorldOverview, getWorldCost,
listWorldVersions, listStoryPackageVersions, setWorldDisabled,
setStoryPackageDisabled, preflightWorld — and moves no API version.
Every route they call has been served since Batch 1; only the client was
missing, which is why the SDK's own README promised an adminKey read of
overview and cost that could not be made.
Nothing was removed
content was never in this response. Callers reading the fields that were
already there are unaffected, and the SDK's listScriptDrafts returns
{ drafts: unknown[] }, so its type surface does not move either — no SDK
version bump.