# Scheduled developer-product discounts

`roblox_schedule_discount` persists a future discount and restoration under `~/.dominus/discount-jobs`. It requires explicit universe/product IDs, the expected original price, a lower discount price, start/end timestamps with a UTC offset, and `confirm: true`. Scheduling reads the product first and rejects an unexpected original price. No production job was created during implementation.

```json
{
  "universeId": 123,
  "productId": 456,
  "originalPrice": 100,
  "discountPrice": 50,
  "startsAt": "2026-10-01T00:00:00Z",
  "endsAt": "2026-10-02T00:00:00Z",
  "confirm": true
}
```

The updated bridge checks for due work every 15 seconds, processing one operation at a time. This is a local persistent scheduler, not hosted cron. The bridge must be running, and its credential must retain developer-product read/write access. Reload an older bridge before relying on execution. If the bridge is offline for an entire sale window, the sale is cancelled without applying it. An active discount is restored when the bridge resumes after its end time.

Both writes compare current price to the expected previous value and read back the result. Conflicts, failed reads, and uncertain writes move the job to `attention`. The comparison is not an atomic Roblox precondition: another creator or pricing system can change a price between requests. Schedule only products whose pricing changes you coordinate.

`roblox_discount_jobs` lists jobs. The local control panel also has a **Scheduled discounts** section with a **Schedule a discount** form, manual refresh, 50-job pages, prices, local display timestamps, execution notes, and cancellation for scheduled jobs. The form accepts explicit universe/product IDs, original and discounted prices, and start/end times labelled UTC. Submitting checks the original price through the shared scheduler before saving. Cancellation checks the displayed revision; stale or executing jobs are rejected. Browser-level visual validation remains pending.

To cancel through MCP, pass `cancel: {id, revision, confirm: true}`. Only one unfinished job is allowed per product. Execution phases are persisted before dispatch. New execution claims record the local process ID. Each worker tick moves an interrupted `applying` or `restoring` claim to `attention` only when the operating system confirms that process no longer exists. Recovery does not call Roblox or replay a write. Live processes, permission-denied probes, reused process IDs, and legacy claims without an owner remain unchanged; they require manual inspection. Keep the store local to one machine. Inspect the actual product and restoration requirement before resolution.

Attention cards in the control panel provide **Close if original price is present** and **Restore [original price] Robux now** actions. They call the same resolution service as MCP and HTTP, with the displayed revision. Executing jobs never receive these controls. Forms and job controls are disabled while a request is running; errors preserve form values and clear stale job cards. A successful resolution can still return `attention` after an uncertain write, which is shown with the persisted execution note.

For jobs in `attention`, use `roblox_resolve_discount` with `id`, `revision`, `action`, and `confirm: true`. `close` requires the original price to be observed and completes the job without writing. `restore` closes when the original price is already present or immediately restores a confirmed discount price. An unrelated current price is rejected. Resolution never takes over an executing claim. The worker distinguishes failed preflight reads from uncertain writes, rechecks the sale window after preflight, and prioritizes due restorations over new sales.

File changes use an exclusive short-lived mutation lock and atomic replacement. A crash during a store mutation can leave `.mutation-lock`; inspect the store and ensure no writer is running before removing a stale lock. Do not delete active job files to cancel a price change. History is capped at 1,000 jobs.

The local public API exposes POST `/v1/cloud/discounts/list`, `/schedule`, `/cancel`, and `/resolve`. All routes use the separate API bearer token. Listing accepts `{offset}` (default 0) and returns up to 50 jobs with `offset` and `total`. Scheduling accepts `{plan, confirm: true}`; cancellation accepts `{id, revision, confirm: true}`; resolution also requires `action: "close" | "restore"`. SDK methods are `client.discounts.list()`, `.schedule()`, `.cancel()`, and `.resolve()` with the same input shapes. No Studio connection is required. Scheduling persists work for the local bridge worker; it does not start a worker in the SDK or immediately change a price.

```ts
const { job } = await client.discounts.schedule({
  plan: {
    universeId: 123,
    productId: 456,
    originalPrice: 100,
    discountPrice: 50,
    startsAt: '2030-10-01T00:00:00Z',
    endsAt: '2030-10-02T00:00:00Z',
  },
  confirm: true,
});
const page = await client.discounts.list();
// Before it starts, use the latest listed revision to cancel:
await client.discounts.cancel({ id: job.id, revision: job.revision, confirm: true });
```

Invalid request schemas return HTTP 400. Job conflicts and operation failures return `DISCOUNT_ERROR` (409) with a sanitized message. After an error or client timeout, inspect the persisted list and product before retrying. Requests are not automatically retried; a lost response does not prove that scheduling or restoration failed.

Tests use temporary stores and simulated product adapters, including authenticated SDK-to-HTTP and control-panel route operations. Real timed execution, host sleep/restart behavior, external writer races, and fault injection during disk replacement still need release validation. Recurring jobs, hosted execution, notifications, and batch discounts remain outstanding.
