diff --git a/README.md b/README.md
index 0bdb54d..075b068 100644
--- a/README.md
+++ b/README.md
@@ -62,9 +62,14 @@ repository milestone and due date; the dashboard validates both and sends them w
self-assignment in the single create request, so planned work appears in its release
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
account-bound title/description captures without selecting a repository or entering the
-mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
-the same Gitea login, **Choose repository** restores the capture to the normal planning
-and durable delivery flow. A different or unconfirmed account can only copy or discard
+mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
+reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
+description, blockers, and ordered screenshot evidence; another signed-in device can then use
+**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report
+whether they are synced or still pending locally. Concurrent changes surface a conflict, and
+successful discard or outbox admission propagates deletion so a stale device cannot resurrect the
+draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure
+never blocks local capture. A different or unconfirmed account can only copy or discard
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 204c7be..cc6b4c7 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -304,6 +304,7 @@
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
+
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
@@ -530,6 +531,9 @@
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
+ createUnfiledDraftSync(
+ unfiledCaptures, fetchReviewJson
+ );
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
@@ -2782,7 +2786,9 @@
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
- if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
+ if (item?.kind === 'unfiled-issue') {
+ await unfiledCaptures.discard(item.capture_id);
+ }
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
diff --git a/frontend/index.html b/frontend/index.html
index 473fdcd..57887ea 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1222,6 +1222,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 9d01b89..c8943c4 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -24,6 +24,7 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
+ BASE + 'static/unfiled-draft-sync.js',
BASE + 'static/shared-image-capture.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js
index a8136b5..da3ff34 100644
--- a/frontend/unfiled-captures.js
+++ b/frontend/unfiled-captures.js
@@ -8,6 +8,8 @@ function createUnfiledCaptures({
maxItems = 20,
}) {
const storageKey = 'stackchain.unfiled-issues.v1';
+ const listeners = new Set();
+ const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
function read() {
try {
@@ -94,6 +96,7 @@ function createUnfiledCaptures({
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
+ onChange(item.id, false);
return item;
};
const stage = prepared.hasAttachment
@@ -150,6 +153,7 @@ function createUnfiledCaptures({
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
+ onChange(id, true);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
@@ -159,6 +163,7 @@ function createUnfiledCaptures({
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
+ onChange(id, true);
return true;
})
);
@@ -191,7 +196,86 @@ function createUnfiledCaptures({
function completeResume(id) { return discard(id); }
- return {list, capacity, save, replaceOldest, discard, resume, completeResume};
+ function encodeBytes(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
+ }
+ return btoa(binary);
+ }
+
+ function decodeBytes(value, contentType) {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
+ return new Blob([bytes], {type:contentType});
+ }
+
+ async function exportOwned(login) {
+ const owner = String(login || '').trim();
+ const exported = [];
+ for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
+ let evidence = [];
+ if (item.hasAttachment) {
+ const stored = await attachmentStore?.get(item.id);
+ const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
+ (stored?.blob ? [stored] : []);
+ if (attachments.length !== Number(item.attachmentCount || 1)) {
+ throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
+ }
+ evidence = await Promise.all(attachments.map(async attachment => ({
+ filename:attachment.filename,
+ content_type:attachment.contentType,
+ ...(attachment.note ? {note:attachment.note} : {}),
+ data:encodeBytes(await attachment.blob.arrayBuffer()),
+ })));
+ }
+ exported.push({
+ id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
+ ...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
+ ...(evidence.length ? {evidence} : {}),
+ });
+ }
+ return exported;
+ }
+
+ async function mergeRemote(drafts, login) {
+ const ownerLogin = String(login || '').trim();
+ if (!ownerLogin || !Array.isArray(drafts)) return 0;
+ const existing = read();
+ const known = new Set(existing.map(item => item.id));
+ const imported = [];
+ for (const remote of drafts) {
+ if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
+ const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
+ if (evidence.length && !attachmentStore) continue;
+ const item = {
+ id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
+ savedAt:Number(remote.saved_at),
+ ...(Array.isArray(remote.blockers) && remote.blockers.length ? {
+ blockers:remote.blockers, blockerCount:remote.blockers.length,
+ } : {}),
+ ...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
+ };
+ if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
+ if (evidence.length) {
+ await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
+ filename:String(entry.filename), contentType:String(entry.content_type),
+ blob:decodeBytes(String(entry.data), String(entry.content_type)),
+ ...(entry.note ? {note:String(entry.note)} : {}),
+ }))});
+ }
+ known.add(item.id);
+ imported.push(item);
+ }
+ if (imported.length) write(imported.concat(existing));
+ return imported.length;
+ }
+
+ return {list, capacity, save, replaceOldest, discard, resume, completeResume,
+ exportOwned, mergeRemote, currentLogin:getCurrentLogin,
+ subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
diff --git a/frontend/unfiled-draft-sync.js b/frontend/unfiled-draft-sync.js
new file mode 100644
index 0000000..6e0dde2
--- /dev/null
+++ b/frontend/unfiled-draft-sync.js
@@ -0,0 +1,139 @@
+(function (root, factory) {
+ const createUnfiledDraftSync = factory();
+ if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
+ if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ function createUnfiledDraftSync(options, mountedFetch) {
+ if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
+ const {captures, fetchJson, storage, getLogin = () => '', onState = () => {}} = options;
+ const key = 'stackchain.unfiled-draft-sync.v1';
+ let inFlight = null;
+ let rerun = false;
+
+ function read(login) {
+ try {
+ const all = JSON.parse(storage?.getItem(key) || '{}');
+ const state = all?.[login];
+ return {
+ all:all && typeof all === 'object' ? all : {},
+ known:new Set(Array.isArray(state?.known) ? state.known : []),
+ deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
+ };
+ } catch (_error) {
+ return {all:{}, known:new Set(), deleted:new Set()};
+ }
+ }
+
+ function write(login, state) {
+ state.all[login] = {known:[...state.known], deleted:[...state.deleted]};
+ storage?.setItem(key, JSON.stringify(state.all));
+ }
+
+ function publish(status, message) {
+ onState({status, ...(message ? {message} : {})});
+ const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
+ if (message && visibleStatus) visibleStatus.textContent = message;
+ }
+
+ function sameDrafts(left, right) {
+ return JSON.stringify(left) === JSON.stringify(right);
+ }
+
+ async function runSync(login) {
+ const owner = String(login || '').trim();
+ if (!owner) return false;
+ publish('syncing', 'Syncing Drafts…');
+ const state = read(owner);
+ try {
+ const remote = await fetchJson('api/v1/unfiled-drafts');
+ const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
+ const remoteIds = new Set(remoteDrafts.map(item => item.id));
+ const before = await captures.exportOwned(owner);
+
+ for (const local of before) {
+ if (state.known.has(local.id) && !remoteIds.has(local.id) && !state.deleted.has(local.id)) {
+ await captures.discard(local.id);
+ }
+ }
+ await captures.mergeRemote(
+ remoteDrafts.filter(item => !state.deleted.has(item.id)), owner
+ );
+ const local = await captures.exportOwned(owner);
+ const merged = [];
+ const byId = new Map();
+ for (const item of remoteDrafts) if (!state.deleted.has(item.id)) byId.set(item.id, item);
+ for (const item of local) if (!state.deleted.has(item.id)) byId.set(item.id, item);
+ for (const item of byId.values()) merged.push(item);
+ merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
+
+ let finalSnapshot = remote;
+ if (!sameDrafts(merged, remoteDrafts)) {
+ finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
+ method:'PUT', headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
+ });
+ }
+ state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
+ state.deleted.clear();
+ write(owner, state);
+ publish('ready', 'Drafts synced across devices.');
+ return true;
+ } catch (error) {
+ if (error?.status === 409) {
+ publish('conflict', 'Drafts changed on another device. Sync again to combine them.');
+ } else {
+ publish('pending', 'Saved on this device · sync pending');
+ }
+ throw error;
+ }
+ }
+
+ function sync(login) {
+ const owner = String(login || '').trim();
+ if (!owner) return Promise.resolve(false);
+ if (inFlight) {
+ rerun = true;
+ return inFlight;
+ }
+ inFlight = (async () => {
+ let result = false;
+ do {
+ rerun = false;
+ result = await runSync(owner);
+ } while (rerun);
+ return result;
+ })().finally(() => { inFlight = null; });
+ return inFlight;
+ }
+
+ async function remove(id, login) {
+ const owner = String(login || '').trim();
+ if (!owner) return false;
+ const state = read(owner);
+ state.deleted.add(String(id));
+ write(owner, state);
+ await captures.discard(String(id));
+ return sync(owner);
+ }
+
+ captures.subscribe?.((id, removed) => {
+ const login = getLogin();
+ if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
+ });
+
+ return {sync, remove};
+ }
+ createUnfiledDraftSync.mount = (captures, fetchJson) => {
+ const getLogin = captures.currentLogin;
+ const controller = createUnfiledDraftSync({captures, fetchJson, storage:globalThis.localStorage, getLogin});
+ const connect = () => {
+ const login = getLogin();
+ if (login) void controller.sync(login).catch(() => {});
+ else globalThis.setTimeout(connect, 250);
+ };
+ globalThis.setTimeout(connect, 0);
+ globalThis.addEventListener?.('online', connect);
+ return controller;
+ };
+ return createUnfiledDraftSync;
+});
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index b1f0fb0..44b64c6 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -31,7 +31,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
- "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
+ "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
diff --git a/src/main.py b/src/main.py
index a7d314f..ab6fde9 100644
--- a/src/main.py
+++ b/src/main.py
@@ -58,6 +58,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
from src.push_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
+from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute
from src.later_store import LaterStore
@@ -632,6 +633,33 @@ class SavedSearchCollection(BaseModel):
views: list[SavedSearchView] = Field(max_length=20)
+class UnfiledDraftBlocker(BaseModel):
+ repository: str = Field(min_length=3, max_length=200)
+ number: int = Field(ge=1)
+ title: str = Field(default="", max_length=255)
+
+
+class UnfiledDraftEvidence(BaseModel):
+ filename: str = Field(min_length=1, max_length=255)
+ content_type: Literal["image/png", "image/jpeg", "image/webp"]
+ note: str = Field(default="", max_length=240)
+ data: str = Field(max_length=14_000_000)
+
+
+class UnfiledDraft(BaseModel):
+ id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
+ title: str = Field(min_length=1, max_length=255)
+ body: str = Field(default="", max_length=10_000)
+ saved_at: int = Field(ge=0)
+ blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
+ evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
+
+
+class UnfiledDraftCollection(BaseModel):
+ revision: int = Field(ge=0)
+ drafts: list[UnfiledDraft] = Field(max_length=20)
+
+
class NotificationLaterRequest(BaseModel):
wake_at: str = Field(min_length=1, max_length=100)
@@ -2138,6 +2166,12 @@ def _saved_search_store() -> SavedSearchStore:
)
+def _unfiled_draft_store() -> UnfiledDraftStore:
+ return UnfiledDraftStore(
+ os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
+ )
+
+
async def _confirmed_login() -> str:
try:
user = await asyncio.wait_for(
@@ -2196,6 +2230,49 @@ async def replace_saved_searches(payload: SavedSearchCollection):
)
+@app.get("/api/v1/unfiled-drafts")
+async def get_unfiled_drafts(response: Response):
+ login = await _confirmed_login()
+ try:
+ snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
+ except (OSError, sqlite3.Error):
+ raise HTTPException(
+ status_code=503,
+ detail="Draft synchronization is unavailable",
+ headers={"Retry-After": "1"},
+ )
+ response.headers["Cache-Control"] = "no-store"
+ return snapshot
+
+
+@app.put("/api/v1/unfiled-drafts")
+async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(
+ _unfiled_draft_store().replace,
+ login,
+ payload.revision,
+ [draft.model_dump() for draft in payload.drafts],
+ )
+ except UnfiledDraftConflict as exc:
+ raise HTTPException(
+ status_code=409,
+ detail={
+ "message": "Drafts changed on another device.",
+ "snapshot": exc.snapshot,
+ },
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc))
+ except (OSError, sqlite3.Error):
+ raise HTTPException(
+ status_code=503,
+ detail="Draft synchronization is unavailable",
+ headers={"Retry-After": "1"},
+ )
+
+
@app.get("/api/v1/today")
async def get_today_plan():
login = await _confirmed_login()
diff --git a/src/request_boundary.py b/src/request_boundary.py
index 0914555..06ecc50 100644
--- a/src/request_boundary.py
+++ b/src/request_boundary.py
@@ -7,6 +7,7 @@ SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
+UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
@@ -15,6 +16,8 @@ def request_body_limit(method: str, path: str) -> int | None:
normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT
+ if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
+ return UNFILED_DRAFT_SYNC_BODY_LIMIT
if (
normalized_method == "POST"
and (
diff --git a/src/unfiled_draft_store.py b/src/unfiled_draft_store.py
new file mode 100644
index 0000000..1968af1
--- /dev/null
+++ b/src/unfiled_draft_store.py
@@ -0,0 +1,177 @@
+"""Durable, account-scoped unfiled issue drafts and ordered evidence."""
+
+import base64
+import binascii
+import json
+import re
+import sqlite3
+from pathlib import Path
+
+
+_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
+_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
+_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
+
+
+class UnfiledDraftConflict(ValueError):
+ """Raised when a client attempts to replace a stale draft collection."""
+
+ def __init__(self, snapshot: dict):
+ super().__init__("unfiled drafts changed on another device")
+ self.snapshot = snapshot
+
+
+class UnfiledDraftStore:
+ def __init__(
+ self,
+ path: str | Path,
+ *,
+ limit: int = 20,
+ max_total_bytes: int = 12 * 1024 * 1024,
+ timeout: float = 1.0,
+ ):
+ self.path = Path(path)
+ self.limit = limit
+ self.max_total_bytes = max_total_bytes
+ self.timeout = timeout
+ self._initialize()
+
+ def _initialize(self) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ with sqlite3.connect(self.path, timeout=self.timeout) as connection:
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.execute(
+ """CREATE TABLE IF NOT EXISTS unfiled_drafts (
+ login TEXT PRIMARY KEY,
+ revision INTEGER NOT NULL,
+ drafts TEXT NOT NULL
+ )"""
+ )
+
+ def _connect(self) -> sqlite3.Connection:
+ return sqlite3.connect(self.path, timeout=self.timeout)
+
+ @staticmethod
+ def _login(login: str) -> str:
+ normalized = login.strip().lower()
+ if not normalized:
+ raise ValueError("login is required")
+ return normalized
+
+ @staticmethod
+ def _snapshot(row) -> dict:
+ return {"revision": 0, "drafts": []} if row is None else {
+ "revision": int(row[0]), "drafts": json.loads(row[1])
+ }
+
+ def get(self, login: str) -> dict:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
+ (self._login(login),),
+ ).fetchone()
+ return self._snapshot(row)
+
+ def _normalize(self, drafts: list[dict]) -> list[dict]:
+ if not isinstance(drafts, list):
+ raise ValueError("drafts must be a list")
+ if len(drafts) > self.limit:
+ raise ValueError(f"unfiled drafts are limited to {self.limit}")
+ normalized = []
+ seen = set()
+ decoded_total = 0
+ for raw in drafts:
+ if not isinstance(raw, dict):
+ raise ValueError("draft must be an object")
+ draft_id = raw.get("id")
+ if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id):
+ raise ValueError("draft id is invalid")
+ if draft_id in seen:
+ raise ValueError("draft ids must be unique")
+ title = raw.get("title")
+ body = raw.get("body", "")
+ saved_at = raw.get("saved_at")
+ if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
+ raise ValueError("title is invalid")
+ if not isinstance(body, str) or len(body) > 10_000:
+ raise ValueError("body is invalid")
+ if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0:
+ raise ValueError("saved_at is invalid")
+ blockers = raw.get("blockers", [])
+ if not isinstance(blockers, list) or len(blockers) > 5:
+ raise ValueError("blockers are invalid")
+ clean_blockers = []
+ for blocker in blockers:
+ repository = blocker.get("repository") if isinstance(blocker, dict) else None
+ number = blocker.get("number") if isinstance(blocker, dict) else None
+ blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else ""
+ if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
+ raise ValueError("blocker repository is invalid")
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
+ raise ValueError("blocker number is invalid")
+ if not isinstance(blocker_title, str) or len(blocker_title) > 255:
+ raise ValueError("blocker title is invalid")
+ clean_blockers.append({"repository": repository, "number": number, "title": blocker_title})
+ evidence = raw.get("evidence", [])
+ if not isinstance(evidence, list) or len(evidence) > 5:
+ raise ValueError("evidence is invalid")
+ clean_evidence = []
+ for item in evidence:
+ if not isinstance(item, dict):
+ raise ValueError("evidence is invalid")
+ filename = item.get("filename")
+ content_type = item.get("content_type")
+ note = item.get("note", "")
+ data = item.get("data")
+ if not isinstance(filename, str) or not filename or len(filename) > 255:
+ raise ValueError("evidence filename is invalid")
+ if content_type not in _CONTENT_TYPES:
+ raise ValueError("evidence content type is invalid")
+ if not isinstance(note, str) or len(note) > 240:
+ raise ValueError("evidence note is invalid")
+ if not isinstance(data, str):
+ raise ValueError("evidence data is invalid")
+ try:
+ decoded_total += len(base64.b64decode(data, validate=True))
+ except (binascii.Error, ValueError) as error:
+ raise ValueError("evidence data must be valid base64") from error
+ if decoded_total > self.max_total_bytes:
+ raise ValueError("synchronized evidence is too large")
+ clean_evidence.append({
+ "filename": filename,
+ "content_type": content_type,
+ **({"note": note} if note else {}),
+ "data": data,
+ })
+ seen.add(draft_id)
+ normalized.append({
+ "id": draft_id,
+ "title": title.strip(),
+ "body": body,
+ "saved_at": saved_at,
+ **({"blockers": clean_blockers} if clean_blockers else {}),
+ **({"evidence": clean_evidence} if clean_evidence else {}),
+ })
+ return normalized
+
+ def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict:
+ login = self._login(login)
+ if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
+ raise ValueError("revision is invalid")
+ normalized = self._normalize(drafts)
+ serialized = json.dumps(normalized, separators=(",", ":"))
+ with self._connect() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ row = connection.execute(
+ "SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
+ ).fetchone()
+ current = self._snapshot(row)
+ if current["revision"] != expected_revision:
+ raise UnfiledDraftConflict(current)
+ revision = expected_revision + 1
+ connection.execute(
+ "INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
+ "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
+ (login, revision, serialized),
+ )
+ return {"revision": revision, "drafts": normalized}
diff --git a/tests/test_request_boundary.py b/tests/test_request_boundary.py
index d2a3d7c..7e710f3 100644
--- a/tests/test_request_boundary.py
+++ b/tests/test_request_boundary.py
@@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
== 64 * 1024
)
assert main.request_body_limit("GET", "/api/v1/context") is None
+ assert main.request_body_limit("PUT", "/api/v1/unfiled-drafts") == 17 * 1024 * 1024
assert (
main.request_body_limit(
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 6c3f4f8..a3ba7d5 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -797,6 +797,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
+ "/dashboard/static/unfiled-draft-sync.js",
"/dashboard/static/shared-image-capture.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py
index de27cdd..8f33fb9 100644
--- a/tests/test_unfiled_captures.py
+++ b/tests/test_unfiled_captures.py
@@ -104,6 +104,47 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
assert output["stored"] == output["notes"]
+def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
+ script = f"""
+const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
+function device() {{
+ const values=new Map(), blobs=new Map();
+ return {{
+ captures:createUnfiledCaptures({{
+ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}},
+ attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}},
+ getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42,
+ }}), blobs,
+ }};
+}}
+(async()=>{{
+ const phone=device(), desktop=device();
+ const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}});
+ await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}});
+ const exported=await phone.captures.exportOwned('timmy');
+ const imported=await desktop.captures.mergeRemote(exported,'timmy');
+ const resumed=await desktop.captures.resume('phone-draft','timmy');
+ process.stdout.write(JSON.stringify({{
+ exported:exported[0], imported,
+ names:resumed.attachments.map(item=>item.filename),
+ notes:resumed.attachments.map(item=>item.note),
+ bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())),
+ blockers:resumed.blockers,
+ }}));
+}})().catch(error=>{{console.error(error);process.exit(1)}});
+"""
+ output = run_node(script)
+
+ assert output["exported"]["id"] == "phone-draft"
+ assert output["exported"]["saved_at"] == 42
+ assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"]
+ assert output["imported"] == 1
+ assert output["names"] == ["one.png", "two.png"]
+ assert output["notes"] == ["First", "Second"]
+ assert output["bytes"] == ["one.png", "two.png"]
+ assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
+
+
def test_unfiled_capture_restores_selected_blockers():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
diff --git a/tests/test_unfiled_draft_integration.py b/tests/test_unfiled_draft_integration.py
new file mode 100644
index 0000000..d119c1e
--- /dev/null
+++ b/tests/test_unfiled_draft_integration.py
@@ -0,0 +1,21 @@
+from pathlib import Path
+
+from src.frontend_bundle import FEATURE_SOURCES, build_frontend
+
+
+ROOT = Path(__file__).parents[1]
+
+
+def test_cross_device_unfiled_draft_sync_is_shipped_and_wired_to_user_flow():
+ html = (ROOT / "frontend" / "index.html").read_text()
+ dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
+
+ assert '' in html
+ assert "static/unfiled-draft-sync.js" in FEATURE_SOURCES["today-timer"]
+ assert "createUnfiledDraftSync(" in dashboard
+ assert "unfiledCaptures, fetchReviewJson" in dashboard
+ assert "Saved on this device · sync pending" in (ROOT / "frontend" / "unfiled-draft-sync.js").read_text()
+
+ built = build_frontend(ROOT / "frontend")
+ assert "unfiled-draft-sync.js" not in built.dashboard_html
+ assert b"stackchain.unfiled-draft-sync.v1" in built.feature_bundles["today-timer"].runtime_bytes
diff --git a/tests/test_unfiled_draft_store.py b/tests/test_unfiled_draft_store.py
new file mode 100644
index 0000000..bf2a840
--- /dev/null
+++ b/tests/test_unfiled_draft_store.py
@@ -0,0 +1,92 @@
+import httpx
+import pytest
+
+from src import main
+from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
+
+
+def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
+ return {
+ "id": draft_id,
+ "title": title,
+ "body": "Steps from the field",
+ "saved_at": 1_723_600_000_000,
+ "blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}],
+ "evidence": (evidence if evidence is not None else [
+ {
+ "filename": "checkout.png",
+ "content_type": "image/png",
+ "note": "Error after tapping Pay",
+ "data": "cG5nLWJ5dGVz",
+ }
+ ]),
+ }
+
+
+def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
+ store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
+
+ created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
+
+ assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]}
+ assert store.get("timmy") == created
+ assert store.get("alexander") == {"revision": 0, "drafts": []}
+ with pytest.raises(UnfiledDraftConflict) as conflict:
+ store.replace("timmy", 0, [draft(title="Stale overwrite")])
+ assert conflict.value.snapshot == created
+
+
+def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
+ store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
+
+ with pytest.raises(ValueError, match="limited to 2"):
+ store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
+ with pytest.raises(ValueError, match="evidence is too large"):
+ store.replace("timmy", 0, [draft(evidence=[{
+ "filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=",
+ }])])
+ with pytest.raises(ValueError, match="valid base64"):
+ store.replace("timmy", 0, [draft(evidence=[{
+ "filename": "bad.png", "content_type": "image/png", "data": "not base64!",
+ }])])
+
+
+@pytest.mark.anyio
+async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
+ monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
+ monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
+ monkeypatch.setenv(
+ "STACKCHAIN_DASHBOARD_SESSION_SECRET",
+ "a-separate-session-signing-secret-with-enough-entropy",
+ )
+ monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
+ monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
+ monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3"))
+ identity = {"login": "Timmy"}
+
+ async def user():
+ return {"id": 1, "login": identity["login"]}
+
+ monkeypatch.setattr(main, "current_user", user)
+ transport = httpx.ASGITransport(app=main.app)
+ payload = {"revision": 0, "drafts": [draft(evidence=[])]}
+ async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
+ await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
+ forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
+ headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
+ saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
+ stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
+ fetched = await client.get("/api/v1/unfiled-drafts")
+ identity["login"] = "Alexander"
+ isolated = await client.get("/api/v1/unfiled-drafts")
+
+ assert forbidden.status_code == 403
+ assert saved.status_code == 200
+ expected = draft(evidence=[])
+ expected.pop("evidence")
+ assert saved.json() == {"revision": 1, "drafts": [expected]}
+ assert stale.status_code == 409
+ assert stale.json()["detail"]["snapshot"] == saved.json()
+ assert fetched.json() == saved.json()
+ assert fetched.headers["cache-control"] == "no-store"
+ assert isolated.json() == {"revision": 0, "drafts": []}
diff --git a/tests/test_unfiled_draft_sync.py b/tests/test_unfiled_draft_sync.py
new file mode 100644
index 0000000..876bf83
--- /dev/null
+++ b/tests/test_unfiled_draft_sync.py
@@ -0,0 +1,104 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js"
+
+
+def run_node(script: str):
+ result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
+ return json.loads(result.stdout)
+
+
+def test_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete():
+ script = f"""
+const createSync=require({json.dumps(str(SYNC))});
+let remote={{revision:0,drafts:[]}};
+function api(url,init={{}}) {{
+ if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote)));
+ const body=JSON.parse(init.body);
+ if (body.revision!==remote.revision) {{
+ const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error);
+ }}
+ remote={{revision:remote.revision+1,drafts:body.drafts}};
+ return Promise.resolve(JSON.parse(JSON.stringify(remote)));
+}}
+function device(initial=[]) {{
+ let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[];
+ const captures={{
+ exportOwned:async()=>drafts.map(item=>({{...item}})),
+ mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}},
+ discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
+ }};
+ const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
+ const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}});
+ return {{sync,states,drafts:()=>drafts}};
+}}
+(async()=>{{
+ const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]);
+ await phone.sync.sync('timmy');
+ const desktop=device();
+ await desktop.sync.sync('timmy');
+ await desktop.sync.remove('field','timmy');
+ await phone.sync.sync('timmy');
+ process.stdout.write(JSON.stringify({{
+ remote,desktop:desktop.drafts(),phone:phone.drafts(),
+ phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status),
+ }}));
+}})().catch(error=>{{console.error(error);process.exit(1)}});
+"""
+ output = run_node(script)
+
+ assert output["remote"]["revision"] == 2
+ assert output["remote"]["drafts"] == []
+ assert output["desktop"] == []
+ assert output["phone"] == []
+ assert output["phoneStates"][-1] == "ready"
+ assert output["desktopStates"][-1] == "ready"
+
+
+def test_unfiled_draft_sync_surfaces_revision_conflict_without_losing_local_draft():
+ script = f"""
+const createSync=require({json.dumps(str(SYNC))});
+let calls=0;const states=[];let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}];
+const conflict={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}};
+const sync=createSync({{
+ captures:{{exportOwned:async()=>drafts,mergeRemote:async()=>0,discard:async()=>true}},
+ storage:{{getItem:()=>null,setItem:()=>{{}}}},
+ fetchJson:async(_url,init)=>{{calls++;if(!init)return {{revision:1,drafts:[]}};const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:conflict}}}};throw error}},
+ onState:state=>states.push(state),
+}});
+(async()=>{{let message='';try{{await sync.sync('timmy')}}catch(error){{message=error.message}}process.stdout.write(JSON.stringify({{calls,states,message,drafts}}))}})();
+"""
+ output = run_node(script)
+
+ assert output["calls"] == 2
+ assert output["states"][-1]["status"] == "conflict"
+ assert output["states"][-1]["message"] == "Drafts changed on another device. Sync again to combine them."
+ assert output["drafts"][0]["id"] == "local"
+
+
+def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict():
+ script = f"""
+const createSync=require({json.dumps(str(SYNC))});
+let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release;
+const gate=new Promise(resolve=>release=resolve);
+const api=async(_url,init)=>{{
+ if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}}
+ puts++;const body=JSON.parse(init.body);
+ if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}}
+ remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
+}};
+const draft={{id:'local',title:'Local',body:'',saved_at:1}};
+const sync=createSync({{
+ captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}},
+ storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api,
+}});
+(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})();
+"""
+ output = run_node(script)
+
+ assert output["puts"] == 1
+ assert output["remote"]["drafts"][0]["id"] == "local"
+ assert output["results"] == [True, True]