---
title: "Coordination at the Edge"
subtitle: "Why openscience.tech treats the manuscript, not the request, as its unit of coordination."
authors: ["Dima Doronin"]
affiliations: ["openscience.tech"]
date: "2026-07-12T12:05:48.387Z"
---

# Coordination at the Edge

*Why openscience.tech treats the manuscript, not the request, as its unit of coordination.*

## Abstract

openscience.tech runs a scientific publishing platform on Cloudflare's edge, and its design rests on one decision: the manuscript, rather than the user session or the page view, is the unit of coordination. Each manuscript is owned by a single Durable Object that carries live collaboration, permissions, and editorial state. Around that core, a Worker and a D1 database form the public control plane and discovery layer, while publication freezes output into immutable R2 artifacts. This report describes the resulting architecture, the reasoning behind its main decisions, and the cost of keeping three representations of the same document in agreement.

Most publishing platforms are, at heart, delivery systems. A document goes in, pages come out, and the interesting engineering is caching. A scientific manuscript does not fit that shape. It spends most of its life being argued over: drafted by several people at once, gated by permissions, pushed through editorial states, screened, revised, and only at the end frozen for the public record. openscience.tech was built around that lifecycle rather than around page delivery, and almost every structural choice in the codebase traces back to this.

The outermost layer is a Cloudflare Worker that handles routing, authentication, queue consumption, static assets, and agent entrypoints. The authoritative core is a Durable Object \[1\] scoped to a single manuscript, which owns the live collaborative document and its editorial workflow. Public readers and crawlers never touch that object. They read denormalized discovery records from D1 and immutable published artifacts from R2. Keeping those two worlds apart is the governing principle of the whole system.

## Three representations of one manuscript

:::figure{caption="Figure 1 — The shape of the system. The Worker authenticates and routes; the Article Durable Object governs live state; D1 serves discovery reads; R2 holds frozen publication artifacts. Screening runs through a queue so the editing path stays simple."}
[![Figure 1 — The shape of the system. The Worker authenticates and routes; the Article Durable Object governs live state; D1 serves discovery reads; R2 holds frozen publication artifacts. Screening runs through a queue so the editing path stays simple.](/api/files/manuscripts/21fb6bd9-315b-4462-b92b-1c5eb700a86d/assets/bceed708-2e62-4845-bcb5-62b421414f0a-edge_arch-view.jpg)](/api/files/manuscripts/21fb6bd9-315b-4462-b92b-1c5eb700a86d/assets/3660577f-10ea-44ea-a324-3140138bfd3f-edge_arch.jpg)
:::

The platform maintains three distinct representations of the same manuscript, each backed by different storage and serving a different audience. The live manuscript is a stateful object under a single coordination authority. The discoverable manuscript is a denormalized record in a relational index. The published manuscript is an immutable artifact in R2, served through the Worker. Each of them appears in code below.

It would have been simpler, on paper, to keep one canonical row per manuscript and derive everything else from it. In practice a single storage model cannot serve collaboration, workflow governance, ranking queries, and archival delivery at the same time without contorting itself. Splitting the representations lets each one stay honest about its job. The price is synchronization, which I return to at the end.

## One manuscript, one Durable Object

The defining choice is to model each manuscript as a single Durable Object. Inside that object live the collaborative document, the permission checks, the review state, and the bookkeeping for uploaded assets. The class declaration says most of it:

```ts
export class Article extends DurableObject<Env> {
  private collab: CollabEngine;           // real-time editing (Yjs)
  private readonly reviews: ReviewStore;  // review + publishing state machine
  private readonly perms: PermissionStore;
  private readonly assets: AssetStore;    // figure/media registry (R2 keys)
```

Every mutation and every serialization of the manuscript passes through one authority, in the style of an actor that serializes access to its own state \[2\]. Helland made the general argument years ago: at scale, guarantees are affordable within an entity, not across entities \[3\]. Here, the entity is the manuscript.

This is a thick object by design. Review state could have been pulled out into a separate workflow service; asset ownership could have been delegated to its own object. Both were considered and rejected, because the manuscript lifecycle is the central invariant of the domain, and invariants are cheapest to enforce at a single boundary. When submission rules, permission rules, and document state all live in one place, "can this user do this to this manuscript right now" is a local question with a local answer. Distribute those concerns and the same question becomes a small consensus problem.

The cost is a heavier class and a wider blast radius when it changes. That trade was acceptable here. It would not be in a system where manuscripts were mostly read and rarely governed.

## Discovery is a read model, not a source of truth

D1 stores manuscript metadata, roles, engagement counters, and the fields that leaderboards and freshness rankings want. Nothing in these tables authors or governs a live manuscript. The Durable Object hands the indexer a flat record and never runs SQL itself:

```ts
// The denormalized record the DO hands the Worker/Queue to index a manuscript.
export interface IndexRecord {
  id: string;
  title: string;
  authors: string[];
  discipline: string | null;
  stage: string;
  artifactKey: string | null;
  publishDate: string | null;
}
```

They exist so that ranking, filtering, sitemap generation, and published lookups can run as plain relational reads. The arrangement is CQRS in spirit \\\[4\\\]: commands go to the authority, queries go to a model shaped for querying.\
\
The effect is that public traffic never wakes a Durable Object. A crawler regenerating the sitemap, a reader browsing the leaderboard, a user listing their manuscripts — none of these acquire the coordination authority. This is the main scale boundary in the system, and it also keeps analytical clutter out of the manuscript object itself. The object does not need to know what "trending" means.

## Background work goes through queues

Two things happen after the fact when a manuscript changes state: the discovery index gets synchronized, and, on submission, an AI screening pass runs. Neither relies on fire-and-forget asynchronous work inside a request handler. Both flow through queue consumers attached to the Worker, which makes them durable and retryable. The consumer is deliberately boring:

```

async queue(batch: MessageBatch<IndexRecord | AiJob>, env: Env) {
  for (const message of batch.messages) {
    try {
      if (batch.queue === 'openscience-ai') {
        await runAIJob(env, (message.body as AiJob).id);
      } else {
        await upsertManuscript(env.DB, message.body as IndexRecord);
      }
      message.ack();
    } catch {
      message.retry();
    }
  }
}
```

The reasoning is that submission and publication are not page-level events. They are workflow transitions with downstream consequences that must eventually hold — the index must reflect the new state, the screening result must land. Treating that follow-up as part of the data model, rather than as a best-effort optimization, is what lets the rest of the system trust it.

## Publication freezes the record

Publishing a manuscript compiles it into artifact files and writes them to R2. Public reading paths serve those artifacts directly; they never reconstruct the article from live state. The publish handler freezes four sidecars in one shot — the compiled article, the raw body, the front-matter metadata, and the canonical Yjs snapshot:

```ts
await Promise.all([
  this.env.ARTIFACTS.put(markdownKey, this.collab.compileArtifact(date)),
  this.env.ARTIFACTS.put(`manuscripts/${id}/body.md`, this.collab.docBody()),
  this.env.ARTIFACTS.put(`manuscripts/${id}/meta.json`, JSON.stringify(meta)),
  this.env.ARTIFACTS.put(`manuscripts/${id}/snapshot.bin`, this.collab.snapshot()),
]);
await this.ctx.storage.put(ARTIFACT_KEY, markdownKey);
```

For scientific publishing this is less a performance decision than an archival one. The public record should be a stable object, and immutable data is the cheapest thing in a distributed system to cache, verify, and copy, precisely because nobody has to coordinate over it \[5\]. Authoring stays live and revisable for as long as it needs to, but the moment of publication produces a frozen output, and that output is what readers, citers, and downstream software refer to. It also happens to make published reads cheap and cacheable, which is a pleasant side effect of doing the right thing for the record.

## Readers and authors are different problems

The React frontend keeps public reading routes on the fast path and lazy-loads the heavier editorial surfaces — the editor, the dashboard — only when someone actually needs them:

```ts
const Editor = lazy(() => import("@/pages/Editor"));
const Dashboard = lazy(() => import("@/pages/Dashboard"));
const ManuscriptRead = lazy(() => import("@/pages/ManuscriptRead"));
```

Bundled editorial articles are compiled at build time by a Vite plugin, while published manuscripts arrive at runtime from object storage.

The split reflects the two audiences. Readers want fast, dependency-light delivery and will tolerate nothing else. Authors and editors want stateful tooling, live collaboration, and richer interfaces, and will tolerate a larger bundle to get them. Serving both from one undifferentiated bundle would shortchange the readers to subsidize the authors.

## Rich documents, without code execution

Manuscripts are allowed expressive content through an MDX pipeline, but the pipeline renders a deliberately safe subset. Expressions and module syntax are stripped before anything else happens:

```ts
// Never executed: module code and embedded expressions are removed.
if (
  node.type === "mdxjsEsm" ||
  node.type === "mdxFlowExpression" ||
  node.type === "mdxTextExpression"
) {
  kids.splice(index, 1);
  return index;
}
```

Lowercase tags pass through a whitelist of elements and attributes, with URLs restricted to sane schemes. Component-like syntax such as `<Figure>` or `<Callout>` maps to semantic markup rather than executing anything. Unrecognized components are unwrapped so the prose inside them survives.

The trust boundary here is explicit: a manuscript is untrusted user content, full stop. Scientific documents get math, figures, callouts, and structured markup — they do not get a JavaScript runtime.

## AI advises; it does not govern

The screening subsystem evaluates submitted manuscripts and posts structured assessments back into manuscript state. Its entire output surface is a small schema:

```ts
const screeningSchema = z.object({
  verdict: z.enum(['pass', 'minor', 'flag']),
  score: z.number().int().min(0).max(100),
  rationale: z.string().min(1).max(2000),
});
```

What it does not do is own the workflow. Lifecycle transitions, permission rules, and publication logic remain ordinary application code that a maintainer can read and a community can audit.

That line was drawn deliberately. AI is useful for compressing editorial triage; it is a poor place to encode the constitution of a journal. Keeping the governing model in plain code preserves institutional legibility — anyone who wants to know why a manuscript moved from one state to another can find the rule that moved it.

## What a request actually does

A short walkthrough ties the pieces together. An author opening a manuscript enters through the Worker, which verifies identity and forwards the request — or upgrades the WebSocket — to the owning Durable Object. The object then authorizes the user, admits or rejects the collaboration session, and applies whatever rules the manuscript's current phase imposes. Authentication and authorization are split across the two layers on purpose: the Worker knows who you are, the object knows what you may do.

A reader browsing the site never gets that far. Leaderboards, per-user listings, sitemaps, and published lookups are all answered from D1 through the repository layer, and published articles are streamed from R2, including markdown representations for machine consumers — a nod to the FAIR goal of making the record as reusable by software as it is readable by people \[6\].

When an author submits, the object records the transition and schedules screening through the queue. Workers AI evaluates the manuscript and the structured result lands back in manuscript state. The editing hot path never carries that weight; screening is part of the workflow, not a tax on every keystroke.

Publication is the same pattern with a different destination: the object compiles the output package, writes it to R2, and from then on the public reading experience is independent of the collaboration runtime.

## The tradeoff

The architecture's strength is that its technical boundaries coincide with the social ones. Authors need low-latency collaboration, so live state is centralized per manuscript. Editors need controlled transitions, so lifecycle enforcement lives with that same authority. Readers need cheap, stable access, so publication produces frozen artifacts. Discovery needs aggregation, so metadata is denormalized into a relational read model.

The weakness is the seams. Once live state, discovery state, and published state are distinct, keeping them in agreement is a first-class engineering problem rather than an afterthought, and the queue infrastructure exists largely to service it. I consider that complexity well spent. A unified model would have either weakened the editorial guarantees or made every public read pay for machinery it does not need — and in a publishing system, both of those are worse problems than synchronization.

## Conclusion

openscience.tech is better understood as a workflow engine with multiple representations of the same manuscript than as a content site with collaboration bolted on. The Durable Object holds the governed live document, D1 holds its discoverable metadata, and R2 holds its archival published form. Aligning that technical structure with the structure of publication itself — authorship, review, release, record — is the design's main achievement, and the reason the rest of the codebase stays legible.

The code snippets above are excerpts, lightly trimmed for presentation; the repository remains the authoritative reference for the implementation.

## References

\[1\] Varda, K. Workers Durable Objects Beta: A New Approach to Stateful Serverless. _The Cloudflare Blog_ (2020). [https://blog.cloudflare.com/introducing-workers-durable-objects/](https://blog.cloudflare.com/introducing-workers-durable-objects/)
\[2\] Hewitt, C., Bishop, P. & Steiger, R. A Universal Modular ACTOR Formalism for Artificial Intelligence. _Proceedings of the 3rd International Joint Conference on Artificial Intelligence_, 235-245 (1973).
\[3\] Helland, P. Life beyond Distributed Transactions: an Apostate's Opinion. _Proceedings of the 3rd Biennial Conference on Innovative Data Systems Research (CIDR)_, 132-141 (2007). [https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf](https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf)
\[4\] Fowler, M. CQRS. _martinfowler.com_ (2011). [https://martinfowler.com/bliki/CQRS.html](https://martinfowler.com/bliki/CQRS.html)
\[5\] Helland, P. Immutability Changes Everything. _Communications of the ACM_ **59**, 64-70 (2016). [https://doi.org/10.1145/2844112](https://doi.org/10.1145/2844112)
\[6\] Wilkinson, M. D. et al. The FAIR Guiding Principles for scientific data management and stewardship. _Scientific Data_ **3**, 160018 (2016). [https://doi.org/10.1038/sdata.2016.18](https://doi.org/10.1038/sdata.2016.18)
