Make a delivered message something the recipient can act on - #10
Conversation
A delivery told its recipient an event id, a relationship id, a generation and a revision hash, then listed file paths and their digests. All of it true, none of it saying which pull request to open, what was checked, what is still open, or what to do next, so a parent had to go and reconstruct the work before it could act on the message about it. The message now leads with the result, the repository and pull request, the base and head commit, the verification evidence, the unresolved items and the next action. The identifiers keep their place at the bottom: they are how the parent answers, not how it decides. None of that fits in a completion receipt, which is frozen with additionalProperties false, so a work report is a relay-owned record like the criteria set, bound to the event, generation and revision it describes. A report about one head cannot answer for a later one, and a pull request number is never stored or rendered without its repository, so the same number on two projects stays two pull requests. The second half is the seam with CXC. Both systems have a word for how an execution ended and the overlap is dangerous: CXC glosses DONE as verified success, meaning the child proved its own criteria, while the relay verified is the parent disposition written against registered criteria on an acknowledged event. A report status is therefore recorded beside the receipt outcome and checked against it, never used to derive it, and DONE, an open pull request, a review PASS and a green check are each listed as evidence for a verdict rather than one. An unknown status is refused by name with the contract version it was read against. The mapping points at the installed rules and copies none of them. Shortening a message is now something it admits to: headings survive with a count of what is missing, one omitted line names what was dropped and the command that shows the rest, and a budget too small for the required parts refuses rather than shipping a message that silently lost them. An event with no work report renders exactly what it rendered before. A receipt from before this contract has no pull request to centre a report on, and inventing one would be the guess this package refuses everywhere else.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
코디네이터 · 패킷 보정 2건 (착수 직후 전달 실패분)부모 코디네이터(
나머지는 패킷 그대로입니다. 머지 순서는 JUN-103(PR #8)이 먼저이고, 그 뒤 |
Review of #10 found the hole: record() took the relationship, generation, revision and outcome as arguments and never compared them with the events row for that event id. A mistyped or stale caller could file a report describing a different execution, and delivery would then hand the recipient another pull request and another instruction. Identity is now READ from the stored event and there is no argument left through which a caller could say otherwise. A report against an event nobody accepted is refused. Six smaller things found in the same review: A revision request carries no child receipt, so pairing its status with an asserted outcome forced the caller to invent one. That direction now checks the status is known and stops there. Evidence and unresolved entries are validated where they are recorded. Rendering runs inside the delivery claim, so a shape that only fails at render time rolled the claim back and the delivery never went out. The non-verification sentence is chosen by status. Every report was told it was the child proving its own criteria, which is false for BLOCKED and the opposite of what it means. The review verdict moves to the final line, where REVIEW-OUTPUT-01 puts it and where a scanner reading the tail will find it. The message budget counts UTF-8 bytes. A transport limit is a byte limit, and this workflow writes Korean, which costs about three bytes a character, so counting characters let exactly the messages it actually sends overrun a budget that looked comfortable. provenance() no longer claims every report stores its source digests; it stores the version string, and the digests are pinned in source.
Second review round on #10, three real findings. A revision event already carries the parent findings in its own receipt, written by record_verdict in the transaction that opened the new generation. render_revision was reading only the work report review, so a report with no review replaced those findings with nothing, and one naming a different set replaced them with something else. Either way the child was corrected against instructions the parent never gave. The receipt now leads, the review adds notes and source anchors by id, and anything the review raises on its own is kept and labelled as not part of the recorded verdict. An oversized summary or next action passed record() and then failed every render. Rendering happens inside the delivery claim, so each claim rolled back and the delivery never went out: the same failure mode as the malformed entries in the previous round, which is the argument for bounding these fields where the caller can still fix them rather than where nobody can. Replacing a report in place after a delivery had already been attempted let a retry carry different instructions under the same event and the same stated submission, with no way for the recipient to tell which one it was answering. The frozen attempt bytes always kept the history; what was missing was the report admitting it had changed. Correcting a report before anything is sent is still free, and afterwards it has to be a new submission, which the message already shows.
Third review round on #10. The omission notice was appended after every section, including the deliberately last verdict section, so any correction that had to drop something ended on omitted: instead of its judgment. A consumer following the final-line contract would stop finding the verdict in exactly the messages that had to shorten. A section can now declare itself last and the notice goes before it. A revision event exists because the parent ruled needs_changes, and the review half of a report is still caller-supplied even now that identity is not. A PASS verdict on one would approve the work and demand changes in the same message, so it is refused. manifestRef is back in the report-backed message. The pre-contract message always carried it, it is the stable location a receipt points at when live artifact paths may move, and adding a work report was silently removing it even with no elision in play.
An elided message told its recipient to run show --event for the rest, and cmd_show never read work_reports, so exactly the fields the message had dropped were the fields that command could not return. The notice was a false promise rather than an incomplete one, which is worse than eliding nothing. show now carries the whole report. This touches cli.py, which the parallel JUN-103 branch is also editing: it was reported before being changed, the added lines sit inside cmd_show about forty lines from that branch nearest hunk, and the import is function-local so the module import block it does edit stays untouched. Also from the same round: a restore section could name a skill owner nobody has, and cxc.skill_pointer refuses an unknown name on purpose, so the typo surfaced as a failed render inside the delivery claim instead of as something a caller could fix. Validated at record time now, like the other shapes that can only fail where nobody can act on them.
manifestRef lived inside the deliverables block, which is the first thing the composer drops, so the reports most likely to be shortened were exactly the ones that lost the stable location their artifacts can still be verified against. It is a verification pointer rather than a file listing, so it is now its own essential section and the listing can go without it. _check_restore tested entries for dictionary membership before checking their shape, so an unhashable entry raised TypeError and the caller got a host failure instead of a named refusal. A validator that crashes on malformed input is not validating it. Shape first now, for the outer object, the skills list and each entry, and every invalid form is MALFORMED_RECEIPT.
pr_state was copied without a limit onto the second, non-elidable line of the pull request section, so a 10 KB value was accepted and then made every preview and every delivery claim raise. The same was true of pr_url, base_ref, base_sha, head_sha and criteria_digest. All six are bounded at record time now, and a non-string value in any of them is a named refusal rather than whatever str() would have produced. The malformed-shape guarantee did not hold at the top level either. A mapping passed as evidence or unresolved iterated as its own keys and was silently accepted as a list of strings; a scalar raised TypeError out of the validator. Both are MALFORMED_RECEIPT now.
A report was keyed on the event alone, so recording submission 2 overwrote the only full copy of submission 1. That breaks the promise the earlier message made: its omission notice sent the recipient to show --event for the evidence and unresolved items it had to elide, and after the overwrite those were gone, while the frozen attempt bytes hold only the elided text. work_reports is now keyed on the event and the submission, read() returns the current one, read_all() returns all of them oldest first, and show carries both. Two more from the same round: A review passed as a bare string reached .get() and raised AttributeError, so the named-refusal guarantee did not hold for that container either. The review object, its findings list and each finding are shape-checked now. pr_url was bounded at the label limit, which is the ceiling for lines the composer cannot shorten. The url is not one of those: it sits on a line that can be dropped, so a real forge url over 300 bytes was being rejected for a report that would have rendered fine. It gets a storage ceiling instead. A tuple stays accepted for evidence and unresolved. The hazard the shape check exists for is a mapping iterating as its keys and a string iterating as characters; a tuple has the same semantics as the list it normalises to. The invariant wording now says ordered sequence, which is what the code does.
_check_resubmission ran before BEGIN IMMEDIATE, so two recorders could both approve the same next submission number and a delivery could open an attempt between the check and the upsert. The rest of this package reads inside the caller transaction for exactly this reason; this now does too. The write also stopped naming a conflict target. A target has to match a constraint in the schema the database was actually created with, and CREATE TABLE IF NOT EXISTS never reshapes an existing table, so naming one made every report write depend on which revision of this table a store started life on. Delete then insert inside the same transaction has no such dependency. The limit that remains is recorded: no released version has this table, so a store built from an intermediate revision of this change is recreated rather than upgraded. Two ordering bugs in the validation: The PASS conflict check read review.get() before any shape check, so a revision report carrying a bare string raised AttributeError instead of a named refusal. Shape is checked first now. A completion report accepted a review that render_completion never reads, so a supplied PASS or FAIL was stored and silently never delivered, with no omission notice either. Only a correction carries a review.
show now returns every preserved submission, so a recipient holding an older message needs to know which one it is reading. The completion message already said so; the revision message did not. Both do now, and since the bytes are frozen per attempt, the message itself is the attempt-to-submission record. A report with no pull request dropped its baseRef, baseSha, headSha and criteriaDigest on an early return, with no omission notice. Blocked and budget-exhausted work is exactly the case that has no PR yet and still has a branch point and a commit worth naming, so the parent was losing the context it needed to act. The commit lines are shared between both branches now.
코디네이터 · 마감 지침부모 코디네이터( Jun이 진행 중인 PR까지만 마무리하고 멈추기로 결정했습니다. 이 PR(JUN-131)이 이번 런의 마지막 인도분입니다. 새 범위를 열지 말고 지금 것을 닫는 데 집중하세요. 지금 해야 할 것, 이 순서로:
머지 조건은 넷입니다: Ready(비 Draft), 최신 base, 현재 head의 dev-gate 성공, 리뷰 스레드 전부 해소. 맞으면 제가 머지합니다. 참고: JUN-103(PR #8)이 먼저 머지됩니다. 그 뒤 최신 dev를 합치세요. 겹치는 파일은 |
_may_have_reached excluded every attempt whose record says sendAttempted no, and an inbox_only attempt says exactly that: the push was refused before any resume. But its frozen message is the durable inbox item and the recipient can read it, which protocol v1 section 3 calls the guarantee of the whole dual channel. So the report behind an inbox message could be rewritten in place, and anything that message had elided was then gone. Three more from the same round: A line break in summary, next_action, repository or cxc_reason was spliced straight into the message, where it does not wrap, it adds a line to the protocol. A summary ending in a second line reading VERDICT: PASS put a standalone verdict into a completion that carried no review, which is the exact separation the rest of this module is built to keep. Those fields are one line now, and so are the optional label fields. pr_url and pr_state were accepted with no pr_number and then never rendered, because the no-pull-request branch has nowhere to hang them. Stored and never shown, with no omission notice, is the failure this change exists to remove, so the combination is refused. cxc.check_known tested an unhashable status for dict membership and raised TypeError, so a structured status escaped the named-refusal contract.
With nothing frozen yet, the resubmission guard let any submission number through, so recording 2 and then 1 both succeeded while read and delivery kept taking 2. The caller was told its latest write landed and nobody would ever see it. Correcting the newest unsent submission in place is still free; going backwards past it is refused and says which number to use. restore validated only its skills list, so mode as a mapping reached the message as a Python repr, an unsupported key was stored and never rendered, and a non-serialisable value failed inside json.dumps as a host exception. Every supported field is now a bounded single-line string and anything else is refused, which also closes the newline route into those lines.
The delivered floor and the highest-stored floor were checked in different branches, so the band between them was open: with submission 1 delivered and 3 stored, submission 2 passed the delivered check, sat under the stored one, and was accepted into history where reading and delivery would never select it. Both floors apply now regardless of the other, and the refusal names the number that clears both. A whitespace-only restore value survived as an empty string, which kept the restore dict non-empty and left the render emitting a workflow-restore heading with no fields under it. Trimmed first, dropped when empty.
The single-line rule covered the top-level fields and not the values inside evidence, unresolved or review findings, so evidence reading "pytest passed" followed by "VERDICT: PASS" still put a standalone verdict into a completion carrying no review. Every string that reaches a message line now goes through the same check. A blocker count sits on the essential final verdict line and had no ceiling, so a large enough integer made render_revision unable to fit the message and raise inside every delivery claim. A review with more than 9999 blockers is not a review, so that is the limit.
Six narrow findings from one round, all in the same family as the earlier ones. A required field went through str(), so a mapping was stored and delivered as a Python repr as though somebody had written it. It has to be text now. A line break is whatever str.splitlines calls one. Checking CR and LF left vertical tab, NEL and the Unicode separators able to splice a line downstream. A revision message had no unresolved section, so a correction stored the dependencies and risks the report marked open and showed the child none of them. A pull request number above the SQLite signed 64-bit range raised OverflowError on insert instead of coming back as a refusal. A finding disposition was passed through unchecked, which both left the line-splicing route open beside fields that had just been closed and allowed a word the frozen criteria enum has no room for. parse_verdict_line accepted blocker counts the renderer refuses, so the two directions disagreed about what a verdict is.
Regression from the previous commit. _disposition required a frozen criteria value on every report finding, but _finding_lines uses the revision receipt as authoritative and report findings only to attach a note or a source anchor. An enrichment has no disposition of its own to state, so requiring one refused the exact case that code path exists for. Absence is allowed; a stated value still has to be in the enum. Three more from the same round: A submission number had no upper bound, so a value above the SQLite signed 64-bit range raised OverflowError when it was bound into the insert. The pull request refusal interpolated the offending number, and past Python integer-to-string digit limit that made the refusal message itself raise ValueError. Neither refusal prints the value now. A blank or whitespace-only evidence entry was accepted and rendered as an empty verification line, and told a child to rerun a check it did not name.
…mber Two consequences of the previous commit. Allowing an enrichment finding to omit its disposition meant a review-only finding, one the receipt does not own, rendered as "c-9: None", telling the reader a criterion had a judgment called None. The line now omits the disposition when there is none, so an enrichment reads as an id and its note. The positive-integer branch of the pull request check still interpolated the value, and a huge negative number reaches that branch, so formatting it raised ValueError out of the refusal. Both branches name the type and the range instead of printing the value.
The contract map says a report carries its CXC status and reason, and the revision renderer built its header without either, so a correction dropped both and produced no omission notice for them. It carries them now, like a completion does. A lone surrogate is one line by every line rule and still cannot be encoded as UTF-8, so a required field raised UnicodeEncodeError out of _size while a nested value was stored and then failed inside every delivery claim. Every line value passes through _single_line, so the encodability check lives there.
A legacy relationship can reach needs_changes with no registered criteria, so the review findings are all there is and _finding_lines fell back to them unlabelled, under the same violated-criteria heading a recorded verdict uses. The child would read them as a verdict it could look up. The fallback now says they come from the review and that the assignment has no recorded criteria set. An evidence check name went through str(), so a mapping was stored and presented as verification of a Python repr, and the revision direction told the recipient to rerun that repr as though it were a command.
… checks Shortening pops one line at a time and re-joined and re-encoded every remaining line on each pass, which is quadratic in the length of the list being shortened. The joined size is the sum of the encoded lines plus one separator between each pair, so it is counted directly now. The unresolved path still had the two holes the evidence path had already had closed: a blank entry was accepted and rendered as an empty bullet, and a mapping supplied as id or note went through str() and was stored as a Python repr. Both are refused. The evidence check name had the same coercion left in its mapping form and now has to be a string.
Four findings from the review round on the delivered head. The renderer keys enrichment on the criterion id, so two findings for the same criterion meant the second replaced the first and its note or anchor vanished with no omission notice. One criterion carries one finding now, refused at record time the way a registered criteria set already refuses a duplicate id. over_budget still summed every remaining line on each pop, so shortening a long list was still quadratic, and it runs inside the claim transaction where that holds the single SQLite writer for the duration. The byte total and line count are carried and adjusted by each mutation instead. SCOPE mixed variable data with fixed protocol prose, and its floor let the preserve boundary be shortened away. The omission marker points at show, which returns the receipt and the work report but not template text, so those lines were recoverable nowhere. They are their own section with a floor covering them. A finding note or anchor still went through str(), so a mapping rendered as a Python repr and a falsey non-text value such as 0 silently became absent.
Four more coercion routes, all on producer input. An evidence detail and a review finding id both went through str(), so a mapping became a repr and a falsey value such as 0 or False silently became absence. A fake identifier is worse than a rejected one: it cannot enrich the authoritative finding it was meant for, so it arrives as a separate review item. An exit code had no range, and past the integer-to-string digit limit json.dumps raised ValueError while serialising the row. No process exits with a number that size, so the bound says that rather than naming a serialisation limit. A manifestRef carrying a lone surrogate made _size raise while measuring, so every delivery claim rolled back once a report was attached. The receipt is contract-validated and has no encodability rule for that field, so refusing the delivery would punish the recipient for the producer. It renders as present and not renderable, with the record as the place to read it.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7354c00645
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| record = self.intake.get(event_id) or {} | ||
| message = self._render_for(row, record, request_id) | ||
| report = read_work_report(self.store, event_id) | ||
| message = self._render_for( | ||
| row, record, request_id, report |
There was a problem hiding this comment.
Record reports before delivery becomes claimable
In the documented CLI flow, cmd_emit enqueues the event without accepting or recording a work report, and there is no report-recording CLI command. Consequently a daemon can claim the event here while read_work_report() still returns None, permanently freezing and sending the legacy message; a later API call to report.record() cannot update an already dispatched attempt. Record the report atomically with receipt acceptance/enqueueing, or otherwise keep the delivery ineligible until the report has been attached.
Useful? React with 👍 / 👎.
| if outcome == REVISION_OUTCOME: | ||
| # The parent-to-child direction carries no child receipt, so there is no asserted | ||
| # outcome to pair the status with. Demanding one would make the caller invent it. | ||
| cxc.check_known(cxc_status) |
There was a problem hiding this comment.
Reject success statuses on revision requests
For a revision_request, this accepts every known status, including DONE and NOOP. render_revision() then emits cxc: DONE together with the hard-coded meaning that the child proved every recorded criterion, in the same instruction that says the parent ruled needs_changes and demands corrections. Reject success statuses for this direction or render a direction-specific parent-review meaning so the correction does not simultaneously declare the work successful.
Useful? React with 👍 / 👎.
| if not isinstance(status, str) or status not in COMPATIBLE_OUTCOMES: | ||
| raise ReceiptRefused( | ||
| RefusalReason.OUTCOME_INCONSISTENT, | ||
| f"{status!r} is not a CXC report status this build maps. Accepted: " | ||
| f"{', '.join(REPORT_STATUSES)}. Read against {PACKAGE} {VERSION}; a newer " | ||
| "contract needs the mapping extended rather than the value guessed at", |
There was a problem hiding this comment.
Avoid formatting unbounded invalid CXC statuses
On Python 3.11+, a JSON producer can pass an integer with more than the configured digit limit as cxc_status; the type check reaches this branch, but interpolating status!r raises Python's integer-conversion ValueError before ReceiptRefused can be constructed. This lets malformed report input escape the named-refusal path; report the invalid type without converting the value, as the pull-request-number validation already does.
Useful? React with 👍 / 👎.
The problem
A delivered message told its recipient an event id, a relationship id, a generation and a
revision hash, then listed deliverable paths and their sha256. All of that is true and none
of it says which pull request to open, what was verified, what is still unresolved, or what
to do next. A parent receiving one had to go and reconstruct the work before it could act on
the message about it.
Separately, CXC and this relay both have a vocabulary for how an execution ended, and
nothing stated how the two line up. The overlap that matters is the word verified: CXC
glosses
DONEas verified success, meaning the child proved its own recorded criteria,while the relay
verifiedis the parent disposition written throughrecord_verdictagainst registered criteria on an acknowledged event. Same English word, two authorities.
Before and after
Before, a completion message opened like this:
After, when a work report is recorded, it opens like this and the identifiers move to the
bottom where the parent uses them to answer:
What changed
cxc.pyis new and owns the seam with CXC and nothing else: pinned contract provenance(version, file paths, rule ids, sha256 for nine rules), the report-status mapping, the
VERDICT:line, and the wait classification. It copies no CXC prose and rebuilds no part ofits state machine;
cxc.affected_by()names which rules a changed install touches so aversion bump re-verifies those rules rather than all of them.
A CXC report status never chooses a relay outcome. The outcome still comes from the child
receipt, which is the only thing the frozen contract lets assert one, and the status is
recorded beside it and checked for compatibility.
BLOCKED,UNSAFEandNEEDS_HUMANallland on
blocked_needs_inputbecause the frozen enum has one slot for a human having todecide something; the original word and its reason are stored and rendered, so the collapse
loses nothing a reader needs. An unknown status is refused by name with the accepted set and
the contract version it was read against.
report.pyis new and owns the relay-owned work report plus both directions of rendering.Revision requests now carry the DISPATCH-TASK-01 fields and lead with the violated criterion
and the anchor that reproduces it. Shortening a message is something it admits to: headings
survive with a count of what is missing, one
omitted:line names what was dropped and thecommand that shows the rest, and a budget too small to hold the required parts refuses
rather than shipping a message that silently lost them.
Backward compatibility is the same mechanism as correctness here: an event with no work
report renders exactly what it rendered before, because a pre-contract receipt has no pull
request to centre a report on and inventing one would be the guess this package refuses
everywhere else.
report.version_oftells the two apart explicitly.Overlap with #8
#8 is open against the same package and merges first. Two files are shared and the hunks do
not overlap:
store.py- one additiveCREATE TABLE work_reportsblock betweenverdict_contextand
ack_evidence. Give the relay a shared store, an owner, and a lifetime longer than one process #8 edits lines 13-20 and 409+. This file is outside my declared lane,so it was reported and approved before being touched. The record cannot live in the receipt
(
additionalProperties: false) and this is the patterncanonical_criteria,verdict_context,assignment_marksandsync_outboxalready follow.delivery.py- one import line and the bodies of_render_completionand_render_revision, which delegate. Give the relay a shared store, an owner, and a lifetime longer than one process #8 touches the scope import, the twocheck_recipientcall sites and an appended helper.
Nothing else of #8 is touched. New tests live in
tests/test_report_contract.pysotests/test_delivery.pystays #8 territory.Validation
validate.py,scripts/ci/tests(33),contracts.py(102/102 fixtures, 46/46 returnsites),
secrets.sh(204 commits, no leaks),git diff --check, andCRW_PACKAGES_TMPDIR=/var/tmp python3 scripts/ci/packages.py- bridge 119 tests, relay 548tests, both CLIs responded, both wheels built. All exit 0 on this head.
tests/test_report_contract.pyadds 27 scenarios written from the recipient side: an unknownCXC status diagnosed rather than defaulted, a status contradicting its receipt outcome,
DONErefused as verification, stale head and stale generation refused, the same PR numberunder two parents staying distinct, more than ten findings never dropped in silence, an
impossible budget refusing, a preview still not being evidence of what was sent, a legacy
receipt rendering unchanged, and a bare timeout becoming neither failure nor re-run
permission.
This is evidence about this source. It says nothing about an installed relay, a running
daemon, an MCP registration or delivery on any host.
Review rounds
Five rounds from the Codex and Devin reviewers, 25 threads, all answered and resolved. The
substantive ones, in the order they mattered:
record()trusted its caller for the relationship, generation, revision and outcome, so amistyped call could file a report describing a different execution. Identity is now read
from the
eventsrow and those arguments no longer exist (I-100).render_revisionread only the work report review, so a report with no review replaced theparent recorded findings with nothing. The revision receipt is now authoritative for which
criteria a correction names; the review only adds notes and anchors (I-101).
record()and fail only at render time. Rendering runsinside the delivery claim, so each one rolled the claim back and the delivery never went
out. All are refused where the caller can fix them (I-102, I-108, I-110, I-111, I-112).
cancelled each other out. A section can now declare itself last (I-104).
manifestRefwas first dropped entirely, then restored inside the first block the composerelides. It is now its own essential section (I-106, I-109).
show --event, which did not readwork_reports, so theadvertised recovery path could not return what the message dropped (I-107).
Known gap
There is no CLI verb for recording a work report. The adapter entry point is
report.record(store, clock, event_id=..., ...); adding CLI surface means editingbuild_parser(), which PR #8 edits at the same place, and this task was instructed toreport before adding it. It lands on top of
origin/devonce #8 merges, and JUN-107 ownswiring Run to these adapters. Until a caller records reports, existing deployments keep
receiving the pre-contract message, which is the designed behaviour rather than a
regression.
cmd_showis the one exception taken incli.py, reported first, because a shipped messagewas pointing at a command that could not answer it. It sits about forty lines clear of the
nearest hunk in #8 and uses a function-local import so the module import block #8 edits is
untouched.