anvil sync providers¶
Why a Protocol¶
anvil needs to talk to many task-tracker backends — GitHub Issues,
GitHub Projects, Linear, Monday, Jira, internal trackers — without
re-implementing the state engine, reconciliation, CLI, or audit log for
each one. The SyncProvider Protocol is the single shape every backend
implements; the engine targets the abstraction, not any one vendor.
This document is for contributors adding a new provider. End-user
documentation for the bundled GitHub Issues provider lives in
github-sync.md.
The SyncProvider Protocol¶
Defined in bin/src/anvil/sync/provider.py. Use
typing.Protocol — runtime structural typing, no inheritance required.
from typing import Protocol
class SyncProvider(Protocol):
provider_id: str # registry key, snake_case
display_name: str # human-facing name for CLI output
def push_task(
self, *, task: Task, mapping: ExternalRef | None,
) -> ExternalRef: ...
def fetch_task(self, *, external_id: str) -> ExternalTask | None: ...
def list_tasks(self) -> list[ExternalTask]: ...
def delete_task(self, *, external_id: str) -> None: ...
def health_check(self) -> ProviderHealth: ...
Method contracts¶
| Method | Contract |
|---|---|
push_task |
Create when mapping is None, update when not. Return an ExternalRef pointing at the persisted record. |
fetch_task |
Return the current remote payload as ExternalTask, or None if the record no longer exists (tombstone). |
list_tasks |
Return every remote record in scope. Implementations handle pagination transparently. |
delete_task |
Make the record no longer present in list_tasks output. Treat 404 / already-absent as success. |
health_check |
Probe reachability + auth. MUST NOT raise — return a ProviderHealth with available=False instead. |
Discipline¶
- Keyword-only after
self. Same asLLMProvider. Positional args break the moment a new optional kwarg is added; every contributor re-types these signatures, so the boundary noise is worth it. - Single exception type to catch. Every failure path MUST wrap the
underlying error in
SyncProviderError(or a subclass) viaraise SyncProviderError(...) from exc. Callersexcept SyncProviderErroronce and recover the original viaexc.__cause__. extra="forbid"on every Pydantic model. Silent field drift is the abstraction's worst failure mode; catch it at the boundary, not in storage.- Idempotency. Sync is a polling loop. Calling
push_tasktwice with the same(task, mapping)should land the same remote state both times.
The supporting models¶
All three live in sync/provider.py and use ConfigDict(extra="forbid").
ExternalRef¶
Minimal pointer to a remote record. Stored on the SyncMapping row.
| Field | Type | Notes |
|---|---|---|
provider_id |
str |
Registry key (snake_case). Must match a key in PROVIDER_REGISTRY. |
external_id |
str |
Provider-native id, always stringified (e.g. "42", "ENG-123"). |
url |
str \| None |
Optional human-facing URL. Not load-bearing. |
ExternalTask¶
Full payload returned by fetch_task / list_tasks.
| Field | Type | Notes |
|---|---|---|
external_id |
str |
Same shape as ExternalRef.external_id. |
title |
str |
Empty string allowed; None is not. |
body |
str |
Markdown-flavoured for every current target. Defaults to "". |
status_label |
str \| None |
Provider-native status (e.g. "open", "In Progress"). Mapping to TaskStatus is provider work. |
url |
str \| None |
Human-facing URL. |
last_modified |
datetime (tz-aware) |
Drives conflict detection. Naive datetimes are rejected at the validator. |
provider_metadata |
dict[str, Any] |
Provider-specific extension blob (labels, assignees, custom fields). See best practice below. |
provider_metadata is the extension point for fields that don't fit a
generic abstraction. GitHub puts {"labels": [...], "assignees": [...],
"issue_number": ..., "issue_node_id": ...} here; Jira would put
{"watchers": [...], "reporter": ...}; Monday puts people-column shapes.
The reconciliation engine treats this dict as opaque — only the
originating provider knows the shape.
ProviderHealth¶
Returned by health_check. Never raise; encode failure as fields.
| Field | Type | Notes |
|---|---|---|
available |
bool |
Upstream reachable. Independent of auth. |
auth_configured |
bool |
Valid credentials present. |
last_check_at |
datetime (UTC) |
When this snapshot was taken. |
error |
str \| None |
Human-readable explanation. Surfaced verbatim by the CLI; keep short. |
Registry mechanics¶
Defined in sync/registry.py.
from anvil.sync.registry import register_sync_provider
register_sync_provider("linear_issues", LinearIssuesProvider)
register_sync_provider raises ValueError on empty provider_id and
on duplicate registration (silent overwrite is how plugins shadow each
other in production — refuse it).
Auto-registration via side-effect import¶
The canonical pattern: every provider module calls
register_sync_provider(...) at module scope (bottom of file, after the
class is bound), and the package __init__.py imports each provider
submodule so registrations fire on package load.
See sync/providers/github_issues.py:651 for the registration call and
sync/providers/__init__.py for the side-effect import.
Step-by-step: add Linear support¶
bin/src/anvil/sync/providers/linear.py # new provider class
bin/src/anvil/sync/clients/linear_api.py # API client (GraphQL)
tests/test_linear_provider.py # respx-based tests
1. Write the API client¶
sync/clients/linear_api.py wraps the GraphQL endpoint via httpx.
Keep it transport-only: no Task ↔ Linear-issue translation, no
anvil types. Each method returns a raw dict and raises
SyncProviderError on transport failure.
2. Write the provider¶
sync/providers/linear.py consumes the client and exposes the Protocol:
from anvil.sync.provider import ExternalRef, ExternalTask, ProviderHealth
from anvil.sync.registry import register_sync_provider
class LinearIssuesProvider:
provider_id: str = "linear_issues"
display_name: str = "Linear"
def __init__(self, *, team_id: str | None = None) -> None:
...
def push_task(self, *, task, mapping): ...
def fetch_task(self, *, external_id): ...
def list_tasks(self): ...
def delete_task(self, *, external_id): ...
def health_check(self): ...
register_sync_provider(LinearIssuesProvider.provider_id, LinearIssuesProvider)
3. Register on package load¶
Add to sync/providers/__init__.py:
from anvil.sync.providers import linear # noqa: F401
4. Test with respx + RecordedSyncProvider¶
tests/test_linear_provider.py uses respx to mock the HTTP layer for
provider-specific tests; CLI / engine tests that consume any provider
use RecordedSyncProvider instead (see Testing pattern below).
After landing those four files, anvil sync provider
linear_issues works end-to-end with no changes to the CLI, engine, or
reconciliation code.
Status label mapping¶
Every provider must map anvil's 11 TaskStatus values to whatever
the remote system uses. Centralise the mapping in a module-level
STATUS_TO_LABEL: dict[TaskStatus, str] and a reverse LABEL_TO_STATUS
so push and fetch agree verbatim.
The GitHub Issues provider is the canonical reference implementation —
see STATUS_TO_LABEL in sync/providers/github_issues.py:67 for the
source, or github-sync.md → Status label mapping
for the rendered table (all 11 statuses, their GitHub label, and open/closed
state).
Where the provider has a separate open/closed bit (GitHub, Jira), keep
a DONE_STATUSES: frozenset[TaskStatus] so closure semantics live next
to the label mapping.
Testing pattern¶
Two complementary doubles:
| Test target | Use |
|---|---|
| CLI / state-engine code that consumes a provider | RecordedSyncProvider (sync/recorded.py). Hash-keyed canned responses. |
| Provider-specific HTTP / subprocess paths | respx for httpx clients; subprocess.run patches for CLI subprocesses. |
RecordedSyncProvider¶
from anvil.sync.recorded import RecordedSyncProvider
from anvil.sync.provider import ExternalRef
key = RecordedSyncProvider.record_key(
"push_task", task=task, mapping=None,
)
provider = RecordedSyncProvider(
provider_id="linear_issues",
display_name="Linear (test)",
recordings={
key: ExternalRef(
provider_id="linear_issues", external_id="ENG-123", url=None,
),
},
)
On a key miss the provider raises SyncProviderError — accidental
"this test secretly called the real API" failures are loud, not silent.
No live API calls¶
Live HTTP / GraphQL calls outside @pytest.mark.live_github (or its
provider equivalent) are forbidden. The default test run
(uv run --project bin pytest) excludes live markers. No live-test CI workflow
is currently committed; run the explicitly authorized local procedure in
live-tests.md when upstream-contract verification is needed.
Per-provider configuration¶
The sync CLI iterates configured providers — by default every provider
in PROVIDER_REGISTRY, optionally narrowed to an explicit subset via
config.yaml. The sync.providers top-level config key lets projects opt
into a deliberate subset (or out of every provider) without deregistering
modules.
Schema¶
# .anvil/config.yaml — fragment
sync:
providers:
- github_issues
# - linear_issues # contributor-registered providers also accepted
# - monday_boards
Three-way semantics¶
The presence-vs-absence-vs-empty-list distinction is load-bearing:
| YAML form | Config.sync_providers |
Caller behaviour |
|---|---|---|
| key absent | None |
Fall back to sorted(PROVIDER_REGISTRY) (the default when no config is present). |
sync.providers: [a, b] |
("a", "b") |
Use the explicit list, in order. |
sync.providers: [] |
() (NOT None) |
Opt out of every provider — sync is a no-op. |
The () vs None distinction matters: a frozen project that wants to
suppress sync drift entirely needs [] (an explicit empty list); a
project that simply has not bothered to configure providers should still
scan everything registered. The Config.sync_providers: tuple[str, ...]
| None field pins both behaviours; tests cover both.
Fallback safety¶
Lookup happens in _resolve_configured_providers in cli/sync.py —
the single seam. A malformed config (unparseable YAML, type errors)
falls back to sorted(PROVIDER_REGISTRY) rather than breaking
anvil sync entirely. Loud config errors are the job of
anvil init / doctor, not the sync surface.
Init template¶
The anvil init config template does NOT include sync.providers
— it is opt-in. Add it manually when you want to narrow or opt out.
Reconciliation interaction¶
The bare anvil sync (reconciliation only) consumes the same
configured-providers list. When the list is the registry fallback,
reconciliation emits one missing_sync_mapping discrepancy per missing
provider per done task. When the list is explicit, only the listed
providers contribute discrepancy rows — the "frozen project" mode above
silences missing_sync_mapping entirely.
provider_id naming¶
Snake_case. Always. Examples:
github_issuesgithub_projectslinear_issuesmonday_boardsjira_issues
The ExternalSystem enum and the sync_mappings.external_system DB
column take snake_case values. Kebab-case (github-issues) registers
and looks up fine in the registry dict but fails at storage time with
confusing reconciliation errors downstream. The Wave 1 critic flagged
this twice; the registry's docstring repeats the warning.
provider_metadata best practice¶
Goes on top-level ExternalTask fields |
Goes in provider_metadata dict |
|---|---|
title, body, status_label, url |
Labels (provider-shaped list) |
last_modified |
Assignees (provider-shaped list) |
external_id |
Custom fields, watchers, reporter |
| Internal IDs (issue node id, etc.) | |
| Provider-specific timestamps |
Rule of thumb: if the reconciliation engine needs to read the field to
make a decision (title diff, body diff, status comparison, recency
check), it belongs on the top-level model. If the field is only useful
back inside the originating provider (round-trip preservation, future
push enrichment), it belongs in the opaque dict. The dict is mirrored
to SyncMapping.provider_metadata so a fetch-then-persist round trip
is lossless.
Error mapping¶
sync/errors.py defines the hierarchy. Wrap every underlying failure
with raise ... from exc.
| Upstream condition | Wrap as |
|---|---|
| HTTP 401, 403, missing/expired token | AuthenticationFailed |
| HTTP 429, primary/secondary rate-limit | RateLimitExceeded |
| HTTP 5xx, network error, DNS failure | ProviderUnavailable |
| Local↔remote diverged, strategy can't reconcile | SyncConflict |
| Any other provider failure | SyncProviderError |
The CLI's batch loops catch the base SyncProviderError and continue
with the next task; the narrower subclasses exist for callers that want
to print a different message per failure mode (auth vs rate-limit vs
transient network).
See also¶
github-sync.md— end-user reference for the bundled GitHub Issues provider.sync/provider.py— the Protocol definition with full docstrings.sync/recorded.py—RecordedSyncProvidertest double.sync/registry.py— registry mechanics + duplicate-registration guard.sync/errors.py— exception hierarchy.specs/2026-05-24-anvil-v0.md— historical v0 design record; this page is the current provider reference.