DEV Community

Kalpesh Sharma
Kalpesh Sharma

Posted on

Five Integration Failures I Hit While Building on SuperDocs

I built two document workflows on top of SuperDocs: a closing-document reconciliation workflow and PaperForge, a multi-document research/reporting system with a human-reviewed editing step.

The happy path was straightforward:

document
→ SuperDocs edit
→ proposed change
→ human review
→ approval
→ export
Enter fullscreen mode Exit fullscreen mode

The interesting engineering work showed up around that path.

The five problems below were not hypothetical edge cases. They came from real implementation and live integration work. None of them required replacing SuperDocs. They required being much more precise about the boundary between an external document API and the application built around it.

The biggest lesson was this:

A document-editing API can give you the right primitives, but the surrounding application still owns state, contracts, retry behavior, review UX, and the definition of “done.”

Scope note: I use “integration failure” deliberately. Some problems below exposed API-contract details I had misunderstood; others were defects in my own application assumptions around SuperDocs. I am not treating every item as a SuperDocs product bug.

Build context: Closing Document Reconciliation

Closing Document Reconciliation workflow

The reconciliation build where I first exercised SuperDocs document upload, asynchronous editing, explicit approval, and export in a real workflow.


1. Additive provider metadata broke my strict pending-change model

What I was trying to do

PaperForge stores SuperDocs proposals in a strict immutable model.

The application only needs a small set of fields for each proposed change:

change_id
operation
chunk_id
document_id
old_html
new_html
ai_explanation
insertion anchors
Enter fullscreen mode Exit fullscreen mode

I deliberately configured that internal Pydantic model with:

ConfigDict(
    extra="forbid",
    strict=True,
    frozen=True,
)
Enter fullscreen mode Exit fullscreen mode

The intent was good: malformed persisted review state should fail loudly instead of quietly entering the application.

What broke

A live pending_changes response contained every field PaperForge expected plus additional provider-side diagnostics.

One response included a machine_checks object containing values such as:

claims
resolved
words_new
words_old
blocks_new
blocks_old
kept_chars_ratio
Enter fullscreen mode Exit fullscreen mode

Those fields were useful metadata, but they were not part of PaperForge's domain model.

Because I was validating the entire provider object directly against a model with extra="forbid", an otherwise valid proposed change could fail validation simply because the provider had added more information.

The bad assumption

My mental model was effectively:

A SuperDocs pending-change object and my application's PendingChange object are the same contract.

They were not.

The external response belongs to the provider. The persisted domain object belongs to PaperForge.

Root cause

I put strictness at the wrong boundary.

The real shape is:

SuperDocs response
        ↓
integration boundary
        ↓
PaperForge-owned model
Enter fullscreen mode Exit fullscreen mode

The provider should be allowed to add fields that PaperForge does not use.

PaperForge should still be strict about the fields it does depend on.

The fix

I kept the internal model strict and added an explicit projection at the integration boundary:

_PENDING_CHANGE_FIELDS = frozenset(
    {
        "change_id",
        "operation",
        "chunk_id",
        "document_id",
        "old_html",
        "new_html",
        "ai_explanation",
        "insert_after_chunk_id",
        "insert_before_chunk_id",
    }
)
Enter fullscreen mode Exit fullscreen mode

Before validating the object, PaperForge now selects only the fields it owns:

def _normalize_pending_change(value):
    if not isinstance(value, Mapping):
        raise SuperDocsProtocolError(
            "SuperDocs returned invalid pending changes."
        )

    return {
        key: value[key]
        for key in _PENDING_CHANGE_FIELDS
        if key in value
    }
Enter fullscreen mode Exit fullscreen mode

The projected value is then validated by the strict model.

So the boundary is tolerant of additive provider metadata, while the persisted contract remains strict.

How I proved it

I added a regression test using the same additive machine_checks shape from the live response.

The test verifies that:

  • the change still reaches awaiting_approval;
  • the expected review fields survive;
  • machine_checks does not leak into persisted review.json;
  • the internal model remains strict.

The useful pattern is:

accept provider evolution at the edge
→ project the fields you own
→ validate those fields strictly
→ persist the smaller stable contract
Enter fullscreen mode Exit fullscreen mode

Code:

PaperForge review model

PaperForge review normalization

Regression test


2. A retry could have started the same SuperDocs workflow twice

What I was trying to do

Starting a review is an external side effect.

PaperForge sends a request to SuperDocs, receives a job_id, and polls that asynchronous job until it becomes actionable.

I wanted this endpoint to be safe to retry.

What broke

There is a dangerous failure window:

PaperForge starts review
        ↓
SuperDocs accepts it and returns job-1
        ↓
local process/request is interrupted
        ↓
client retries POST /review
Enter fullscreen mode Exit fullscreen mode

If the retry simply starts again from the beginning, it can create a second provider job for the same report.

That can mean duplicated work, duplicated usage, two independent change sets, and uncertainty over which job local state should trust.

The bad assumption

The incorrect assumption was:

If my HTTP request did not finish, the external operation did not finish either.

A local timeout or exception tells me almost nothing about whether an external side effect already occurred.

Root cause

I had been thinking of review creation as a normal request-response operation.

It is actually a small distributed state machine:

A. no provider job exists

B. provider job exists and is processing

C. provider job reached an actionable or terminal state
Enter fullscreen mode Exit fullscreen mode

A retry in state B must not behave like a request in state A.

The job_id is therefore not disposable response metadata. It is the recovery handle for the external side effect.

The fix

PaperForge persists review state and checks it before starting anything new.

If the state is already processing and has a job_id, the service polls the existing job:

existing = self._read_optional_state(report_id)

if existing is not None:
    if existing.status == "processing" and existing.job_id:
        return self._poll(existing)

    return existing
Enter fullscreen mode Exit fullscreen mode

Only a report with no recoverable review state is allowed to start a new SuperDocs job.

The session ID is also deterministic:

session_id = f"paperforge-review-{report_id}"
Enter fullscreen mode Exit fullscreen mode

The rule became:

Persist external identity before treating a retry as new work.

How I proved it

The regression test preloads review.json with:

ReviewState(
    report_id=report_id,
    session_id="paperforge-review-existing",
    job_id="job-1",
    status="processing",
)
Enter fullscreen mode Exit fullscreen mode

Then it calls start_review() again.

The critical assertion is:

assert not client.start_calls
Enter fullscreen mode Exit fullscreen mode

The retry polls job-1. It does not create another one.

This changed how I think about idempotency.

It is not just “support the same request twice.” It is:

Know exactly when the external side effect happened, persist its identity, and recover that same work on retry.

Code:

Review recovery logic

Recovery regression test


3. I inferred the approval write contract from the read contract

This was a smaller bug, but a useful integration lesson.

What I was trying to do

The review job returned a list of proposed changes, each with a change_id.

So the natural approval payload looked like this:

{
  "job_id": "job-...",
  "changes": [
    {
      "change_id": "change-...",
      "approved": true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Each change already had an explicit decision. That looked semantically complete.

What broke

The approval endpoint also required a top-level approved value.

The working request therefore needed both:

payload = {
    "job_id": job_id,
    "approved": approved,
    "changes": changes,
}
Enter fullscreen mode Exit fullscreen mode

The nested decisions described the human intent, but the endpoint still had its own required envelope.

The bad assumption

I assumed the write shape would mirror the read shape:

pending_changes[]
        ↓
changes[{ change_id, approved }]
Enter fullscreen mode Exit fullscreen mode

But read and write contracts are not automatically symmetrical.

The actual request had another required field:

approval request
├── job_id
├── approved
└── changes[]
    ├── change_id
    └── approved
Enter fullscreen mode Exit fullscreen mode

Root cause

I inferred the provider's transport schema from the application data I wanted to send.

That is fragile because action endpoints often contain workflow-level fields that appear redundant from the domain model's point of view.

The fix

I moved provider-specific request construction into the SuperDocs client.

The workflow decides what the human chose.

The client decides how that decision must be encoded on the wire.

In the Closing Document Reconciliation build:

payload = {
    "job_id": job_id,

    # Required even when every individual change
    # below has its own approval decision.
    "approved": approved,

    "changes": changes,
}
Enter fullscreen mode Exit fullscreen mode

Later, PaperForge used a narrower one-change-at-a-time approval request:

payload = {
    "job_id": job_id,
    "change_id": change_id,
    "approved": approved,
}
Enter fullscreen mode Exit fullscreen mode

In both cases, the provider adapter owns the exact request contract.

What I took away

Do not infer a write schema from a read schema, especially for action endpoints such as:

approve
reject
continue
commit
cancel
Enter fullscreen mode Exit fullscreen mode

A field that looks redundant may still be part of the endpoint's actual workflow contract.

Code:

Closing-document SuperDocs client

Human-review demo flow

PaperForge SuperDocs client


4. A targeted edit produced too much HTML to be a useful human diff

What I was trying to do

PaperForge uses SuperDocs for a deliberately narrow editing step:

SuperDocs proposes one Executive Summary revision
        ↓
human sees BEFORE / AFTER
        ↓
human approves or rejects
Enter fullscreen mode Exit fullscreen mode

I initially expected a targeted edit to naturally produce a small review payload.

What broke

The semantic change was small, but the old_html and new_html could include a large amount of surrounding document structure.

Dumping that raw content into the UI technically showed the provider response, but it made the human-review step worse.

A reviewer should not have to scan a document-sized block to discover what changed.

The product requirement became:

Show the smallest useful human-readable window around the changed text, while keeping the underlying provider proposal untouched.

The bad assumption

I had treated the provider's edit representation as if it were also the correct presentation model.

Those are different responsibilities.

The provider representation answers:

What document content changed?
Enter fullscreen mode Exit fullscreen mode

The review UI must answer:

What does the human need to read to decide?
Enter fullscreen mode Exit fullscreen mode

The fix

I added a dedicated presentation transform.

First, the frontend parses the HTML and extracts normalized text:

const plainText = (html: string | null) => {
  const document = new DOMParser().parseFromString(
    html ?? "",
    "text/html"
  );

  return (document.body.textContent ?? "")
    .replace(/\s+/g, " ")
    .trim();
};
Enter fullscreen mode Exit fullscreen mode

Then it finds the common prefix and common suffix between before and after.

Finally, it keeps only a bounded amount of context around the changed region:

const radius = 260;
Enter fullscreen mode Exit fullscreen mode

The UI renders the result as normal React text under explicit Before and After labels.

It does not render provider HTML with dangerouslySetInnerHTML.

Why I did not modify the provider change

The original PendingChange still contains the complete old_html and new_html.

The bounded diff is only a presentation.

That separation matters:

provider representation
        ↓
domain representation
        ↓
human presentation
Enter fullscreen mode Exit fullscreen mode

The approval workflow keeps the original change intact while giving the reviewer a much smaller and safer view of it.

What the review UI looked like

PaperForge human review screen

The live PaperForge review surface: the human sees the proposed before/after change and must explicitly approve or reject it before the reviewed DOCX becomes available.

How I proved it

The frontend test provides:

old_html: "<p>Before sentence.</p>",
new_html: "<p>After sentence.</p>"
Enter fullscreen mode Exit fullscreen mode

and checks that the interface renders:

Before sentence.
After sentence.
Enter fullscreen mode Exit fullscreen mode

It then approves only the selected change_id.

The real end-to-end run used the same presentation path against a much larger change block and produced a readable review card rather than a raw HTML dump.

What I took away

A provider's wire representation is not automatically a good human interface.

For review systems, I now think in three layers:

1. provider representation
   enough structure to describe/apply the edit

2. application representation
   stable fields needed to track the decision

3. review presentation
   the minimum readable context needed by a human
Enter fullscreen mode Exit fullscreen mode

Human approval is only meaningful if the change is easy enough to inspect.

Code:

Bounded diff logic

Diff UI

Frontend regression test


5. completed did not prove the exported DOCX contained the approved edit

This was the most important correctness lesson.

What I was trying to do

The end of the workflow looked reassuring:

human approves
        ↓
SuperDocs job reaches completed
        ↓
export endpoint returns DOCX bytes
Enter fullscreen mode Exit fullscreen mode

It would be natural to display:

Reviewed document ready
Enter fullscreen mode Exit fullscreen mode

at that point.

I decided that PaperForge needed one more check.

What could go wrong

A provider can report a workflow as completed and still leave room for artifact-level failure:

  • stale session state;
  • wrong export target;
  • change not reflected in the file;
  • structurally valid DOCX with old text;
  • delete operation whose text remains.

A successful HTTP response proves that bytes came back.

A provider status proves that the provider workflow finished.

Neither directly proves:

The human-approved edit is present in the file PaperForge is about to offer for download.

Root cause

I was defining success too early.

There are three different levels:

1. transport success
   the API call worked

2. workflow success
   the provider job completed

3. artifact success
   the exported file reflects the approved decision
Enter fullscreen mode Exit fullscreen mode

PaperForge's user-facing promise depends on level 3.

The fix

PaperForge independently verifies the exported DOCX before setting:

final_docx_available = true
Enter fullscreen mode Exit fullscreen mode

A DOCX is a ZIP container, so the verifier opens it and reads:

word/document.xml
Enter fullscreen mode Exit fullscreen mode

It parses the WordprocessingML text nodes and normalizes the resulting document text.

Then it checks the approved operation.

For edit and create:

if not new_text or new_text not in document_text:
    raise ReviewVerificationError(
        "The approved change was not found "
        "in the DOCX export."
    )
Enter fullscreen mode Exit fullscreen mode

For delete:

if old_text and old_text in document_text:
    raise ReviewVerificationError(
        "The deleted change remains "
        "in the DOCX export."
    )
Enter fullscreen mode Exit fullscreen mode

If the DOCX is not a usable ZIP, the XML is missing, or the document cannot be parsed, verification fails closed.

How I proved it

I added a deliberately bad export test.

The approved change is:

Original executive summary.
        ↓
Clear revised executive summary.
Enter fullscreen mode Exit fullscreen mode

The fake provider reports:

completed
Enter fullscreen mode Exit fullscreen mode

but exports a DOCX containing:

The old text remains.
Enter fullscreen mode Exit fullscreen mode

The test requires:

with pytest.raises(ReviewVerificationError):
    service.approve(report_id, "ch-1")
Enter fullscreen mode Exit fullscreen mode

and then verifies:

assert service.get_review(report_id).status == "failed"
Enter fullscreen mode Exit fullscreen mode

So PaperForge refuses to claim success even though the simulated provider workflow itself reached completed.

Important limitation

This verifier does not prove complete DOCX fidelity.

It does not prove every style, page break, list, table, or layout detail survived.

It proves the narrower property PaperForge promises at this boundary:

approved replacement/create text must be present
approved deleted text must be absent
Enter fullscreen mode Exit fullscreen mode

That postcondition is deterministic and testable.

What I took away

External status should not automatically become application-level truth.

For consequential artifacts, define a postcondition your own system can verify:

provider says completed
        ↓
export artifact
        ↓
inspect artifact independently
        ↓
verify the promised property
        ↓
only then expose "completed"
Enter fullscreen mode Exit fullscreen mode

Code:

DOCX verification

Fail-safe regression test


What changed in my integration design

These bugs look unrelated at first:

extra JSON fields
duplicate jobs
approval payload shape
oversized diffs
DOCX verification
Enter fullscreen mode Exit fullscreen mode

But they all came from the same mistake: letting the external API boundary leak too far into the rest of the application.

The more robust architecture became:

                SuperDocs
                   │
                   ▼
          narrow provider adapter
                   │
         normalize + validate
                   │
                   ▼
        PaperForge-owned state
                   │
          explicit state machine
                   │
                   ▼
            human review UI
                   │
                   ▼
          explicit human decision
                   │
                   ▼
              DOCX export
                   │
          independent verification
                   ▼
               completed
Enter fullscreen mode Exit fullscreen mode

The important word there is owned.

SuperDocs owns the document-editing primitives.

PaperForge owns:

  • what provider data becomes persistent application state;
  • how retries recover external jobs;
  • how a human sees a proposed change;
  • when a decision is allowed;
  • and what evidence is required before the application says the workflow is complete.

That separation made the integration much easier to reason about.


The five lessons I would carry into the next external API integration

  1. Be tolerant at the provider boundary, strict inside your own domain.

    Additive upstream metadata should not break a stable application model.

  2. Persist external operation identity before relying on retries.

    A failed local request does not prove an external side effect did not happen.

  3. Do not infer write contracts from read contracts.

    Action endpoints often have envelope-level semantics that are easy to miss.

  4. Separate machine representations from human review representations.

    Correct payloads can still create unusable approval UX.

  5. Verify the product-level postcondition yourself when you can.

    200 OK, completed, and “file returned” are different claims.

The integration became much stronger once I stopped treating SuperDocs as a black-box call inside a happy-path script and started treating it as one component in a stateful, human-reviewed system.

That is also the main thing I would do differently if I started again: define the boundary contracts and state transitions first, before wiring the first live provider call.


Repositories

Top comments (0)