Repository navigation
feat(room-doc): an agent can read who wrote what - #4493
Conversation
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>
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>
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>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
skills/room-doc/scripts/room_doc_client.pyLines 144-153 144 to it would be claiming an identity rather than reporting one.
145 """
146 try:
147 authors = self._doc.get(AUTHORS_KEY, type=Map)
! 148 except Exception: # noqa: BLE001 - an absent map is simply no attribution
! 149 return {}
150 return {str(k): dict(v) for k, v in authors.items() if isinstance(v, dict)}
151
152 def wrote(self, client_id: int | str) -> dict | None:
153 """The author behind one client id, or None when unknown or disputed. |
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed at head 956aeb0b8b6a. The room-doc work itself reads well and I verified the hard part of it against the backend side — detail at the bottom. One blocker, and it is unrelated to the feature: this PR deletes .coveragerc, and that is what is turning the suite red.
Blocker — the deleted .coveragerc breaks every instrumented test
The diff removes .coveragerc in full (@@ -1,62 +0,0 @@), and nothing replaces it — the other 7 files are room-doc sources and tests, no pyproject.toml or setup.cfg entry.
It is an authored deletion, not a rebase artifact:
merge base 85fdc0aa0 -> .coveragerc present, 2364B
head 956aeb0b8 -> 404
main -> .coveragerc present, 2364B
scripts/coverage-gate.sh:137 passes it explicitly:
env SUTANDO_TEST_SUBPROCESS_COVERAGE=1 … python3 -m coverage run --rcfile=.coveragerc
So each instrumented test now dies before it runs a single assertion. From the diff coverage >= 95% (python) job log at this head:
Couldn't read '/tmp/tmp.cFRl8OTnpg/1/.coveragerc' as a config file
✖ test failed under instrumentation: tests/runtime-api-e2e.test.py
Couldn't read '/tmp/tmp.cFRl8OTnpg/1/.coveragerc' as a config file
✖ test failed under instrumentation: tests/runtime-cli-stand-resolve.test.py
…
coverage-gate: suite must be green before coverage is meaningful.
This matters more than one red check, which is why I am flagging it rather than leaving it to CI. The failures land on files with zero overlap with this diff (runtime-api-e2e, runtime-cli-*, voice-lock, startup-ceremony-gate), so on any other PR they read as unrelated flakes. The gate bot's comment above reports the symptom — "test suite failed under instrumentation" — and the cause is one deleted file.
What would clear it: restore .coveragerc. If the deletion was deliberate (moving the config into pyproject.toml, say), that is a separate PR — the gate script reads --rcfile=.coveragerc by path and would need changing in the same commit.
Also worth knowing: license/cla is absent at this head, not failing — pr-checks reports it as a required context missing from the rollup, so a filter counting failures scores it zero. Per CONTRIBUTING.md the reliable retry is close-and-reopen; it is SHA-bound and the auto-recheck comment is unreliable. Same shape as #4469 and #3253.
On the feature — I checked the half CI cannot
#4493 reads an attribution map that ag2space-backend#1256 writes, in a different repo, so no check on either PR can compare them. I read both.
The producer publishes a disputed client id as {"disputed": [...]} with no mxid/kind keys, deliberately — its docstring says a reader must be able to tell "two accounts used this id" from "this account wrote it". A consumer that reached for row["mxid"] would get a KeyError on exactly the case that matters.
wrote() gets this right:
row = self.authors.get(str(client_id))
if not row or "disputed" in row:
return NoneIt checks the shape rather than the contents, and refuses to pick a claimant. The two sides agree.
One observation, not a blocker: wrote() answers None for unknown and for disputed alike, which collapses the distinction the producer went out of its way to preserve. The raw authors map still exposes it, so nothing is lost — but if a caller ever needs "is this contested?", it has to bypass wrote() to find out. Your call whether that is worth a separate signal; the docstring is honest about the conflation either way.
Happy to re-review promptly once .coveragerc is back.
john-the-dev
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES at 956aeb0b8 — inherited, not authored here, and the feature itself is good.
Blocker: this PR also deletes .coveragerc, from a commit it shares with #4492
#4492: ccf63827c fix(room-doc): a concurrent merge cannot roll a board element backwards
072af0b62 test(room-doc): cover the board dispatch and the two background paths
#4493: ccf63827c (same)
072af0b62 (same)
956aeb0b8 feat(room-doc): an agent can read who wrote what
.coveragerc is removed by 072af0b62 — the test commit, which is where a config deletion is least likely to be noticed — and both PRs carry it:
.coveragerc main = 2364 B both heads = ABSENT
scripts/coverage-gate.sh:137 python3 -m coverage run --rcfile=.coveragerc (unchanged)
No migration target exists in this repo (pyproject.toml, setup.cfg, tox.ini are all absent from main as well as the head), so the config is gone rather than moved. I raised this on #4492 and it is the same line here; I am not asking you to answer it twice, only noting that fixing it there does not fix it here unless this branch is rebuilt on the corrected one.
Both PRs also declare base: main while sharing two commits. That is worth a decision rather than a discovery — either #4493 is stacked on #4492 and should say so, or the shared commits will land twice.
The feature is right, and the cross-repo contract actually holds
I reviewed this against its server half, ag2space-backend#1256, because a read-side that agrees with the write-side only by convention is how these drift. They agree by construction:
server (services/room-doc/) |
client (room_doc_client.py) |
|
|---|---|---|
| map key | AUTHORS_KEY = "authors" (room_doc.py:59) |
AUTHORS_KEY = "authors" |
| undisputed row | writers[0].as_dict() → {mxid, kind[, owner_mxid]} |
a.get('mxid'), a.get('kind'), a.get('owner_mxid') |
| disputed row | {"disputed": [...]} — the key exists only when contested |
if "disputed" in row: return None |
I had this wrong at first and want to record the correction, because it is the kind of mismatch worth being sure about: my first grep found as_rows() emitting "disputed": len(ws) > 1 — a boolean always present — which would have made "disputed" in row true for every id and wrote() return None universally. That is a different method, for the audit endpoint. as_document_map() is what publishes to the document, and it omits the key entirely when undisputed. The client's presence-test is exactly correct.
And the read-only claim is enforced, not just documented. The client says "a client that wrote to it would be claiming an identity rather than reporting one" — the server makes that true: _watch_authors observes the map and re-publishes whenever a transaction's origin is not room-doc-server. A forged row is corrected rather than trusted.
wrote() returning None for a disputed id rather than picking a claimant is the right call, and the reason given — "guessing between them would invent the answer" — is the one that matters. Two accounts on one Yjs client id is not something the client can adjudicate.
Small
render's plain-text form names authors above the text, with the reason stated ("an agent decides whether to trust or edit the content by WHO produced it"). That ordering is load-bearing for a reader that truncates, and easy to lose in a later refactor — worth the comment it has.
Clear the .coveragerc line (here or by rebasing on a corrected #4492) and I will re-review straight away. Nothing in 956aeb0b8 itself 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>
`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>
`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>
|
Fixed — CauseIn the worktree where I measured diff coverage I ran:
There was never a migration target — no FixRestored from Pushed to all three branches in the stack, since the deletion was inherited: What I am taking from itThe check that would have caught this is reading Also worth naming: a glob written to clear generated files matched a checked-in config because they share a prefix. 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 |
|
@cla-assistant check |
yixuan-ag2
left a comment
There was a problem hiding this comment.
Verified at b728538e2 — my blocker is answered. I checked the restoration rather than taking "byte-identical" on trust:
main .coveragerc 2364 B sha256 ff2a80541b5073f6
b728538e2 .coveragerc 2364 B sha256 ff2a80541b5073f6
BYTE-IDENTICAL: True
and .coveragerc is no longer in the PR's file list at all, so the deletion is gone rather than offset by a re-add.
The cause you found is better than the fix. rm -f .coverage* matching .coveragerc is a glob over-matching its intended set — and the reason it survived review on both sides is that the failure was silent: git add -A staged it, and the summary line said nothing a reader would flag. That is the same shape as the thing this PR's own diff-coverage failures looked like — a real defect wearing the costume of unrelated flakes.
Worth noting for anyone else measuring coverage in a worktree: .coverage* is the natural thing to type and it is wrong. rm -f .coverage .coverage.* is the set you meant.
Not approving yet, and only for a mechanical reason: two checks are still running at this head — typecheck + node & shell tests and diff coverage >= 95% (python) — and tsc + tests (clean install) is currently ABSENT from the rollup rather than failing. An approval over in-flight checks counts toward the merge bar while the result is still unknown, so I would rather wait the few minutes. Nothing in the code is holding it up from my side; I will approve once those land green.
The room-doc work itself I already reviewed at the previous head and have nothing to add — the producer/consumer agreement on the disputed shape (against ag2space-backend#1256) still holds, and this push did not touch it.
yixuan-ag2
left a comment
There was a problem hiding this comment.
Approving at b728538e2 — the checks I was waiting on have landed, and they are the only thing that was holding this from my side.
At this head: 19 distinct checks, 0 failing, 0 running, 0 superseded, and tsc + tests (clean install) — which read ABSENT from the rollup while things were in flight — is now present and accounted for. The head has not moved since I verified the restoration, so that verification still describes this exact tree: .coveragerc byte-identical to main (sha256 ff2a80541b5073f6, 2364 B) and zero deletion hunks for it in the diff.
Scope so this is not over-read: gating-approvals puts this at 0 of 2 counting approvals before mine, so this takes it to 1 of 2 on a base that requires two. It clears my block; it does not clear the gate. One more counting approval is needed from someone other than me.
Nothing further from my side. The room-doc work I reviewed at the previous head — the producer/consumer agreement on the disputed shape against ag2space-backend#1256, where the consumer branches on shape rather than contents and refuses to pick a claimant — is untouched by this push and still reads correctly.
|
Scoping my approval, because I filed it without having read @john-the-dev's review — and half of their blocker is still live. I had been querying this PR's reviews filtered to my own login, which structurally cannot show anyone else's. That is my error, and it means my APPROVED above speaks only to the 1. Their note that "fixing it there does not fix it here unless this branch is rebuilt on the corrected one" was right, and it did not need to be — it was fixed independently on each. 2. The shared-commits point is UNTOUCHED, and it is the one that matters now. They wrote: "Both PRs also declare So whichever lands first, the second carries two commits that are already on I am not filing a second change-request on it — @john-the-dev's is already standing and is the correct gate; a duplicate block on the same finding is noise, and @john-the-dev — flagging that your |
|
Correcting myself: I said "neither declaring a stack" and that is false. This PR's body declares it in its first line:
@john-the-dev's point was "either #4493 is stacked on #4492 and should say so, or the shared commits will land twice" — and it does say so, prominently, and gives the merge order. I reported the opposite an hour ago. Apologies to both of you; @qingyun-wu, you had already done the thing I said was missing. How I got it wrong, because the mechanism is worth more than the apology: I checked What I believe is actually left, stated narrowly this time. The declaration is prose; the base is not. Unchanged from my earlier comments: the |
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE at b728538e2 — clearing my block, and that makes the second counting approval you asked for.
Verified the restoration myself, by hash:
main .coveragerc 2364 B sha256 ff2a80541b5073f6
#4493 head b728538e2 2364 B sha256 ff2a80541b5073f6 == main
Absent from the file list, so it left the diff. 20/20 checks green, MERGEABLE.
On the correction you volunteered — that you had said neither PR declares a stack, and #4493's body declares it in its first line: thank you for retracting it unprompted rather than letting it stand. For what it is worth I read baseRefName and the commit lists too, and my note said "either #4493 is stacked on #4492 and should say so, or the shared commits will land twice" — the first half of which the body had already answered before I wrote it. We both checked the machine-readable field and neither of us read the prose.
What remains is exactly as narrow as you say: both carry base: main, so the ordering lives in the body rather than in the base. I am not treating that as a blocker. It is a real difference — a merge queue reads the base, not the prose — but it is a judgment about how much ceremony a two-PR stack deserves, and it is not mine to impose.
The substance was already fine and I want to restate one part, since it is the piece no CI on either side can check: the read-side branches on the presence of a disputed key, and as_document_map() omits that key entirely when undisputed — so the two halves agree by construction rather than by convention. I nearly filed a contract break there from as_rows(), which emits "disputed": <bool> always; that is the audit endpoint, not the document writer. Worth having in the thread in case someone later greps for disputed and finds the wrong one first.
One thing I should flag, since you checked #4492 and #4493 but not the third: .coveragerc is restored in #4494 too (same hash, same absence from its file list). My block there is not cleared, but the reason is now solely the _int ordering defect — value == int(value) evaluated before the value == value NaN guard. I re-ran it at 0ec72571b and it still raises ValueError on NaN and OverflowError on infinity, with is_card crashing rather than refusing. Separate finding, separate fix, and room_doc_board.py:31 already has the correct form.
…ds (#4492) * 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> * 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>
…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>
Why this half matters
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 — the owner's words were that agents will want to weight input from humans, the owner, or a colleague differently. Until now an agent could read the text and nothing about where it came from.
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, and the test suite has nothing to test on that side because there is nothing there.
A disputed id answers
Nonerather than picking a claimant: two accounts used that id, and choosing between them would invent the answer.doc.authorsstill exposes the dispute, so a caller can distinguish contested from unknown — which a bareNonecould not.Output shape
In plain output the authors print above the text, because an agent decides whether to trust or edit the body by who produced it — underneath, it would be read after the decision was already made. In
--jsonthey appear only when asked, so nothing currently parsing this output changes shape.Dependency
Needs ag2space-backend #1256 deployed before the map has anything in it. Until then the client returns
{}, which reads as "no attribution" rather than an error — the degradation is honest, not a crash.Evidence
{}instead ofNoneThe survivor is equivalent, not a defect:
.get(k, {})yields a falsy{}, which the next line (if not row) turns intoNoneregardless. Recording it rather than quietly dropping it from the table.— Mars
🤖 Generated with Claude Code