Skip to content

feat(sdk): report a permanent refusal beside the applied count (C26) - #370

Merged
vieiralucas merged 6 commits into
mainfrom
sdk/c26-refused-op-signal
Jul 29, 2026
Merged

feat(sdk): report a permanent refusal beside the applied count (C26)#370
vieiralucas merged 6 commits into
mainfrom
sdk/c26-refused-op-signal

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes C26. Document::apply answers false for 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 answer MalformedOp, 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.

  • C ABI: crdtsync_doc_apply takes a nullable uint32_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.
  • wasm: WasmDocument.apply returns { applied, refused }.
  • JS ApplyOutcome, Python ApplyOutcome (NamedTuple), Go a second return value.

Deliberate boundaries:

  • The judgement is shared with the server's ingress; the policy is not. This fold applies the admissible remainder — only the server has an ack frontier taken as a max over the batch, which is why it refuses the whole frame.
  • applied counts 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.
  • A refused-op list (a takeRefused drain mirroring takeRejected) 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.rs is untouched — advancing last_seen_seq past 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

    • C ABI: crdtsync_doc_apply(doc, bytes, len, uint32_t *out_refused) returns applied; always writes out_refused (0 on malformed batch).
    • wasm: WasmDocument.apply returns { applied, refused }.
    • JS: Backend.apply and Doc.applyUpdate return ApplyOutcome { applied, refused }.
    • Python: Document.apply and Doc.apply_update return ApplyOutcome (NamedTuple).
    • Go: Document.Apply, Doc.ApplyUpdate, Provider.Receive, and Backend.Apply return (applied, refused).
    • Counting caveat: applied counts ops as they arrived; if [write, create] are in one batch, both land but only one is counted.
  • Migration

    • C: pass a uint32_t* out_refused (nullable if unused).
    • JS: handle object outcome from apply/applyUpdate; networked docs throw here.
    • Python: consume ApplyOutcome instead of an int; networked docs raise here.
    • Go: handle two return values; networked backends still refuse with (-1, 0).

Written for commit c22b55d. Summary will update on new commits.

Review in cubic

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.
@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 23:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vieiralucas
vieiralucas merged commit 2e0ea0d into main Jul 29, 2026
18 checks passed
@vieiralucas
vieiralucas deleted the sdk/c26-refused-op-signal branch July 29, 2026 00:28
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.

2 participants