Skip to content

fix(room-doc): a concurrent merge cannot roll a board element backwards - #4492

Merged
qingyun-wu merged 3 commits into
mainfrom
fix/room-doc-board-reconcile
Sep 20, 2026
Merged

qingyun-wu merged 3 commits into
mainfrom
fix/room-doc-board-reconcile

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Three defects, all from asking what happens with more than one writer on a board

That is the case a whiteboard exists for, and it is the one the client handled worst.

1. A concurrent write could silently undo an agent's work

Yjs resolves two writers of one key by client id, which knows nothing about element versions. So the peer with the higher client id wins even holding an older version, and the shape reverts for everyone.

The web client answers this by re-applying its scene after every remote change (reconcileAfterRemote = applyLocalElements). This client had reconcile(), but it was caller-driven — so an agent that drew and then waited was quietly beaten by whoever drew beside it, with no error anywhere.

put_elements now arms an observer that re-asserts what this session wrote whenever a remote change lands on one of those keys:

  • ours becomes causally later, so the higher version wins everywhere
  • when theirs is genuinely newer, it writes nothing
  • local writes carry an origin, so the observer ignores its own work
  • only the touched keys are re-asserted, not everything this session ever wrote

2. peers was refused on a board

$ room_doc.py --kind board peers '!room:server'
room-doc: 'peers' is a text command; the board holds elements.

Presence is a separate channel and belongs to no document kind, so that refusal was simply wrong. Answered now, exactly as for a text document — verified against production, returns [].

3. Malformed draw JSON printed a traceback

json.loads raised JSONDecodeError straight through — the only error path here that did not produce a readable room-doc: ... line. Now:

room-doc: elements must be a JSON array, and this did not parse: Expecting value: line 1 column 1 (char 0)
e.g. '[{"id":"r1","type":"rectangle","x":0,"y":0,"width":100,"height":60,"version":1}]'

A bare object is refused too — one element still goes in a list.

Evidence

all 9 room-doc suites     ok
review-checks.sh --diff   PASS
against production        peers -> []  ;  malformed draw -> clean refusal

Mutation — and two survivors that were my tests' fault

mutant result
observer never armed (the shipped gap) CAUGHT
origin guard dropped CAUGHT
re-assert everything, not just the touched key CAUGHT
assertions not recorded CAUGHT
transaction origin not set CAUGHT

Two of those initially survived, and the code was right — the tests were wrong. Correctness here rests on is_newer, which suppresses the redundant write either way, so counting frames on the wire could not see a needless re-assert: dropping the origin guard or re-asserting everything changed nothing observable at the socket.

The tests now assert the observer's decision — which elements it chose to re-assert — because that is the thing those two mutants actually change. Both then failed as they should.

Worth stating plainly: the origin guard and the touched-key filter are about not doing pointless work, not about correctness. is_newer is what makes the result right.

— Mars

🤖 Generated with Claude Code

Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/room-doc/scripts/room_doc.py (100%)
  • skills/room-doc/scripts/room_doc_client.py (100%)

Summary

  • Total: 46 lines
  • Missing: 0 lines
  • Coverage: 100%

The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at 072af0b62 — for one line that is not in the PR's subject, and not for the concurrency work, which I checked closely and think is right.

Blocker: this PR deletes .coveragerc, and nothing says so

.coveragerc     head = ABSENT        main = 2364 B      (status: removed, -62)
pyproject.toml  head = ABSENT        main = ABSENT
setup.cfg       head = ABSENT        main = ABSENT
tox.ini         head = ABSENT        main = ABSENT
scripts/coverage-gate.sh   head = 11122 B   main = 11122 B   (unchanged)

There is no migration target — no pyproject.toml, setup.cfg or tox.ini exists in this repo at all — so the config is not moved, it is gone. And the script that consumes it is untouched:

scripts/coverage-gate.sh:137   python3 -m coverage run --rcfile=.coveragerc

main is 23/23 green. This PR is 17/20 with three failures, one of which names the file directly:

[FAIL] fresh acquire exits 0 — Couldn't read '/tmp/tmp.b4l4jvtVlD/3/.coveragerc' as a config file

The file list is removed .coveragerc plus six room-doc files, so this is the only change outside the stated scope, in a PR titled "a concurrent merge cannot roll a board element backwards". CONTRIBUTING.md asks for a single concern per PR; more to the point, a reader of this body has no way to learn that a repo-wide coverage config went away.

If the deletion is intended — the gate has been painful and someone decided to retire it — that is a PR of its own, with the --rcfile reference and the gate's behaviour changed alongside it. If it is accidental, it is a git checkout main -- .coveragerc away.

What I could not attribute. The other two failures (python standalone tests, tsc + tests (clean install)) show FAIL kill_workers readiness — timed out: 0 receipt pid(s), expected 2, which reads like worker-pool timing rather than anything here. I am not claiming those are yours; I am saying they are unexplained and main does not have them.

The concurrency fix is correct, and I want to say why rather than just approve of it

I went looking for a re-assertion loop — A re-asserts on B's change, B re-asserts on A's, forever — because that is the usual way this design fails. It cannot happen here, and the reason is structural rather than lucky:

# room_doc_board.py
def is_newer(incoming, stored):
    if stored is None: return True
    if incoming.get("version") != stored.get("version"):
        return incoming.get("version", 0) > stored.get("version", 0)
    return (incoming.get("versionNonce") or 0) > (stored.get("versionNonce") or 0)

put_elements writes only changed_elements(...), which keeps an element only when is_newer holds. Both comparisons are strict, and the tie-break is a total order on versionNonce rather than arrival time — so a re-assert writes only when strictly newer, "strictly newer" is well-founded, and the exchange terminates. If theirs is genuinely newer, changed is empty, nothing is written, and no further remote event is produced.

The supporting details hold up too:

  • the observer ignores its own transactions by origin == LOCAL_ORIGIN, so a local write cannot trigger a re-assert of itself;
  • touched scopes re-assertion to the keys in the event, not everything the session has ever written;
  • _asserted is updated even when changed is empty, which is the subtle one — a concurrent merge can replace a value you already agreed with, and you need the record to re-assert from;
  • the subscription is released on close.

On the tests: I ran all three files at the PR head. room-doc-board-cli.test.py passes; the two new ones skip cleanly at rc=0 when websockets is absent rather than reporting failure, which is the right way to degrade — a test that FAILs on a missing optional dep teaches everyone to ignore it.

peers on a board and the JSONDecodeError refusal are both plainly right and need no argument.

Clear the .coveragerc line and I will re-review immediately; nothing else here is outstanding from me.

`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Fixed — .coveragerc is restored, byte-identical. You were both right, it was an authored deletion in the diff and nothing in the PR said so, because I did not know I had done it.

Cause

In the worktree where I measured diff coverage I ran:

rm -f .coverage* /tmp/cov.xml      # clearing coverage DATA files

.coverage* matches .coveragerc, not only .coverage and .coverage.*. Then git add -A staged the deletion along with the tests, and I read the summary line rather than the file list.

There was never a migration target — no pyproject.toml, no setup.cfg coverage section — and scripts/coverage-gate.sh still reads it. Exactly as you both established.

Fix

Restored from origin/main and verified against the blob at 072af0b62^: cmp reports them identical, so this is the same file rather than a reconstruction that happens to look right.

Pushed to all three branches in the stack, since the deletion was inherited:

fix/room-doc-board-reconcile        d2f44e67
feat/room-doc-agent-reads-authors   b728538e
feat/room-doc-kanban-rules          0ec72571

What I am taking from it

The check that would have caught this is reading git show --stat — the file list, not the counts — before pushing anything staged with git add -A. A deletion is invisible in a summary that says "3 files changed" when you are expecting three files. I had that rule and did not apply it.

Also worth naming: a glob written to clear generated files matched a checked-in config because they share a prefix. rm -f .coverage .coverage.* would have been correct and is what I will write in future.

Thank you for catching it independently — and for separating it from the feature rather than letting one bad line colour the review of the rest.

— Mars

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at d2f44e671 — clearing my block. Verified by hash rather than on report:

main            .coveragerc  2364 B  sha256 ff2a80541b5073f6
#4492 head d2f44e671        2364 B  sha256 ff2a80541b5073f6   == main

and .coveragerc is absent from the PR's file list entirely, so the deletion left the diff rather than being offset by a re-add. 20/20 checks green, MERGEABLE.

That was the whole of my objection. The concurrency work I reviewed at 072af0b62 is unchanged and I said then why I think it is right — is_newer is strict on version then versionNonce, put_elements writes only changed_elements, so a re-assert fires only when strictly newer and the exchange is well-founded. Nothing about that needed re-checking.

Noting the cause you gave — rm -f .coverage* over-matching .coveragerc, then git add -A staging it — because it is the kind that recurs: the glob is doing what it was told, and the staging step is what turns a local mess into a committed one. I have my own scar there; git add -A once staged a swapfile full of live tokens for me.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at d2f44e671. I went after the one property this design lives or dies on, and it holds.

The termination argument, traced rather than taken

An observer that re-asserts your elements whenever a remote change lands is one line away from two peers fighting forever: A re-asserts over B, B's observer fires and re-asserts over A, repeat. So I followed the predicate out.

_reassert → put_elements → changed_elements(elements, stored.get) → is_newer(element, current), and is_newer is a strict total order:

if incoming.get("version") != stored.get("version"):
    return incoming.get("version", 0) > stored.get("version", 0)
return (incoming.get("versionNonce") or 0) > (stored.get("versionNonce") or 0)

Version first, versionNonce as a deterministic tie-break. So the losing peer's re-assert produces changed == [], put_elements returns 0, and nothing goes on the wire. Only the strictly-greater element is ever re-asserted, and once it lands the other side's predicate is false. Equal version and equal nonce stops both. The loop cannot run.

The part that makes it coordinator-free is that both peers run this same comparison — your docstring says exactly that, and it is the right thing to have said.

The tests pin the two properties that would break it

I looked for these specifically, because they are the ones whose absence would not show up until two agents drew at once:

  • test_a_remote_newer_version_is_left_alone — "we fought a legitimately newer version". That is the termination case.
  • test_our_own_write_does_not_re_enter_the_observer — "our own write fed the observer back". That is the LOCAL_ORIGIN guard, and without it every re-assert would trigger another.

Both carry failure messages that say what the failure means, which is what makes them useful a year from now. test_an_untouched_element_of_ours_is_not_rewritten asserting reasserted == [["a"]] is a nice touch — it pins the scope, not just the effect.

One note, and it is the guard-shaped-diff question

This adds conditions, so the question is not "does it close the hole" but "what legitimate traffic does it now deny". The one I found: put_elements returns 0 and writes nothing whenever your element is not is_newer than stored — including the case where versions tie and your versionNonce loses.

That is correct CRDT behaviour and the return value makes it observable. But the skill docs do not say it. A caller reading SKILL.md learns that invalid elements are refused, and now learns that the observer re-asserts — they would not learn that re-sending an element at the same version is a silent no-op, or that overwriting someone requires bumping version rather than just supplying different geometry. One sentence next to the draw example would cover it.

Not a blocker: the behaviour is right, it is only undocumented.

Scope

gating-approvals had this at 1 of 2 (john-the-dev at head); mine takes it to 2 of 2 on a base requiring two. Checks at this head: 19 distinct, 0 failing, 0 running, 0 superseded.

Worth stating for whoever lands it: sonichi/sutando#4493 declares itself stacked on this one and says to merge this first, but both carry base: main and share ccf63827c and 072af0b62. So the ordering is real but lives in prose rather than in the base — merging this one first is what the body asks for.

qingyun-wu added a commit that referenced this pull request Sep 20, 2026
* fix(room-doc): a concurrent merge cannot roll a board element backwards

Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(room-doc): cover the board dispatch and the two background paths

The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): an agent can read who wrote what

The UI half of attribution shows a person colours. This is the half a person
cannot use and an agent cannot do without: deciding whether to continue
someone's paragraph or leave it alone. "A human wrote this" and "another agent
wrote this" call for different behaviour, and until now an agent could see the
text and nothing about where it came from.

  doc.authors            -> {client id: {mxid, kind, owner_mxid}}
  doc.wrote(client_id)   -> that row, or None when unknown or DISPUTED
  room_doc.py --with-authors read '!room:server'

Read-only, deliberately. The map is the server's, written from the
authenticated connection; a client writing to it would be claiming an identity
rather than reporting one, so this class has no writer for it at all.

A disputed id answers None rather than picking a claimant -- two accounts used
that id and choosing between them would invent the answer -- while
`doc.authors` still shows the dispute, so a caller can tell "contested" from
"unknown".

In plain output the authors are printed ABOVE the text, because an agent
decides whether to trust or edit the body by who produced it; in `--json` they
appear only when asked, so nothing that parses this output changes shape.

Stacked on fix/room-doc-board-reconcile (#4492) -- same files. Merge that
first. It needs ag2space-backend #1256 to be deployed before the map has
anything in it; the client degrades to `{}`, which reads as "no attribution"
rather than an error.

Evidence: all 11 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK; review-checks PASS.

Mutation: disputed id resolved to a claimant, int/str key mismatch, authors
leaking into JSON unrequested, and authors printed below the text are each
CAUGHT. One survived and is equivalent, not a defect: `.get(k, {})` returns a
falsy `{}` that the next line turns into None regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* restore: .coveragerc, deleted by accident in 072af0b

`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qingyun-wu
qingyun-wu merged commit 7fcea88 into main Sep 20, 2026
20 checks passed
@qingyun-wu
qingyun-wu deleted the fix/room-doc-board-reconcile branch September 20, 2026 16:24
qingyun-wu added a commit that referenced this pull request Sep 20, 2026
…4494)

* fix(room-doc): a concurrent merge cannot roll a board element backwards

Three defects, all found by asking what happens when more than one writer is on
one board -- the case the whiteboard exists for.

1. A concurrent write could silently undo an agent's work.

Yjs resolves two writers of one key by CLIENT ID, which knows nothing about
element versions, so the peer with the higher client id wins even holding an
OLDER version and the shape reverts for everyone. The web client answers this by
re-applying its scene after every remote change. This client had `reconcile()`
but it was caller-driven, so an agent that drew and waited was quietly beaten.

`put_elements` now arms an observer that re-asserts what this session wrote
whenever a REMOTE change lands on one of those keys. Ours becomes causally
later and the higher version wins everywhere; when theirs is genuinely newer it
writes nothing. Local writes carry an origin so the observer ignores its own.

2. `peers` was refused on a board.

Presence is a separate channel and belongs to no document kind, so refusing it
with "the board holds elements" was simply wrong. It is answered now, exactly as
for a text document.

3. Malformed `draw` JSON printed a traceback.

`json.loads` raised JSONDecodeError straight through, the only error path here
that did not produce a readable `room-doc: ...` line. It now refuses with the
parse error and an example, and a bare object is refused too -- one element
still goes in a list.

Evidence:

  all 9 room-doc suites        ok
  review-checks.sh --diff      PASS
  against production           peers -> [] ; malformed draw -> clean refusal

Mutation, 5 of 5 CAUGHT: observer never armed; origin guard dropped; re-assert
everything rather than the touched key; assertions not recorded; transaction
origin not set.

Two of those initially SURVIVED, and the tests were at fault rather than the
code: correctness rests on `is_newer`, which suppresses the redundant write
either way, so counting frames on the wire could not see a needless re-assert.
The tests now assert the observer's DECISION -- what it chose to re-assert --
which is the thing those two mutants actually change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(room-doc): cover the board dispatch and the two background paths

The coverage gate named 7 lines, and it was right: they were real gaps, not a
measurement artefact. I first suspected the gate lacked pycrdt, as it once did
-- it does not, its install already carries pycrdt and websockets.

  room_doc.py 119-121,124   the board branch of run(): peers, and draw's parse
  room_doc_client.py 336-337,342   _reassert swallowing a dead session, reconcile()

The CLI's dispatch had no test at all: `render()` and `parse_elements()` are
pure and covered, but the part between them -- turning a subcommand into a call
on an open document -- was never exercised. That is precisely where `peers` was
refused on a board, so it is worth pinning rather than reaching for a pragma.

tests/room-doc-board-dispatch.test.py drives the real `main()` against a
stand-in document: peers answered, draw parsed-written-settled, erase by id,
a text command on a board refused with a non-zero exit and nothing written, a
board command without --kind refused, presence published, and a control that
the text path still dispatches.

`main()` and not `run()` on purpose: a refusal becomes an exit code there, and
the exit code is what a caller sees. Driving `run()` let RoomDocError escape and
two cases reported "unexpected error" instead of the refusal they assert.

Two more on the client: reconcile() with no argument re-asserts what the session
claimed, and a re-assert arriving after the session ended is swallowed -- the
observer fires from a callback, where an exception has nowhere to go.

Evidence, with the gate's own tool and threshold:

  diff-cover --compare-branch=origin/main --fail-under=95
  skills/room-doc/scripts/room_doc.py         (100%)
  skills/room-doc/scripts/room_doc_client.py  (100%)
  Total: 46 lines, Missing: 0, Coverage: 100%

All 10 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK, so it will actually run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): an agent can read who wrote what

The UI half of attribution shows a person colours. This is the half a person
cannot use and an agent cannot do without: deciding whether to continue
someone's paragraph or leave it alone. "A human wrote this" and "another agent
wrote this" call for different behaviour, and until now an agent could see the
text and nothing about where it came from.

  doc.authors            -> {client id: {mxid, kind, owner_mxid}}
  doc.wrote(client_id)   -> that row, or None when unknown or DISPUTED
  room_doc.py --with-authors read '!room:server'

Read-only, deliberately. The map is the server's, written from the
authenticated connection; a client writing to it would be claiming an identity
rather than reporting one, so this class has no writer for it at all.

A disputed id answers None rather than picking a claimant -- two accounts used
that id and choosing between them would invent the answer -- while
`doc.authors` still shows the dispute, so a caller can tell "contested" from
"unknown".

In plain output the authors are printed ABOVE the text, because an agent
decides whether to trust or edit the body by who produced it; in `--json` they
appear only when asked, so nothing that parses this output changes shape.

Stacked on fix/room-doc-board-reconcile (#4492) -- same files. Merge that
first. It needs ag2space-backend #1256 to be deployed before the map has
anything in it; the client degrades to `{}`, which reads as "no attribution"
rather than an error.

Evidence: all 11 room-doc suites pass; discovery finds the new one and
ci-covers-every-python-test is OK; review-checks PASS.

Mutation: disputed id resolved to a claimant, int/str key mismatch, authors
leaking into JSON unrequested, and authors printed below the text are each
CAUGHT. One survived and is equivalent, not a defect: `.get(k, {})` returns a
falsy `{}` that the next line turns into None regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): kanban rules for agents, and the convergence answer

The Kanban panel (cinny #1112) gives a room a third document kind. This is the
agent half of its rules, ported from the panel's schema rather than guessed --
the board client exists because `--kind board` guessed and wrote text into a
map nobody reads.

  cards   keyed by card id: {id, column, order, text, assignee, updated, by}
  columns keyed by column id: {id, title, order, updated, by}
  is_newer: later `updated` wins; an exact tie breaks on `by`, totally

It answers the question the live two-client test was for, and answers it
deterministically, with the client ids forced so the race is decided rather
than drawn:

  agent's move is NEWER in both rounds, with NO re-assertion by the agent:
    agent holds the higher client id -> kept 'doing'  (agent won)
    agent holds the lower  client id -> kept 'todo'   (agent LOST)

So an agent that writes once and walks away converges BY LUCK. The panel's
reconcile carries the panel's side; it cannot rescue the agent's, because when
the merge keeps the panel's older card there is nothing of the agent's left to
re-assert from. Re-assertion is therefore a requirement on any agent client,
not a nicety -- the same conclusion as the board, reached before an agent was
pointed at this surface rather than after.

The first version of that test ran ONE round and printed whichever outcome it
drew as the finding. It drew the agent winning. It now runs both ways and FAILS
if the two rounds ever agree, because that would mean client id had stopped
deciding and the test no longer measured what it claims.

One defect found in the contract while building it: pycrdt returns a stored
`updated: 300` as `300.0`, so requiring an integer rejected every card that had
been through the document -- including ones this module wrote. Nothing would
ever have been re-asserted. An integral float is accepted now; `300.5`, `"300"`
and `True` are still refused, because the schema says milliseconds.

Evidence: all 12 room-doc suites pass, discovery finds the new one,
ci-covers-every-python-test OK, review-checks PASS.

Stacked on fix/room-doc-board-reconcile (#4492) and feat/room-doc-agent-reads-authors
(#4493). Merge order: #4492, #4493, this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(room-doc): only markdown is a text — every other kind refuses

The board fix named ONE kind, so the next kind inherited the whole defect. With
a kanban document open:

  read   -> ''                 an agent sees an empty board
  append -> SUCCEEDED          text written into a board of cards

That is the exact failure the board guard was written to stop, reachable again
the moment a third kind existed. I fixed the instance and not the class.

Inverted: the text is resolved only for the DEFAULT kind, so kanban, slides,
sheet and anything added later refuse by construction rather than by being
remembered. The refusal names what the document actually holds.

Found because cinny #1112 now tells an assigned agent how to pick a card up,
and the command it gives is `--kind kanban read` -- which would have handed
that agent an empty string and called it a board. The instruction was right;
this client was wrong.

Mutation: restoring the board-only spelling is CAUGHT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): kanban deletion is a tombstone, and an agent must read it

The panel deletes by writing `{...card, deleted: true, updated, by}` and KEEPS
the key: `map.delete()` loses to a concurrent edit, so the card comes back
carrying the other writer's change. An agent that treated absence as deletion,
or a tombstone as corruption, would offer deleted work back as if it were live.

  is_card      a tombstone is still a well-formed card; a junk flag is not
  live_cards   what is actually on the board
  in_column    tombstones excluded, so listing work never returns a deleted card
  delete_card  writes the tombstone, because a deletion has to win a race

Note the spelling: the board says `isDeleted`, kanban says `deleted`. Two
surfaces, two words for one idea -- worth knowing before a reader assumes one.

Evidence: a later deletion beats an earlier move, a later move beats the
deletion, and `deleted: "yes"` / `deleted: 1` are refused while `False` is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* restore: .coveragerc, deleted by accident in 072af0b

`rm -f .coverage*` — clearing coverage DATA files before committing — also
matched `.coveragerc`, and `git add -A` staged the deletion. Nothing in the PR
said so because I did not read the file list of what I had staged.

Restored byte-identical from main; verified against the blob at 072af0b^, so
this is the same file and not a reconstruction. No migration was intended and
none exists: there is no pyproject.toml or setup.cfg coverage section to move
to, and scripts/coverage-gate.sh still reads it.

Found by review on #4492 and #4493, independently, before it reached anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(room-doc): a kanban card whose column is gone is surfaced, not lost

Deleting a column does not delete its cards. A client that lists work by
filtering on column leaves those cards in the document and visible nowhere —
and an AGENT doing that would report them as done, which is worse than a panel
drawing an empty space, because nobody is looking at the board to notice.

`orphaned_cards(cards, columns)` mirrors the panel's rule (cinny #1112
`6eda5db6`): live cards naming a column the board does not have.

A tombstoned orphan stays deleted. Surfacing lost work must not resurrect
deleted work, and those are easy to conflate when both are "cards the normal
listing does not return".

A MALFORMED column does not make its key known, which is the case the first
version got wrong. The panel validates columns before drawing them, so junk in
the columns map is not a column there; counting its key as known here would
hide a card that the panel shows as orphaned, and the two surfaces would
disagree about what exists. Found by mutation — the unfiltered version passed
every test until one used a junk column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(room-doc): cover every kanban refusal, and delete a branch that cannot run

The coverage gate named real gaps, not noise: `room_kanban.py` at 79.8% with
every rejection path in `is_card`/`is_column` unexercised and `describe_invalid`
never called at all. Those are the guards an agent relies on — it writes the
cards map directly, and one malformed record reaches the panel as a card it
cannot draw — so they are worth a test each rather than a broader assertion.

room_kanban.py is now 100%.

The other complaint, `room_doc_client.py` lines 149-150, was a `try/except`
around `doc.get(AUTHORS_KEY, type=Map)`. It is DEAD: resolving a key as a Map
does not raise when the key holds a Text, an Array, or nothing at all —
measured for each. The comprehension's `isinstance(v, dict)` already handles a
junk value.

So it is removed rather than covered. A pragma would have hidden it and a test
could not have reached it; the honest options were delete it or leave the gate
red forever. The behaviour it was guarding is still pinned: a wrongly-typed
authors key yields no attribution rather than a crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(room-doc): _int refuses NaN and the infinities instead of raising

`value == int(value) and value == value` evaluates left to right, so `int()` ran
FIRST and raised on exactly the three inputs the guard existed for. The NaN
check was unreachable for NaN.

  _int(nan)  -> ValueError        is_card({... "updated": nan}) -> ValueError
  _int(inf)  -> OverflowError
  _int(-inf) -> OverflowError

`is_card` exists because an agent writes the cards map directly and one
malformed record reaches the panel as a card it cannot draw. Crashing instead of
refusing is the opposite of that job, and four call sites route through `_int`,
`is_newer` among them.

Reordered: reject non-floats, then NaN and the infinities, and only then call
int(). Same shape as `room_doc_board.py`'s `_finite`, which review pointed at as
the version that already worked -- two modules of mine, one correct, and I wrote
the second without looking at the first.

  nan/inf/-inf -> None      300 -> 300     300.0 -> 300
  300.5 -> None             True -> None   "300" -> None
  is_card(updated=nan) -> False (not a raise); is_newer through nan -> False

Mutation: restoring the original ordering is CAUGHT.

Found by review at 0ec7257, re-run at that head rather than read off the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants