Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

Scheduling plugin

This is a remote service plugin. It schedules one pinned version to be published, or one pinned published version to be unpublished. It also runs named releases: groups of publish and unpublish items across models and languages, run together at a set time or now. It carries out each change through Dee Wan plugin protocol v1. It has its own Cloudflare Worker, its own D1 database, its own cron trigger, and its own management UI. None of its code runs inside Dee Wan.

The manifest id is dev.dee-wan.scheduling, set as PLUGIN_ID in src/app.ts. It never changes across releases: an installed plugin’s id cannot change, and a release with a different id is refused as manifest_identity_changed. A fork that you deploy as a different plugin needs its own reverse-domain id.

This directory is prepared to become the root of its own repository, https://github.com/dee-wan-cms/dee-wan-scheduling.git. Until that move happens it lives in the Dee Wan repository at plugins/scheduling. The last section, Moving to its own repository, lists the steps.

Documentation: Testing a plugin sets up this plugin alongside a local Dee Wan instance. Scheduling, end to end then walks through install, a scheduled publish, and each failure. On the core side, see Writing a plugin, Protocol v1, Plugin security, and Testing a plugin.

Plugin Manifest Dee Wan protocol
0.1.0 v1 v1
Route Who What
GET /dee-wan/manifest.json Dee Wan admin Manifest v1, with the origin taken from PUBLIC_ORIGIN.
GET /assets/scheduling.css Browser The management UI’s stylesheet.
POST /admin/activation-codes Operator, with Bearer ADMIN_SECRET Issues a one-time code, valid for 15 minutes. Body {} issues an activation code. {"installation_id":"..."} issues a rotation code for that installation only. Answers 201 with {"code", "purpose", "installation_id", "expires_at"}: purpose is activate or rotate, installation_id is null for an activation code, and expires_at is in milliseconds since the epoch.
POST /dee-wan/activate Dee Wan core activate creates an installation and refuses an existing one. rotate replaces the token. Each code is consumed once.
POST /dee-wan/operations/schedule-status Dee Wan core The schedule-status operation. Takes the closed invocation envelope, exchanges its code with the installation’s token, reads the bound entry and answers its pending, retrying and running schedules as rows. Read-only. An open envelope is 400 and an unknown installation 404; if core will not exchange the code, if the request names another operation, or if the entry cannot be read, the operation returns status: "refused".
POST /dee-wan/operations/schedule-column Dee Wan core The schedule-column content-list column. Takes the closed invocation envelope, exchanges its code, and answers one cells block: a badge for each named entry that has a pending, retrying or running schedule (the soonest, as Publish 2026-09-20 09:00; tone warning once it is retrying or running). It reads only this installation’s schedules and writes nothing. An entry with no live schedule gets no cell. A code core will not exchange, another operation, a bound entry or input that is not a list of entry ids answers status: "refused".
POST /dee-wan/operations/schedule-draft Dee Wan core The schedule-draft operation. Exchanges the code, takes the closed input { locale, transition, due_at, timezone, version_id } (due_at a whole UTC minute, timezone IANA, version_id the saved draft version to pin), reads the bound entry through the binding’s delegation_id and creates the same pinned publish schedule as the form, pinned to exactly version_id, keeping that delegation and the caller’s email. The same request again answers the schedule it made. Refusals answer status: "refused" by name: invalid_input, invalid_instant, unknown_timezone, past_time, entry_unreadable, requester_unauthorized, locale_unavailable, transition_not_allowed, nothing_to_pin, version_moved, draft_changed (the entry’s current draft is not version_id), already_scheduled, execution_in_progress. It never supersedes another live schedule.
POST /dee-wan/operations/releases Dee Wan core The releases site operation. Exchanges the code and reads the reach of the binding’s delegation_id. Empty release_id: every release as rows and releases.json. With release_id: that release’s items the caller may read, with readiness or outcome, as rows and release.json (the receipt). Read-only.
POST /dee-wan/operations/release-edit Dee Wan core The release-edit site operation: action create (name), rename (release_id, name), add / add_future (release_id, instance_id, kind, optional locale and transition) or remove (release_id, instance_id, kind, locale). Answers the release as releases does. Refusals answer status: "refused" by name, as in Releases below.
POST /dee-wan/operations/release-run Dee Wan core The release-run site operation, destructive: action schedule (release_id, due_at a whole UTC minute, timezone IANA), cancel, run_now or retry. The caller’s delegation becomes the one the release runs under. run_now and retry start the first items at once and answer the release.
POST /dee-wan/operations/timeline-preview Dee Wan core automation Read-only cumulative release timeline for release_id, under the current delegation. Requires a release time; refuses release_time_required or overlapping same-instant changes as release_time_ambiguous. Not directly available through management API, CLI or MCP.
GET /installations/:id?dee_wan_launch=dwl_... Browser, from Dee Wan Exchanges the code, sets a session, and redirects with 303 to the page without the code. A manage launch with dee_wan_view=calendar or dee_wan_view=releases redirects to that view instead.
GET /installations/:id Session A content launch shows the content, the schedule form and that content’s schedules. A manage launch shows the agenda.
GET /installations/:id?month=&zone=&model=&lang=&state= Manage session The agenda: one month as a list, grouped by day.
GET /installations/:id/calendar?month=&zone=&model=&lang=&state= Manage session The calendar: one month as a grid, with a count per day.
GET /installations/:id/calendar/:day?zone=&model=&lang=&state= Manage session One day (YYYY-MM-DD): its schedules by due time, then id.
GET /installations/:id/schedules/:scheduleId Session One schedule, with a preview of its content read through the session’s delegation.
POST /installations/:id/schedules Session and CSRF token Creates a schedule.
POST /installations/:id/schedules/:scheduleId/reschedule Session and CSRF token Moves a pending or retrying schedule to a new time.
POST /installations/:id/schedules/:scheduleId/cancel Session and CSRF token Cancels a pending or retrying schedule.
GET /installations/:id/releases Manage session Every release, newest first, and the form that creates one.
POST /installations/:id/releases Manage session and CSRF token Creates a release (name, 1 to 80 characters).
GET /installations/:id/releases/:releaseId Manage session One release: when it runs, progress, its items with readiness, or its receipt.
POST /installations/:id/releases/:releaseId/rename Manage session and CSRF token Renames the release.
POST /installations/:id/releases/:releaseId/schedule Manage session and CSRF token Sets or moves the time (local, timezone, fold) of a draft or scheduled release.
POST /installations/:id/releases/:releaseId/run Manage session and CSRF token Runs a draft or scheduled release now.
POST /installations/:id/releases/:releaseId/cancel Manage session and CSRF token Unschedules a scheduled release, or stops a running one.
POST /installations/:id/releases/:releaseId/retry Manage session and CSRF token Runs the failed and cancelled items of a finished release again.
POST /installations/:id/releases/:releaseId/items/:itemId/remove Session and CSRF token Removes an item while the release has not started. A content session removes only its own entry’s items.
POST /installations/:id/release-items Content session and CSRF token Adds the session’s entry to a release (release), or to a new one (new_release), with transition kind:to, optional locale, and version_mode ordinary (default) or future (publish only).
POST /admin/installations/:id/delete-data Operator, with Bearer ADMIN_SECRET Deletes all plugin data for the installation, releases included, and answers {"deleted": true}. 404 not_found if the plugin holds no such installation. 409 execution_in_progress while one of its schedules or release items is running.

scheduled() runs one schedule runner tick, then one release tick.

Node 24 and npm. From this directory:

Terminal window
npm install
npm run typecheck
npm test
npm run dev # wrangler dev on port 8788, with --test-scheduled

npm install also installs @dee-wan/plugin-conformance, the protocol v1 suite the tests run. Inside the Dee Wan repository it is the local package packages/plugin-conformance, declared as file:../../packages/plugin-conformance. Build it first with npm run build in that directory. The other tools also resolve from the Dee Wan root install there. In this plugin’s own repository the package is a semver range from the npm registry, once it is published.

  1. Deploy the Worker (see below) and set PUBLIC_ORIGIN.
  2. Mint an activation code:
    Terminal window
    curl -X POST -H "Authorization: Bearer $ADMIN_SECRET" https://scheduling.example/admin/activation-codes
  3. In Dee Wan, go to Settings → Plugins and install from https://scheduling.example/dee-wan/manifest.json with the code. Grant the capabilities and models. workflow:locale is needed only to schedule one language at a time. Without it, every schedule moves the whole translation group.
  4. To rotate the token, mint a code with {"installation_id":"<id>"} and give it to the rotate action in Dee Wan.

The manifest declares four ui_extensions. They are data. Dee Wan draws each card itself and opens Scheduling through its normal delegated launch.

  • schedule-card (entry_sidebar) and page-schedule (page_sidebar): a Schedule card beside an entry or a page whose model the grant includes. It launches the schedule content action.
  • calendar-row (settings_row): a Calendar card on the Scheduling row in Settings → Plugins. It launches the calendar manage view, so it replaces the plain Calendar button there.
  • releases-row (settings_row): a Releases card on the same row. It launches the releases manage view.

Scheduling declares no field editor.

Its operations cover schedule status, content-list badges, releases and core automation. It declares no policy.

  • schedule-column (scope: "page", audience: "column", cell: "badge", requires: ["content:read"], result cells). Dee Wan adds a Scheduled column to the content list of every granted model. Core sends the ids of rows on the current page that the person may read, never their values; the operation returns a badge for each entry with a live schedule. It binds no delegation and is never listed as an ordinary operation.
  • schedule-status (audience: "interactive", requires: ["content:read"], result rows, list_action: true). Dee Wan shows it beside an entry of a granted model; a person runs it there and sees the entry’s pending schedules. In the content list it is also a Plugin actions entry for the rows a person selects, run once per row. An agent runs it through the management API or MCP.
  • schedule-draft (audience: "automation", requires: ["content:read", "workflow:read", "workflow:publish", "workflow:locale"], result rows and json, destructive). Only Dee Wan’s content planner runs it, as the plan owner, to schedule the exact draft version it saved (version_id); when the entry’s current draft is another version the operation refuses with draft_changed and schedules nothing. It is never an editor, management API, CLI or MCP operation, and those invocation routes reject it. The result’s schedule.json block is the reference Dee Wan keeps: { schedule_id, status, locale, transition, due_at, timezone, local_datetime, pinned_version_id }. The schedule keeps the operation’s delegation_id, so Dee Wan re-reads the owner’s authority when it runs, exactly as for a schedule made in the form.
  • timeline-preview (scope: "site", audience: "automation", requires: ["content:read", "workflow:read", "workflow:release"], result json): core-only cumulative preview through release_id, under the current delegation. It writes nothing.
  • releases, release-edit and release-run (scope: "site", audience: "interactive", result rows and json; release-run is destructive and also requires workflow:publish + workflow:release). They are the Releases page as operations, so the admin, the management API, the CLI and local and remote MCP run the same rules. Dee Wan offers site operations to people with site:plugins and binds the caller’s manage delegation, on the granted models they read; Scheduling reads that delegation’s reach on every call. One operation per kind of change, with an action input, keeps the manifest inside the operation cap.

Copy wrangler.example.jsonc to wrangler.jsonc. For a deployment, replace the placeholder database_id with the id wrangler d1 create prints. Local development keeps the placeholder.

  • Binding: PLUGIN_DB (D1).
  • Cron: * * * * *.
  • Variables: PUBLIC_ORIGIN, ENVIRONMENT, DEE_WAN_DEV_ORIGINS, MAX_ATTEMPTS (5), MAX_LATENESS_MS (900000), LEASE_MS (60000), MAX_RETRY_AFTER_MS (300000).
  • Secrets: ADMIN_SECRET (at least 16 characters), TOKEN_KEY (base64 of 32 random bytes, openssl rand -base64 32).
Terminal window
npx wrangler d1 create dee-wan-scheduling
npx wrangler d1 migrations apply dee-wan-scheduling --remote # --local for dev
npx wrangler secret put ADMIN_SECRET
npx wrangler secret put TOKEN_KEY
npm run deploy

Apply new files in migrations/ with the same migrations apply command before you deploy a release that needs them.

Losing TOKEN_KEY makes the stored installation tokens unreadable. Schedules are then refused with token_unreadable, and each installation has to be rotated.

The setup — both services, their settings, and the commands — is in Testing a plugin. It needs no Cloudflare account.

The management UI cookie uses the __Host- prefix and the Secure attribute, so the browser needs HTTPS or localhost. An HTTP Dee Wan base URL is accepted only when ENVIRONMENT=development and its exact origin is listed in DEE_WAN_DEV_ORIGINS. Otherwise the base URL must use HTTPS and must contain no credentials, no query, and no fragment.

  • A schedule can be created only from a content launch (the Schedule action). The plugin reads the content with the delegation, and the chosen (kind, to) must be in the delegated workflow.transitions.
  • Language: when the installation holds workflow:locale and the content has more than one language, the form offers All languages or one language. Single-language content shows no choice. All languages moves the whole translation group. One language moves only that language: the chosen (kind, to) must be in that language’s transitions under workflow.locales, and the command sends locale. A posted language that Dee Wan does not list is refused with locale_unavailable.
  • Pins: a publish pins current_version_id, an unpublish pins published_version_id, and both also pin workflow.revision. A one-language unpublish pins that language’s own published_version_id. A one-language publish pins the group’s current_version_id, because one version holds every language. The revision is always the group’s. The schedule is stored against the primary instance, with locale set for one language and empty for the whole group.
  • There is at most one live (pending|retrying|running) schedule per installation, content item, kind, and language. A whole-group schedule and a one-language schedule of the same item and kind conflict too: the group includes that language. A new schedule supersedes every conflicting live pending or retrying schedule in one batch. So a whole-group schedule replaces the item’s one-language schedules of that kind, and a one-language schedule replaces the whole-group schedule of that kind. One-language schedules for different languages wait side by side. If a conflicting schedule is running, creation fails with execution_in_progress.
  • Rescheduling creates a new row with a new revision and a new idempotency key. Revisions count per installation, content item and kind, across languages.
  • The idempotency key is sched:<installation_id>:<schedule_id>:r<revision>. It stays the same on every retry.
  • Only pending or retrying schedules can be cancelled. Cancelling a running schedule returns execution_in_progress. A finished schedule cannot be cancelled. Cancelling never calls Dee Wan and never reverses a transition.
  • At execution time, core re-checks the person’s authority, the pins, and the revision.
  • A manage launch opens the agenda, or the calendar when Dee Wan asked for it: the schedules of the models the delegation reaches. The person can preview them, and reschedule or cancel those they may change. They cannot create schedules.

Statuses: pending, retrying, running, completed, refused, cancelled, superseded.

A release publishes or unpublishes up to 25 items in one CMS command. Items may span models and languages within one site. Each item names an entry, a language or the whole translation group, a kind (publish or unpublish) and a target state.

  • Open the Releases card in Settings → Plugins, or Releases on the Dashboard. This is a manage launch with view: releases; it needs access to a granted model, not site:plugins. Site operations still require site:plugins. Add entries from Add to release on their Schedule cards.
  • Each change must currently be offered to the person through their delegation. Different languages of one translation group are allowed. Duplicate language scopes, or a whole-group item overlapping a language item, are refused as already_in_release. The limit is 25 items (release_full).
  • Items show their title, language, model, change and current readiness. Unreadable items are counted without exposing their titles or reasons. Run now and Retry unfinished items are disabled while an item is blocked. An unavailable readiness check is unknown, not blocked; nothing starts.
  • States are draft, scheduled, running, done, failed, unknown and cancelled. Failed means the CMS refused the command and none of its publication changes committed. Outcome unknown means a response did not establish whether the command committed. Item receipts retain the reported result; the runner never guesses which items succeeded.
  • Adding or removing items is allowed only in draft and scheduled. Scheduling uses the same date, time and timezone rules as a single schedule. The release runs as the person who schedules, starts or retries it. Core rechecks current grants immediately before commit.
  • Ordinary items are not pinned when added. At execution, the runner records the latest saved ordinary draft for publication (expected_current_version_id), the live version for withdrawal (expected_published_version_id) and the workflow revision. An independent future item uses its own branch instead, as described below. The whole recorded set goes to /releases/commit with reviewed workflow:release and the required per-change capabilities.
  • Core validates the complete final publication state, including dependencies between items in the release. List order does not determine whether a shared section and its page can publish together. Distinct locales of one translation group commit together. Permission, version, workflow, locale, dependency or statement-budget refusal changes nothing. Core never splits an oversized command into smaller commits.
  • Run now checks the whole release and sends one command. A scheduled tick checks one due release before starting it. A blocked scheduled release stays scheduled, records blocked_at and a Blocked attempts receipt, and is not automatically started again. Fix or remove the blocking items and use Run now, or set a new time to clear the mark.
  • If the item set changes during readiness checks, starting or retrying is refused as release_changed; nothing is sent. The scheduled tick checks the new set on a later tick.
  • Unscheduling returns a scheduled release to draft. Stopping prevents work that has not been sent. It cannot reverse an in-flight atomic command; the recorded result must still be recovered.
  • Idempotency uses rel:<release_id>:a<attempt> for the complete command. A missing, transient or untrusted response retains every recorded pin and repeats the same command under the same key. After automatic retry limits, the release shows Outcome unknown when commit status remains unknown. Retry recovers that command before starting new work. A definite refusal permits a new attempt with fresh versions after readiness checks. Completed publication is never repeated as a new command.
  • Receipts keep the release outcome, per-item published versions, last code and completion time. The releases operation downloads them as release.json. Delivery of notifications and automatic redirect completion happen after the tenant commit and cannot roll it back.

In Add to release, Version → Latest ordinary draft keeps the ordinary behavior. For publication, choose Version → Independent future version to create a separate version for this release. Future versions cannot be used for unpublish (future_requires_publish). The release-edit operation offers the same choice through action: add_future with kind: publish; action: add keeps the ordinary draft.

In Dee Wan, open Future versions on the entry and select the release’s version. Edit its fields, or the sections and facts of a composed page, then Save. Future history lists that branch’s immutable saved versions. Other releases can hold their own branches for the same entry. Editing or publishing one leaves the ordinary draft intact. When execution starts, core freezes the selected branch revision and version; it cannot be edited after freezing. Retries recover that exact recorded command and version rather than substituting a newer ordinary draft.

Set the release’s date, time and timezone before Preview through this release; otherwise preview refuses with release_time_required. The preview starts from published content, applies earlier releases’ publish and unpublish items, then the selected release. It respects current read permissions and does not change publication or public caches. Releases at the same instant are ordered by stable release id, but overlapping changes to the same entry and language at that instant refuse with release_time_ambiguous: choose different times. For composed pages, use the preview inside the future page editor.

Core obtains this timeline through the read-only timeline-preview automation operation with release_id. It is not a management API, CLI or MCP operation. Publication still rechecks current delegated authority, required fields and tasks, workflow, references and pinned media for the selected future snapshot. Editing a frozen branch or previewing an unavailable target is refused; the ordinary draft is never substituted.

The site-wide publishing calendar. It reads only the schedules this plugin stores. Dee Wan keeps no copy of them and runs no scheduler.

  • Who: anyone who reads at least one model granted to Scheduling, own entries included. Calendar on the Dashboard or the Content list, or on the Scheduling row in Settings → Plugins, is a manage launch with view: calendar; it needs no site:plugins. Manage in Settings → Plugins is a bare manage launch and still needs site:plugins; it lands on the agenda. The manifest declares manage_views: ["calendar", "releases"], and Dee Wan adds dee_wan_view=calendar to the launch URL. The hint grants nothing; the plugin reads it only after a successful exchange of a manage code. A content launch has no calendar or agenda and answers 403 there.
  • Reach: before every page and every change the plugin asks Dee Wan GET /api/plugin/v1/delegations/<id>. Each view, count, filter, notice and schedule page shows only schedules of the models listed there. Any other schedule answers not_found. A person with access to no model sees No content you can see is scheduled here. Scheduling keeps the schedules; Dee Wan keeps no copy.
  • Own entries: a model listed entries: "checked" is one where the person reads or updates only their own entries (read:own, update:own). For it the plugin asks Dee Wan GET /api/plugin/v1/delegations/<id>/entries, up to 100 entry ids a call, on every page and change. It shows, counts and offers filters only for the entries Dee Wan lists, and offers Reschedule or Cancel only for the kinds listed on that entry. Any other schedule answers not_found. A person with no own schedules sees an empty calendar. Scheduling stores no owner. A view pages through every entry of such models in its range and asks once per entry, however many schedules it has; counts, lists and filters come from that one checked set. A range with more than 4,000 such entries, past what one Workers Free request may ask, is refused whole: This range is too large to check with Dee Wan at once. Nothing is shown. Open a single day. The model and language filters of such models come from the checked entries of the view’s range.
  • Removed: when Dee Wan answers that read with 401 plugin_unauthorized (the installation was uninstalled, turned off, or its token revoked), every page answers 200 and says This installation was removed from Dee Wan or turned off there. Its schedules no longer run. It shows no schedule, no count and no action. Without the delegation’s current model list the plugin cannot tell which models the person may see, so it shows none. A Cancel or Reschedule sent anyway changes nothing.
  • Dee Wan unavailable: no answer, a 5xx, or 429 rate_limited answers 403 with Dee Wan is not available right now. Nothing changed. Try again in a minute. and shows no schedules.
  • Who may change what: Reschedule and Cancel appear only when the schedule’s kind (publish or unpublish) is listed for its model. Dee Wan lists a kind when the installation holds workflow:publish or workflow:unpublish, the person may update the item (any item of the model, or under update:own their own), and the model’s workflow has an edge of that kind whose permission the person holds. A person who does not meet these conditions browses read only and the page says so. A Reschedule or Cancel sent anyway answers not_permitted and changes nothing. A reschedule then still re-reads the content, as below.
  • Views: Calendar is a month grid, Monday first, with a count per day. A day with schedules links to that day. Agenda is the same month as a list, grouped by day. Day lists one day’s schedules by due time, then id.
  • Address: the month and every filter live in the query, so each view is a plain GET link and the filter form works without JavaScript. Previous and next month keep the filters. So do the links between Calendar and Agenda, and the day links.
  • month: YYYY-MM. Default: the current month in the chosen timezone. An unusable value falls back to the default.
  • zone: the IANA timezone the days are interpreted in. Default UTC. Each row lands on the local day of its due time in that zone; month and day bounds are that zone’s local midnights, also across clock changes and where midnight itself is skipped. An unknown zone is refused by name with 400 and a link to the same view in UTC.
  • model: a model id. The list holds only models this installation has schedules for and the delegation reaches, by the slug recorded when the schedule was created.
  • lang: a language. The list holds only languages this installation has one-language schedules for, on models the delegation reaches. A whole-group schedule moves every language, so it matches any language filter.
  • state: current (the default: pending, retrying, running, completed, refused), live (pending, retrying, running), all, or one status. Completed and refused schedules show their result code and message.
  • Bounds: each view reads one month, or one day, with a range on due_at served by the schedule_agenda (installation_id, due_at, id) index. The calendar counts per day in that one query. The agenda and the day show at most 200 rows, then a note to narrow the filters. Views write nothing.
  • Names: each schedule shows the item’s title recorded when it was created, or its slug when the item has no title. A one-language schedule shows the language name, in the UI language. When the viewing zone differs from the schedule’s own zone, the time also shows in the viewing zone.
  • Preview: Open on a schedule reads its content from Dee Wan with the session’s delegation. It shows the title, model, language and current state, and whether the pinned version and workflow revision still match the current values. For a one-language schedule the language, state and pin are that language’s own. If Dee Wan no longer lists that language, the preview says locale_unavailable. If Dee Wan refuses the read, the page says why. Back returns to the view it was opened from.
  • Reschedule: a new local time and timezone for a pending or retrying schedule. The pins, the kind, the target and the language stay as they were. The plugin first re-reads the content through the session’s delegation: the person must still be offered that transition, for that language when the schedule has one, and the pinned version and revision must still be current. Otherwise it refuses with locale_unavailable, transition_not_allowed, scheduled_target_stale or workflow_conflict and changes nothing. A reschedule inserts a new row with the next revision and marks the old one superseded, so it gets a new idempotency key. The new row carries the rescheduling person’s delegation. A running schedule answers execution_in_progress, and a finished one cannot be moved.
  • Cancel: pending or retrying schedules only, as in Schedule semantics. It never calls Dee Wan. From a view it returns to that view with the notice. A return address is accepted only for this installation’s views, and its query is re-read, not echoed.

Dee Wan’s Workflow panel links here. When the last workflow move of an item was made by this plugin, the panel offers View schedule, which opens this plugin’s page for that item through the Schedule content action.

Each schedule stores the UTC due_at, the IANA timezone, the local wall time, and the fold. A timezone rule change never rewrites due_at. Resolution uses only Intl.DateTimeFormat.

Input Result
2026-07-01T09:00 America/New_York 2026-07-01 13:00 UTC
2026-03-29T02:30 Europe/Berlin refused nonexistent_local_time (clocks jump 02:00→03:00)
2026-10-25T02:30 Europe/Berlin, no fold refused ambiguous_local_time
same, fold earlier 2026-10-25 00:30 UTC (CEST)
same, fold later 2026-10-25 01:30 UTC (CET)
2026-07-01T09:00 Mars/Base refused unknown_timezone
2025-12-31T23:59 UTC, already past refused past_time

The confirmation and the list show the local time with its zone, and the UTC time. When the zone is UTC, the time shows once.

Each cron tick takes up to 20 due rows, round-robin across installations. A compare-and-set claim changes the observed status and lease token, increments attempt_count, and assigns a fresh token with lease_until = now + LEASE_MS. After token decryption, the sender rechecks ownership and attempt/age limits, and checks that enough lease time remains for the complete request timeout. A runner that no longer owns the lease sends nothing and cannot settle another owner’s row. Recovery may resend the identical key and body. A timeout does not prove that remote execution stopped. Core idempotency is the effect boundary, not a promise of one HTTP request.

Dee Wan answer Plugin result
200 transition_applied / idempotent_replay completed
400 invalid_request refused, no retry
401 plugin_unauthorized refused, no retry
403 capability_refused / requester_unauthorized refused, no retry
404 content_not_found refused, no retry
409 scheduled_target_stale / workflow_conflict / dependent_content / idempotency_key_reused refused, no retry
422 transition_unavailable / transition_refused refused, no retry
Present outcome with a status/code/retry mismatch or malformed fields. Body without an outcome on other statuses. Redirects refused, untrusted_response
429 rate_limited retrying. Uses a valid bounded Retry-After. Otherwise, uses exponential backoff with jitter.
503 temporary_failure retrying. Uses a valid bounded Retry-After. Otherwise, uses exponential backoff with jitter.
Bare 429/500/502/503/504 without an outcome. Network failure or timeout (10 s) retrying. Uses bounded Retry-After when supplied. Otherwise, uses exponential backoff with jitter.
past MAX_ATTEMPTS, past due_at + MAX_LATENESS_MS, or Retry-After > MAX_RETRY_AFTER_MS refused retry_exhausted, nothing more sent

A structured outcome is checked before the bare-status fallback. Retry-After accepts nonnegative integer seconds or a canonical HTTP date (IMF-fixdate). A past date means zero and malformed values use backoff. A Retry-After value above MAX_RETRY_AFTER_MS, an exceeded attempt cap or an exceeded age cap yields retry_exhausted. LEASE_MS has a minimum of 11 seconds, longer than the 10-second request timeout. A claim with insufficient lease time sends nothing. A valid success is only transition_applied or idempotent_replay.

retry_exhausted after a network failure means the outcome is unknown, so check the content in Dee Wan. A schedule is never sent later than the lateness cap, including when a lease left by a crashed runner is reclaimed.

Attempts are recorded in schedule_attempt (attempt, start, finish, code, HTTP status). This is an operational record for the user, not telemetry.

  • Stored data includes the installation id, site id, Dee Wan base URL and AES-GCM-encrypted token. It also includes hashed activation codes, hashed sessions (30 minutes) with their CSRF tokens, schedules and attempts, and releases with their items and receipts. No content snapshots and no Dee Wan user sessions are stored.
  • Launch codes and raw tokens are never logged or stored raw.
  • Uninstalling the plugin in Dee Wan revokes the token. The plugin data stays. Remaining schedules are refused with plugin_unauthorized when they come due. The plugin pages then say the installation was removed and show no schedules.
  • To delete plugin data, the operator calls POST /admin/installations/:id/delete-data with Bearer ADMIN_SECRET. The call is refused with 409 execution_in_progress while a schedule is running. No automatic retention purge is built.
Terminal window
npm test # vitest, from this directory
npm run typecheck
npm run test:cross-service # this plugin and a live Dee Wan, both under wrangler dev; two to three minutes

npm test includes test/conformance.test.ts, which runs the @dee-wan/plugin-conformance protocol v1 cases against this plugin: the outcome table, statuses, retry flags, capabilities and secret shapes against the published schemas, the manifest against the published manifest schema, reply classification, activation, launch exchange, and delivery through the real runner against the package’s fake core. test/helpers/d1.ts is a better-sqlite3 D1 shim that applies every file in migrations/.

test/docs.test.ts runs the Runner and Time tables above through classify and resolveLocalTime, so a row that nobody updated fails.

test:cross-service runs test/cross-service/cross-service.test.mjs. It starts Dee Wan’s admin Worker and this plugin under local wrangler dev, installs the plugin through Dee Wan, and runs scheduled publishes, unpublishes and refusals through protocol v1. Dee Wan is an external checkout: set DEE_WAN_CORE_DIR to a Dee Wan repository with its dependencies installed (node scripts/install-dependencies.mjs), its packages built (npm run build:packages) and its Prisma client generated (npm --workspace backend run prisma:generate). Inside the Dee Wan repository the variable may be left unset. The automated cross-service run lists what it asserts.

.github/workflows/ci.yml is this plugin’s own CI, for its own repository: install, typecheck, npm test, then the cross-service run against a Dee Wan checkout at vars.DEE_WAN_CORE_REF (default main). GitHub does not run it while this directory is inside the Dee Wan repository, and Dee Wan’s CI does not run this plugin.

The move needs explicit authority: creating dee-wan-cms/dee-wan-scheduling, pushing to it and publishing @dee-wan/plugin-conformance are external actions. With that authority, from the Dee Wan repository root:

Terminal window
# 1. Publish the conformance package (needs npm publish rights for the @dee-wan scope).
cd packages/plugin-conformance
npm install
npm test
npm publish --access public
cd ../..
# 2. Split this directory with its history into a branch.
git subtree split --prefix=plugins/scheduling -b scheduling-split
# 3. Create the empty repository dee-wan-cms/dee-wan-scheduling on GitHub, then push the branch as main.
git push https://github.com/dee-wan-cms/dee-wan-scheduling.git scheduling-split:main
# 4. In a clone of the new repository: depend on the published package and commit a lockfile.
git clone https://github.com/dee-wan-cms/dee-wan-scheduling.git
cd dee-wan-scheduling
npm pkg set devDependencies.@dee-wan/plugin-conformance=^0.1.0
npm install
npm run typecheck
npm test
git add package.json package-lock.json
git commit -m "Depend on the published @dee-wan/plugin-conformance"
git push

Then, in that repository: if dee-wan-cms/dee-wan is private, add a read-only DEE_WAN_CORE_TOKEN secret, and optionally set the DEE_WAN_CORE_REF variable to the Dee Wan ref to test against. The links in this README and in docs/end-to-end.md that point into ../../docs/plugins/ must become links to the published Dee Wan documentation.

Removing plugins/scheduling from the Dee Wan repository is a separate change there. It also drops this README and docs/end-to-end.md from the Dee Wan documentation site (docs-site/allowlist.mjs and their ru and th translations) and points docs/plugins/*.md at the new repository.


Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes