feat(sdk): report a permanent refusal beside the applied count (C26) - #370
Merged
Conversation
The fold `Document::apply` drives reduced three unrelated outcomes to one
number: an app learned how many ops applied now and nothing else. A buffered
op — which a later arrival commits — and an op no replica will ever hold both
read as "not counted", and the two want opposite responses.
Every binding now answers both counts, classifying with `Op::is_admissible`
(the public pure predicate C19 introduced) so the SDK seam and the server's
ingress boundary cannot disagree about which refusals are permanent. Offline,
P2P and relayed peers reach this fold with no server between them to answer
`MalformedOp`, so it is the app's only signal.
- C ABI: `crdtsync_doc_apply` takes a nullable `uint32_t *out_refused`, left
untouched on the -1 outcomes, which decode no batch to judge.
- wasm: `apply` returns `{ applied, refused }`.
- JS `ApplyOutcome`, Python `ApplyOutcome`, Go a second return value.
… batch Local review pass: - The C ABI left `out_refused` untouched on -1, so a caller reusing one variable across batches read the previous batch's refusals as this one's. It is now written on every outcome, zero where no op was judged. - The refusal-cause lists named the transaction-size condition, which the codec refuses at the frame and this seam therefore never sees. - `applied` counts ops as they arrive; an op a later op in the same batch releases from the buffer is not among them. Said so, everywhere. - ARCHITECTURE now separates the shared judgement from the differing batch policy: this fold applies the admissible remainder, the server's ingress refuses the whole frame. - Every binding pins the mixed batch (2 applied, 1 refused) and a duplicate replay as not-refused; the SDK tests read the stamp's client id back before forging it, so a codec reorder fails by name.
Second local review pass: - `crdtsync_doc_apply` now returns the pair from inside `catch_unwind` and writes `out_refused` once after it. A panic mid-batch previously returned -1 with the count already written, contradicting the documented contract and letting a raw-pointer write escape the unwind guard. - The `applied` undercount is pinned by a test, not prose alone: a batch encoded [write, create] lands both ops and reports one. - "the rest of the batch still applies" narrowed to "a refused op does not hold back the rest of the batch" — a bad transaction size fails the frame at decode, so that batch applies nothing. - The low-level Go/Python `Apply` docs carry the undercount caveat too, and the JS/Python ergonomic docs say a networked doc throws/raises here. - Test hygiene: Go's `frames` is `opFrames` beside a package about wire frames, and the three SDK helpers guard the emitted-update count.
Third local review pass: a crdtsync *relay* connection still reaches `handle_ops`, whose `is_admissible` gate is schema-independent, so it answers MalformedOp like any other server — a relay only opts out of schema validation. The seam with nothing upstream is offline, direct peer exchange, or a byte pipe the app carries itself. Also drops a parenthetical that gave the wrong reason for the count being zero on a panic, and a comment that narrated the shape rather than the contract.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes C26.
Document::applyanswersfalsefor three unrelated reasons and only one is permanent, so a fold that reduces the batch to an applied count reports a buffered op and an op no replica will ever hold identically. Offline, P2P and relayed peers reach that fold with no server between them to answerMalformedOp, so it was the app's only signal — and the two cases want opposite responses (wait vs. surface a client bug).Every binding now reports both counts, classifying with
Op::is_admissible— the public pure predicate C19 (#367) introduced — so this seam and the server's ingress cannot disagree about which refusals are permanent.crdtsync_doc_applytakes a nullableuint32_t *out_refused, written on every outcome (zero where no op was judged, so a caller reusing one variable across batches cannot read the last batch's refusals as this one's). Header regenerated.WasmDocument.applyreturns{ applied, refused }.ApplyOutcome, PythonApplyOutcome(NamedTuple), Go a second return value.Deliberate boundaries:
appliedcounts ops as they arrive, so an op a later op in the same batch releases from the buffer is not among them. Documented in all five bindings rather than papered over; making it truthful needs a core seam this unit does not need.takeRefuseddrain mirroringtakeRejected) was rejected: per-handle state with its own lifetime rules, a muddy stub on every client-backed replica, and a refusal is a fact about this batch.crates/core/src/client.rsis untouched — advancinglast_seen_seqpast a refused op is correct.Suites: a refused/buffered discrimination test per binding (FFI, wasm, JS, Python, Go), each also pinning the mixed batch (2 applied, 1 refused), a duplicate replay as not-refused, and a malformed batch as neither. The three SDK tests read the stamp's client id back before forging it, so a codec reorder fails by name.
ARCHITECTURE §SDK-Ergonomic-Surface + DECISIONS (2026-07-28) updated.
Summary by cubic
Adds a refused-op count to all SDK apply paths so apps can distinguish “not yet” from “never” (C26) when folding peer ops offline/P2P/byte-pipe, aligned with
Op::is_admissible. The C ABI writes the refused count on every outcome (including-1), and refusal is per-op; the rest of the batch still applies.New Features
crdtsync_doc_apply(doc, bytes, len, uint32_t *out_refused)returns applied; always writesout_refused(0 on malformed batch).WasmDocument.applyreturns{ applied, refused }.Backend.applyandDoc.applyUpdatereturnApplyOutcome{ applied, refused }.Document.applyandDoc.apply_updatereturnApplyOutcome(NamedTuple).Document.Apply,Doc.ApplyUpdate,Provider.Receive, andBackend.Applyreturn(applied, refused).appliedcounts ops as they arrived; if[write, create]are in one batch, both land but only one is counted.Migration
uint32_t* out_refused(nullable if unused).apply/applyUpdate; networked docs throw here.ApplyOutcomeinstead of an int; networked docs raise here.(-1, 0).Written for commit c22b55d. Summary will update on new commits.