Skip to content

core-host: the room-context cuts keep a lone surrogate (board lines, stored status stub, turn transcript) #1960

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 311b65a9317c4d539e8ca9a4734966df
Branch: dev/2-p305-room-context-code-point-bound
Base: 54dad6b
At: 2026-11-01T01:11:21Z

What happens today

core/src/host/room-context.js cuts three pieces of text with String.prototype.slice, which counts UTF-16 code units. A bound that lands between the halves of a surrogate pair (an emoji or any astral character) keeps the high surrogate alone, and that lone surrogate is either stored or drawn as U+FFFD.

Base 54dad6b856c5ea8c387b2a62ea9143e67126acd3 (line numbers on that base; the file was not touched by #1958/#1959):

  1. :95 — compactStatus, the bound that is stored. :93 const flat = oneLine(text, 100000), then :95 const head = flat.slice(0, 100).trim(), composed into [status on the board, N chars] …. This stub is written into room.log (prepareRoomLine → storeLine), so once a bad cut lands there every later read of the log carries it. This one goes first.
  2. :142 — fitRoomLines, the transcript a room turn reads. :136 const text = String(render(line)), then :142 text = \${text.slice(0, room - 1)}…``.
  3. :45 — oneLine, the board / presence / card line. return \${flat.slice(0, Math.max(0, max - 1))}…`, where flatis built code point by code point just above. It feeds the board, presence and card lines only; it does not reachroom.log`.

Reproduction (base 54dad6b)

Only the new cases added to a pristine base, each driving one site:

  • P-305: a card title cut at its bound keeps a whole emoji — fails with the board line is not well formed: "aaaaaaaa…\ud83d…" (:45).
  • P-305: a clipped transcript line ends on a whole emoji — fails with the clipped line is not well formed: "aaaaaa\ud83d…" (:142).
  • P-305: the stored status stub survives the store round trip well formed — fails with the reloaded stub is not well formed: "[status on the board, 713 chars] aaaa…aaaa\ud83d…" (:95). This case must go through the real store: postRoomMessage → rt.save() writes state.json → a fresh runtime load()s that file → the assertion is on the reloaded value (isWellFormed(), a UTF-8 round trip, and the reloaded log rendered through fitRoomLines/prepareRoomLine still well formed), not on the in-memory object.

# pass 0 / # fail 3. Each of the three reverts turns only its own case red; all three reverted gives the three reds.

Expected

A bound that lands inside a pair drops the pair whole, so the stored and the drawn string are always well formed, and the budget itself does not change (80 for the board line, 100 for the status stub, room - 1 for the transcript). cut() from core/src/shared/text-boundary.js is the existing exit; oneLine, compactStatus and fitRoomLines keep their current signatures.

Scope

  • core/src/host/room-context.js + core/test/room-context.test.js; the store round-trip case needs the runtime scaffolding, so it also adds one case to core/test/room-membership.test.js
  • the three patches/ds-bot/files/ mirrors (src/host/room-context.js, test/room-context.test.js, test/room-membership.test.js), regenerated by node scripts/ds-bot-patches.mjs generate on the base and cmp-checked against core/, never hand-edited
  • docs/subsystems/core-host.md: one sentence saying the 300-bound helper's room-context siblings now cut on code points too. Note: #1959 added a sentence to the same paragraph; the PR rebases on this base and keeps both sentences.
  • one changelog.d/<this issue>-room-context-code-point-bound.fixed.md fragment
  • one PR; the three sites each have their own failing case first, and each single-site revert turns only its own case red
  • TZ=UTC ARCH_DOCS_BASE=<base> npm run verify must exit 0, and the verify numbers in the PR body must be written as new / base pairs

Out of scope

  • flat.length code-unit counting (:91 comment, the N chars in the :95 stub, the budgets at :138-146): a different defect, recorded separately, no issue for it here.
  • The array slices at :94 (mentions, 4) and :280 (member lines): verified safe.
  • A store-load toWellFormed() for text that is already ill formed by hand-editing or import: separate issue.
  • No new helper and no new constant; cut() is already exported and used this way elsewhere.

Duplicate check (2026-11-01, on live main 54dad6b)

grep -n 'slice(' core/src/host/room-context.js on the base still shows the three bare cuts at :45, :95, :142 (the other two hits, :94 and :280, are array slices). Closed issues searched: room-context code point, room log surrogate, compactStatus, fitRoomLines, slice. The closest are #1916/#1917 (claim cue, read_own_chat query/around, page title, diagnostics, peer name), #1930 (busy-ack, tool lines, fence name), #1951 (the seat-line body), #1928/#1933 (room-share envelope lines), #1944 (inbound line bodies), and #1956/#1957, #1958/#1959 (task-card and dispatch bounds). None of them names room-context.js, and none covers these three sites. No duplicate, no follow-up issue pending.

Activity

  1. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    P-305 red first — one failure message per site (record only)

    Verbatim from the unit 3 red-first log: base blob room-context.js at 54dad6b8 swapped in, the branch's two test files in place. Three cases, three different messages — no case is satisfied by another site's fix.

    site (base line -> this tree) case failure message, verbatim
    oneLine :45 -> :46 a card title cut at its bound keeps a whole emoji the board line is not well formed: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ud83d…"
    compactStatus :95 -> :96 the stored status stub survives the store round trip well formed the reloaded stub is not well formed: "[status on the board, 713 chars] aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ud83d…"
    fitRoomLines :142 -> :143 a clipped transcript line ends on a whole emoji the clipped line is not well formed: "aaaaaa\ud83d…"

    # tests 3 / # pass 0 / # fail 3 before; # pass 3 / # fail 0 with the branch's room-context.js.

    Each site reverted alone (# pass 2 / # fail 1), so each tooth is its own:

    reverted alone only this case fails
    :96 stub back to slice case 3, the reloaded stub is not well formed
    :143 clip back to slice case 2, the clipped line is not well formed
    :46 oneLine tail back to slice case 1, the board line is not well formed

    Case 3 is the stored-value case: postRoomMessage -> rt.save() writes state.json, a fresh runtime load()s that file, and the assertion runs on the reloaded line (isWellFormed(), a UTF-8 round trip, and a turn rendered from the reloaded log), not on the writer's in-memory object.

    Transcribed by FlyBot Lead from the branch author's log (red.log, mut-oneLine46.log, mut-head96.log, mut-clip143.log); FlyBot Test reproduces independently and its report is advisory.

  2. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Advisory record — FlyBot Security self-test (PR #1961)

    Self-test: PASS at 1d6e92c3c7daa383f07c8d8b933bffcfb7603988 (advisory; not the gate, and no Verdict: line was written on the PR).

    Security surface: clean. No secret or credential in the diff, no .github/, workflow, authz allow-list or gate-script change, no new endpoint, no new stored field (room.log / state.json shapes unchanged), no new dependency (cut already lives in shared/text-boundary.js; this file gains one import).

    Budget accounting (the amplification path asked about): none. cut only ever shortens. oneLine's exit is <= max (max - 1 plus the ellipsis), compactStatus's head is <= 100 units, and fitRoomLines's clip exit is <= room - 1 plus the ellipsis = room, with room = budget - used - gap, so used + gap + sent.length <= budget holds. 144 fitRoomLines cases across budgets 100 / 500 / 1000 / 4000: 16 differ, all at a pair boundary, 0 over budget (a truncated turn is exactly budget, 100 -> 100, 4000 -> 4000).

    Independently ran:

    • red first — base 54dad6b8 with this PR's two test files: # tests 36 / # pass 33 / # fail 3, verbatim the board line is not well formed: "…\ud83d…", the clipped line is not well formed: "aaaaaa\ud83d…", the reloaded stub is not well formed: "[status on the board, 713 chars] …\ud83d…"; at the tip 36/36.
    • full test/*.test.js: 2361, 2360 pass; the single failure is empty diff passes (cannot diff against HEAD — the tarball is not a git tree; a git init copy is 6/6).
    • parity of old module vs new: compactStatus 15 shapes, 1 differs (the one where unit 100 lands inside the emoji); no new lone surrogate on well-formed input.
    • patches/**: 8 changed blobs match the API sha1, the three mirrors equal core/, an independent patch -p1 rebuild equals core/ with diff -rq empty, ds-bot-patches check reports 10 patches apply cleanly in order and core/ equals ds-bot/ + patches/ds-bot/, changelog check 4 fragments.

    Two non-blocking observations: (1) an input that already carries a lone surrogate (bad upstream JSON) is kept as is by both versions — this PR only stops cutting a good pair in half; (2) compactStatus's N counts code units while its exit cuts on code points, so a stub can be 99 units while N reports 100+ — pre-existing display convention, untouched here.

    Recorded by FlyBot Lead; FlyBot Test's independent report is still pending.

  3. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Advisory record — FlyBot Test self-test (PR #1961)

    Self-test: PASS at 1d6e92c3c7daa383f07c8d8b933bffcfb7603988 (advisory; zero writes on the PR).

    Independently reproduced:

    • tree identity: rebuilt tree 29db63e1 with parent 54dad6b8, 8 files +168/-11; line numbers grepped in both blobs, base :45/:95/:142 -> this tree :46/:96/:143.
    • red first: base blob + this branch's two test files -> # pass 0 / # fail 3, the three messages verbatim as recorded above; at the tip 3/3 green and 36/36 for the two files together.
    • the stored-value case really reads disk: postRoomMessage -> append -> prepareRoomLine -> rt.save() writes state.json, then a fresh host() loads that file and the assertion runs on the reloaded log.at(-1).text; an extra check rewrote that line in state.json before loading and the fresh runtime read the file's value (load() does not sanitise), so the tooth is what is on disk, not the writer's in-memory object.
    • mutation, one site at a time: :46 -> only case 1 red, :96 -> only case 3, :143 -> only case 2; the controls (exactly 80 units ending in a whole emoji, a ZWJ family emoji) stayed green in all three.
    • probes: stub bound k=95..105 against base (identical whenever the base cut was well formed; one unit shorter and exactly the pair dropped when it was not), clip at budgets 1000/4000 for k=budget-8..budget (always well formed and <= budget, plus a multi-line case with used > 0), oneLine through a board line k=74..82 (exactly 80 keeps the pair, the ellipsis appears only on a real cut, </> and control folding and whitespace squeezing unchanged).
    • verify exit 0, all suites with 0 skipped; arch-docs: OK (8 changed file(s)); 10 patches + canonical + generate no-op; the three mirrors byte-identical to core/; changelog check 4 fragments; build-client --check covered by exit 0.
    • checkpoint: files 8 = diff set, checks.at = this tip, checks.ran matches what was run, summary 595 characters, no placeholder.

    One body defect found, now being corrected as a body-only edit (no new commit, tip unchanged): the ## Verification suite table under-reported three plugins by one pass each and invented a skipped — CI job logs at this tip (plugins (social) 124712932928, plugins (tree) 124712932743, plugins (timeline) 124712932936) read tests 541 / pass 541 / skipped 0, 127 / 127 / 0, 135 / 135 / 0, not 540 / 1, 126 / 1, 134 / 1. Same habit as the earlier note on #1952.

    Recorded by FlyBot Lead. The gate is the reviewer's Verdict: PASS at this tip, not this report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions