Acceptance Criteria: Instant Data-Repo → Main-Repo Sync
Feature ID: data-repo-instant-sync
Spec: ./spec.md
Status: Draft
Last updated: 2026-05-16
Core behaviour
AC-1: Webhook-driven sync (App installed)
- Editing
categories.ymlon the data repo and committing to default branch triggers a sync within 30 seconds (debounce window). - The main repo's
README.mdreflects the new category after the sync completes. - An activity row of type
data-sync.successappears withsource: webhook, correctbeforeSha/afterSha, andfilesChanged >= 1. - Five commits to the data repo within 10 seconds coalesce into a single sync run (one
.successrow,afterSha = HEAD of last commit).
AC-2: Poller-driven sync (App not installed)
- On a Work with
githubAppInstalled = falseandsyncIntervalMinutes = 5, editing a markdown file in the data repo via a manualgit pushresults in a sync within ≤ 5 minutes. - Activity row
data-sync.successrecordssource: poll. - If no commits are made for 24h, only 24
data-sync.skipped reason=no-changesrows appear (rate-limited to 1 per hour), not 288.
AC-3: Mutex with generation pipeline
- When a generation pipeline run is
RUNNING, a concurrent sync attempt emitsdata-sync.skipped reason=generation-in-progressand does NOT callMarkdownGeneratorService.syncFromDataRepo(). - After the generation pipeline completes, the next webhook delivery (Path A) or next poll tick (Path B) detects the SHA delta and successfully runs the deferred sync.
- When two sync runs are triggered within the same second, only one acquires the lock; the other emits
.skipped reason=sync-in-progress.
AC-4: Lock TTL recovery
- If
dataRepoSyncTaskcrashes with the lock held (simulated viakill -9), a new sync attempt 5 minutes later acquires the lock cleanly (no stuck Work). - The dispatcher (
WorkScheduleDispatcherService.dispatchDue) skips the locked Work and resumes scheduling for it once the lock expires.
Render correctness
AC-5: Render parity with full pipeline
- For a Work with no item changes (only category/tag changes), the resulting
README.mdproduced bysyncFromDataRepo()is byte-identical to the README produced by a fullWorkScheduleDispatcherTaskrun. -
details/<slug>.mdfiles are written exactly as they would be by the full pipeline. - No
items/regeneration occurs — verified by absence of AI provider calls in test logs.
AC-6: Idempotency
- Running
syncFromDataRepotwice in a row against the same data-repo SHA producesfilesChanged: 0on the second run. - The second run still emits an activity row (
.successwithfilesChanged: 0) so users can see the sync attempt happened.
Failure modes
AC-7: Data repo unreachable
- If the data repo returns 404 / 403 from GitHub, the sync emits
data-sync.failedwitherrorClass: 'data-repo-unreachable'anderrorTailcontaining the last 200 chars of stderr. - The lock is released; subsequent runs are not blocked.
- The Work's
lastSyncedDataRepoShais not updated.
AC-8: Main repo push rejected
- If the main repo push is rejected (branch protection / force-push refused / merge conflict from a parallel manual edit), the sync emits
data-sync.failedwitherrorClass: 'main-repo-push-rejected'. - On the next tick the sync retries; if the underlying conflict resolves naturally, the retry succeeds and emits
.success.
AC-9: Webhook signature invalid
- A
pushpayload with an invalidx-hub-signature-256returns 401 from the webhook controller, does not emit any activity row, and does not touch the lock or queue.
Force-sync endpoint
AC-10: POST /api/works/:id/sync
- Returns 202 with
{ activityRowId, status: 'enqueued' }for a healthy Work. - Returns 202 with
{ status: 'skipped', reason: 'generation-in-progress' }when the pipeline is RUNNING (no error, by design). - Returns 403 for an unauthenticated caller; 404 for an unknown Work id.
- Auth check rejects users without write access to the Work.
Activity feed UI
AC-11: Sync chip + rows
- The
Syncfilter chip appears on the activity page next toGenerate. - Clicking the chip filters to only
data-sync.*rows. -
data-sync.successrows show short SHAs (7-char prefix), files-changed count, and a clock duration. -
data-sync.failedrows render with an alert variant and an expandable disclosure showingerrorTail. -
data-sync.skippedrows render with a muted variant and the reason verbatim.
Telemetry
AC-12: Counters and histogram
- On a successful webhook-triggered sync,
data_sync_success_total{source="webhook"}increments by 1 anddata_sync_duration_msrecords a sample. - A skipped sync increments
data_sync_skipped_total{reason,source}with the correct labels. - A failed sync increments
data_sync_failed_total{errorClass,source}. - PostHog dashboard
Data Syncshows per-day totals, p50/p95 duration, and top error classes.
Feature-flag gating
AC-13: Both flags off (default at first deploy)
- With
subscriptions.dataSync.webhookEnabled = false:pushevents are accepted (200 OK) but do not enqueue. No activity rows. - With
subscriptions.dataSync.pollerEnabled = false: the poller task either does not run or short-circuits and emits no rows. - Toggling either flag to
trueenables the corresponding path without a redeploy (env var read on each task run).
Rollout / regression
AC-14: No regression to the scheduled pipeline
- The existing scheduled generation pipeline (
WorkScheduleDispatcherTask) continues to run end-to-end on its existing per-Work cadence. - Items, comparisons, taxonomy, and markdown rendering are byte-identical to the pre-feature output on a control Work that does not receive any data-repo edits during the test window.
AC-15: Migration safety
- Migration up + down + up cycle on a snapshot of staging data completes in < 60 seconds with no row-level errors.
- Backfill of
github_app_installed = truecovers every Work that has a non-nullgithub_app_installation_idand only those Works.
AC-16: Telemetry sampling sanity
- Over a 24-hour soak on
developwith 50+ active Works, total counter values are within ±2% of the activity-row counts (no drops, no double-counts).