Manual 0.8.0

Writing a connector

Who this is for: anyone reading a source this engine ships no connector for — a tracker, a doc store, an internal API. You need: a running instance (revetly doctor passing), Python, and read credentials for the source; no engine checkout and no image rebuild. Not here: writing back to that source (writing-a-write-adapter.md), the field-by-field envelope (connector-contract.md), and what a backfill costs (adopting-history.md).

A connector reads one source and hands Revetly artifacts. It never writes to that source, and it never talks to the graph: identity derivation, the manifest hash gate, episode send, the deterministic linker passes, tombstoning and stamping are engine-owned and identical for every source. A connector's whole job is to map and to declare — which is what keeps it small.

Nothing here requires editing the engine.

The other half of a backend — landing an approved story in that source — is a write adapter, and it is writing-a-write-adapter.md. Same three doors, same context, same kind of kit; what differs is that an adapter is selected singly rather than listed in order, has its own [backend.<name>] settings, and fails in front of a human at the gate. The two halves stay independent: enabling a connector grants no write access, and installing an adapter triggers no read.

The field-level halves of this page are generated from the code and live in connector-contract.md: the artifact envelope, which fields are required, the provenance and content-format vocabularies, and every connector this instance runs with what it reads and what each field mints. Run revetly check --contract to regenerate it.


1. How your connector arrives

Three doors. Every connector the engine ships comes through the first one, so there is no privileged in-tree path beside the one you use.

A worked example of all of it: src/revetly/connectors/todoist.py — a credential, a declared scope, cursor paging, an incomplete view, and a source that hides its completed work. It arrives through the first door below like anyone else's.

An installed distribution. Declare an entry point in the revetly.connectors group:

# your package's pyproject.toml
[project.entry-points."revetly.connectors"]
todoist = "revetly_todoist:build"

Install it into the same environment as the engine, and the name is declarable. Nothing is imported until an instance enables it, so an installed connector nobody uses costs nothing.

A module inside the instance. No packaging, no image rebuild — the instance is mounted, so the file travels with it. revetly connectors new acme writes both halves of this and a connector that already passes the kit (section 8); by hand it is:

# the instance's revetly.toml
[connectors]
enabled = ["pm", "docs", "acme"]

[connectors.acme]
module = "connectors/acme.py"   # relative to the project root
factory = "build"               # optional; "build" is the default

register_connector(name, factory), for a process that embeds the engine and for tests. It wins over both of the above.

Either way an instance turns the connector on by naming it in [connectors] enabled. A name that resolves to nothing, an entry point that will not load and a module path that does not exist are all hard stops that name the fix and list what is registered. None of them is a skip: a source that quietly stops being read is a source whose artifacts the next complete pass reads as deleted.

2. What you are handed

The factory takes a ConnectorContext and returns your connector:

def build(ctx):
    return AcmeConnector(ctx)
on the context what it is
ctx.config the instance — project root, group id, routing, doc roots
ctx.settings your own [connectors.<name>] table
ctx.secrets credentials, from the environment and nowhere else
ctx.state a slot on disk that survives a restart, opaque to the engine
ctx.log a logger named for your connector
ctx.name the name this instance enabled you under

There is no write adapter on it and no path to one. Which store a decision lands in is the write seam's question, and a connector never gets to ask it.

Settings. ctx.settings.get("base_url"), .require("workspace") (a named hard stop when absent), .as_list("projects") for a list that tolerates the single-value shorthand. Reads are recorded: a key you never ask for is reported by revetly doctor as declared-but-never-read, so project for projects stops being an instance that silently reads the whole account.

Secrets. ctx.secrets.require("ACME_TOKEN") — a named hard stop naming the variable when it is unset. A credential value written into revetly.toml is refused at load, because that file is committed and a value in a history is not recoverable; name the variable that holds it (token_env = "ACME_TOKEN"). Use ctx.secrets.describe(var) when a check or a log line needs to say something about a credential: it says set-or-not and how long, never the value.

State. ctx.state.read() / .write({...}) — JSON under the instance's .revetly/connectors/. Keep a cursor, an etag, a last-modified marker here. The engine writes the file and never reads inside it. This is the slot the cheap change check keeps its cursor in (§7b), and watch mode is what consumes it.

2b. When the instance declares more than one project

One instance can hold several projects, each with its own graph namespace, its own root and its own ledger — the whole surface is multi-project.md. Your connector does not change for it — it is built once per project, with that project's context — but four facts about the context are different from what a single-project instance would have led you to expect, and each of them has bitten somebody.

Your settings table is replaced, not merged. A section is read key by key, so [projects.beta.connectors] enabled shadows only enabled. Your own settings table is a single key: a project that re-states it replaces the whole table.

[connectors.acme]
account = "smg"                  # the Acme account these ids belong to
token_env = "ACME_TOKEN"
board = "Roadmap"

[projects.beta.connectors.acme]
board = "Beta board"             # and NOTHING else is in force for `beta`

beta now declares no account, so if your connector falls back to something else for source_instance — the project's group_id, a default, the account the token happens to belong to — that project's artifacts carry a different namespace from its sibling's, from one omitted line. Two consequences for you: require what identity depends on (ctx.settings.require("account")) rather than defaulting it, and keep the fallback, if you must have one, out of anything that derives an id. revetly doctor and a dry-run pass name the keys a re-stated table dropped before anything bills, which is a report about your table — it is only as useful as your require calls make it.

group_id is the project's and is never inherited. You never write it, and you should never derive anything from it: it is the namespace the engine stamps your artifacts into, and two projects may not share one. An id of yours is the source_system / source_instance / native_id triple, and the same triple read into two projects is deliberately two artifacts in two namespaces — not one shared record.

Your state slot is per project. ctx.state is <that project's root>/.revetly/connectors/<your name>.json, and each project's root is its own. So a cursor, an etag or a changed_since marker is kept once per project, and the same source read into two projects keeps two independent cursors that move at different times. A backfill checkpoint (§7c) lives beside it under the same root. Never key state by connector name alone in a location of your own choosing — a shared file would have one project's resume position skip the other's window.

A route sends your artifacts somewhere else. A project may route one of its connectors into a shared namespace — a third namespace beside the projects, which several may read:

[projects.platform.routes]
acme = "decisions"               # this source's artifacts land in the shared namespace

Nothing about your connector changes: you read the same source and hand back the same artifacts. What changes is that a pass over a shared namespace reads every project routing into it, in one pass, because the manifest is shared and a pass that saw one contributor's artifacts would read the others' absence as deletion. If you report an incomplete view (§5), you are protecting every contributor's artifacts and not only the ones you read — which raises the stakes on the rule in that section rather than changing it.

Every command you check yourself with takes --project. Three of the four above are only visible per project, so the commands are too:

revetly connectors list --project beta          # what beta enables, and what it resolves
revetly connectors prove acme --project beta    # the kit, over the connector beta builds
revetly connectors sample acme --project beta   # beta's settings table, beta's credential
revetly doctor --project beta                   # your checks, and beta's resolved settings

Named, each answers for that project and says which one answered; unnamed, each answers for the declared default, and on an instance declaring one project none of them says anything about projects at all. The named project is the running one for the whole read, not a config handed to your factory — so the derived id sample prints is elided against that project's namespace, which is how you find out an omitted account in a re-stated table has re-namespaced a board. Check a mapping under each project that reads it: the same source under two projects is two contexts, and only one of them is the one you have been looking at.

3. What you return

def extract(self) -> ExtractResult:
    return ExtractResult(artifacts, complete=True, reason=None)

extract() reads the full current state of the source every time. Idempotency is enforced downstream by the manifest, not by you — an unchanged artifact costs no extraction, so re-reading everything is cheap and correct.

And it moves nothing. Every caller declares what its read may do to the sources, and the two halves are named separately: whether the repositories behind the roots may be brought to the ref they track, and whether this instance may record the date it first read one. A pass does both. A command that only looks — the check, the dry-run quote, the configuration page's quote — does neither, and reads every checkout at the revision it already holds. The ship gesture brings the store it is about to write into up to date and records no connection date.

Your connector does not choose between them. It reads its source and hands back what it found, and where a repository has to be brought up to date the engine's own sync does it — GitRootsMixin.sync(), called at the top of extract() by the connectors that read repositories, under whatever the caller declared. A connector that fetched, pulled or rewrote a file of its own accord would move somebody's working tree every time a quote was priced, and the kit fails it for that (read policy, below). A cursor, a cache or anything else you want remembered goes in the state slot, which the engine writes once the pass has landed.

Every field of RawArtifact, and which are required, is in connector-contract.md. The five that decide whether your connector is right:

If your source holds backlog items

A work item reaches the backlog as kind=KIND_PBI with type set to this project's word for it, translated from your source's own word through a [connectors.<name>.types] map. connectors.tracker.parse_type_map reads that table into a TypeMap that the read half reads forwards and a write half reads backwards; never infer the type from an id.

Criteria are read off the body, by the engine. Put them in the body as the source holds them, under ## Acceptance criteria (CRITERIA_HEADING; for a JSON body, in its description). One item per line opening - , * , 1. or 1), with any number, and a wrapped line rejoined to the item above it. One parse, acceptance_criteria_of, serves the read surface and the delivery record, so the two cannot disagree. A body with the heading and no item under it is served with criteria_unparsed: true beside the empty list, and the ingest note counts such items per connector.

An item your map could have typed and did not is still yours to name. It ingests with your source's own word as its kind, outside the backlog. Say why on the artifact, so list_backlog can name it in its outside_backlog block rather than leaving it out in silence:

from revetly.connectors.base import OutsideBacklog, WHY_UNMAPPED, WHY_UNTYPED

outside_backlog=(
    None if pbi_type
    else OutsideBacklog(WHY_UNMAPPED, native_type=native, labels=tuple(labels)) if native
    else OutsideBacklog(WHY_UNTYPED, labels=tuple(labels))
)

untyped is an item with no native type and no label the map names; unmapped is one whose native type the map leaves out. Declare it only on the items a map could have typed (an issue, a work item), never on a comment, a pull request or a delivery note. It is no part of the content hash, so a changed map re-types nothing anybody edited, and the ledger rewrites it on every pass. A connector that exposes its map as type_map also gets a <name>: untyped items and <name>: unmapped items pair in revetly doctor, counted off the same ledger rows.

3b. Say how a fact should be weighed, and who it is about

Three more axes, all yours to declare and none of them inferred by the engine. Skipping them costs nothing today and costs a reader the ability to tell your source's facts apart from anyone else's.

RawArtifact(
    ...,
    authority=AUTHORITY_DECLARED,          # how strongly your source claims it
    confidence=0.87,                       # only if your source states one
    sensitivity=SENSITIVITY_CONFIDENTIAL,  # how freely it may be handled
    actors=[{"native_id": "5f3", "role": "owner",
             "display_name": "Sam", "email": "sam@acme.io",
             "same_as": ["slack:acme:U024BE7LH"]}],
)

Declare the first and the third per kind, not per connector, wherever your source emits more than one shape. The three connectors this engine ships to real trackers do it with a ClassificationDeclaration on the class — which is also what makes the tiers readable without reading the code, because revetly connectors list prints them and the published contract page tabulates them:

from revetly.connectors import Classification, ClassificationDeclaration

CLASSIFICATION = ClassificationDeclaration(
    kinds={
        "pbi": Classification(SENSITIVITY_INTERNAL, AUTHORITY_DECLARED,
                              "the work item's own fields, held as the record"),
        "comment": Classification(SENSITIVITY_CONFIDENTIAL, AUTHORITY_ASSERTED,
                                  "free text on a thread"),
    },
    default=...,          # for a `kind` that is your source's own word, verbatim
)

class AcmeConnector:
    classification = CLASSIFICATION
    ...
    def _artifact(self, row, kind):
        tier = CLASSIFICATION.of(kind)
        return RawArtifact(..., sensitivity=tier.sensitivity, authority=tier.authority)

One tier for a whole source is wrong in both directions the moment it has both shapes: a work item is what the tracker is for, and a comment thread is where somebody pastes a stack trace with a token in it. Declaring the connector confidential over-withholds the backlog; declaring it internal under-withholds the threads. The because line is not decoration — an operator setting withhold_above is deciding what leaves their perimeter on the strength of your declaration.

Declaring any of these moves no content hash, so adding them to a connector already in production re-extracts nothing.

ingest_run is filled in by the engine and never by you: it names the run that produced the artifact, and the ledger keys your connector's version, a fingerprint of the configuration it ran under, and its completeness outcome to that id. Set version on your connector class if you ship it as a distribution — revetly runs --artifact ACME-12 is how somebody traces a wrong fact back to the code that extracted it.

4. Say what you read

declaration = SourceDeclaration(
    reads="issues from the Acme REST API",
    fields=(SourceField("parent_key", "the epic this issue rolls up under", mints="parent"),),
)

revetly check and the published contract page both read this. Three outcomes, and they are different on purpose:

Say whether what you read is Revetly's own records

declaration = SourceDeclaration(
    reads="issues from the Acme REST API",
    own_records=False,          # the default, and almost certainly your answer
)

One boolean, defaulting false, and false is what a connector reading somebody else's system wants. True means what is on the other end is this instance's own records — the backlog it writes, the conversation notes it captures, the delivery records it files — rather than a source anybody connected. It is true for exactly three of the connectors shipped here (pm, conversations, deliveries), and those three render as a single line on the project card and are never offered among the sources somebody can add, because offering them would be offering to connect an instance to itself. Everything else gets a card of its own, which is what a source is.

file_backed cannot draw this line, and that is the entire reason this boolean exists. It is a tempting shortcut, because all three record stores are files on this disk — but so is the docs connector, and a doc root is somebody's documentation: written by whoever writes it, merely readable from here, external in exactly the way a tracker is external, and still external the day the same prose arrives out of Drive rather than off a disk. Reading "on this disk" as "ours" would file a documentation folder among the records this engine authors. Nor can the page settle it by naming the three connectors, because a page that knows what its connectors are called is what every declaration on this page exists to prevent.

Saying it costs nothing and moves no content hash, so adding it to a connector already in production re-extracts nothing. Saying nothing means false, which is the right reading of a connector that said nothing: a source nobody declared as this instance's own is a source somebody had to connect. And it is not, on its own, a declaration — a connector that sets only this is still reported unchecked, because whose records these are says nothing about what is in them.

If a write adapter writes to this store, read its trailer back

A store with no field for a citation or for supersession has its write half put both into the one free-text field it does have, under a ## Revetly heading. Your connector is the other half of that: take them out before you build the body, and declare them.

from revetly import trailer

CONTRACT = (
    # ... everything else this connector reads ...
    *trailer.fields(),   # cited_sources → CITES, supersedes → SUPERSEDES
)

def _artifact(self, row):
    description, cited, supersedes = trailer.split(row.get("description") or "")
    return RawArtifact(
        body=f"# {row['title']}\n\n{description}".strip(),
        metadata=ArtifactMetadata(
            cited_sources=cited,
            supersedes=supersedes,
            status=row["state"],
        ),
        kind=KIND_PBI,
        connector=self.name,
    )

Both halves matter and each fails silently by itself. Skip trailer.split and the ids reach CONTEXT as prose in the body with no edge behind them; skip *trailer.fields() and the edges are made but revetly check, the published contract page and the uncited audit all read your source as one whose artifacts could not have cited anything — so an ungrounded work item in it is one no audit can name. Two checks catch it for you: revetly doctor reports the pair when the adapter for this store declares carries_trailer, and the conformance kit below reports any artifact your extract produced whose body still carries a section you declared you read back.

If a write adapter posts delivery notes to this store, read them as notes

A write adapter that implements post_delivery_note leaves one comment per delivery on the item, ending in a marker that names the delivery record and a sha256 of the note's words. Your connector reads that comment as a rendering of the record, not as a remark, and delivery_note.classify says which it is — hand it the comment's text exactly as the store served it, before any conversion, because the hash is checked against those bytes:

from revetly import delivery_note

reading = delivery_note.classify(raw)       # a note, or a remark
kind = reading.kind                         # "delivery-note" or "comment"
title = reading.title(f"issue {number}", first_line)
body = reading.body(text)                   # "# Delivery note" / "# Comment" heading
relation = reading.relation(str(number))    # renders DEL-…, or comment-on the item
source_locator = {"comment_id": comment_id, **reading.locator}

A reply under a note has no marker and stays a comment. reading.locator is empty for a comment and carries the note's delivery id and whether its hash held for a note.

The delivery-note kind is on the index tier, so no LLM reads it. The renders target is one of this engine's own record ids and is not namespaced into your source. The locator keys are what revetly check and context_health read back (delivery_note.locator_state) and compare against the records: a record that expected a note and has none after a complete read of your source, or a note whose words no longer match its marker, is reported as drift. Declare the kind's classification beside your comments' in section 3b.

4b. Say what you read from your own table

The declaration above is about the source. This one is about the two lines somebody has to put in revetly.toml before your connector reads anything at all — and until you declare them, that table is the one part of the whole configuration surface with no help in it: a text box per key, no default shown, no sentence, and the first sign that tells anyone they got it wrong is a pass that failed after the money was spent.

from revetly.connectors.base import (
    BOOL, FLOAT, INT, LIST, STR, TABLE,
    TIER_ADVANCED, TIER_BASIC, TIER_REQUIRED,
    Option, SettingsDeclaration,
)


class AcmeConnector:
    name = "acme"

    settings = SettingsDeclaration(
        reads="which Acme workspace this instance reads and how much of it one pass covers",
        options=(
            Option(
                "workspace", type=STR, required=True,
                label="Workspace", tier=TIER_REQUIRED,
                summary="The Acme workspace this instance reads. Required: there is "
                        "nothing sensible to guess and a wrong guess reads somebody "
                        "else's board.",
            ),
            Option(
                "token_env", type=STR, default="ACME_TOKEN", env_name=True,
                label="Access token", tier=TIER_REQUIRED,
                summary="The environment variable holding the API token. The name, "
                        "never the token — revetly.toml is committed.",
            ),
            Option(
                "boards", type=LIST, default=(),
                label="Boards to read", tier=TIER_BASIC,
                summary="Narrow the read to these boards. Empty reads the workspace whole.",
            ),
            Option(
                "page_size", type=INT, default=100, unit="issues per page",
                label="Issues per request", tier=TIER_ADVANCED,
                summary="How many issues one request asks for.",
            ),
        ),
    )

It is the same record the engine's own options use — configspec.Option, not a second vocabulary — so a key you declare renders on revetly config with the control its type earns: a toggle for a BOOL, a select for a closed set, a number carrying its unit, a row editor for a LIST, a key-value editor for a TABLE, and a text box only where the value genuinely is prose. A handful of fields on that record are the loader's own — which variable outranks the file for an option, which table it is scoped to, and which field of a loaded configuration it becomes — and they are the engine's own options' to fill in: a component states none of them, and everything below is what one does say.

Per key you may say: type (one of BOOL, INT, FLOAT, STR, LIST, TABLE), required, default, values (a tuple, or a callable returning one — late-bound, for a set that belongs to a registry rather than to you), offers (a set your source holds rather than one this engine can state — section 4c), unit, item (what one entry of a list is), secret, env_name, reads_as (what one accepted value is called, where the token is not what a person would read), summary — the one sentence a person setting it needs, which is the only field the conformance kit insists on — and detail, which is everything that sentence used to be carrying and now sits behind a toggle.

Five more decide where the key lands, in what order, and what it is called there. A page generated from a declaration renders everything in the declaration, which on an instance running a few connectors is several hundred fields at once — so an option says which of them somebody meets first, which of the three subjects the page draws it belongs to, where within that it sits, and what it is called in words that are not a key:

SettingsDeclaration itself may also carry a topic — one word that becomes the fallback for every option in the table that does not name its own, so a component whose settings share one coherent topic states it once rather than repeating it per key:

settings = SettingsDeclaration(
    reads="...",
    topic="source",   # the default a source's settings would already fall
                       # back to; state it explicitly only where a
                       # component's per-option fallback would otherwise
                       # differ, such as a token verifier's own table
    options=(...),
)

And a connection — which of your keys reach the tool, as against which of them say what to read out of it. One instance may serve several projects, and the useful case for a source declared at instance level is a tool whose connection is established once and reused: the API key, the host, the account the address needs. What each project reads through it — which board, which team project, which area, which folders — is that project's own. Those two halves are inherited by different rules, so the connector says which keys are which:

SETTINGS = SettingsDeclaration(
    reads="which Todoist account and projects this instance reads, and what one "
    "ordinary pass over them may spend",
    topic=SOURCE,
    connection=("token_env", "base_url", "timeout"),
    options=(...),   # token_env, project, projects, account, page_size, …
)

That is Todoist's, whole. The variable holding the token, the endpoint every call goes to and how long one may take establish communication and say nothing about what is read; project, projects, write_project and account are scope and are not named. Azure DevOps declares five — ("token_env", "organization", "base_url", "api_version", "timeout") — because organization is half of dev.azure.com/contoso, an address rather than a selection, while its project, area_path and query are the scope. GitHub declares four — ("token_env", "base_url", "api_version", "timeout") — and its repo and repos are the scope.

What the engine then does with it. On an instance declaring two or more projects, a [projects.<key>.connectors.<name>] table that states none of your connection keys takes exactly those keys from the instance's [connectors.<name>], and no others; one that states any of them states its own connection whole and inherits nothing. Scope is never carried across — a re-stated table replaces rather than merges, which is the rule that keeps one project's board out of another's. revetly doctor and revetly check then say, per source, whether it runs on a connection of its own or the instance's.

A project that states no table of its own reads the instance's table whole, and on an instance declaring two or more projects that is a failure: every key of an instance-level [connectors.<name>] that is not one of your connection keys (nor module or factory) is scope, and revetly doctor fails an instance scope line once for each project that would inherit it, naming the table, those keys and the project, and that project's dry-run pass prints the same sentence as an INHERITED SCOPE: line before anything is priced. Your connection is what decides which keys that line names, so a connection key you leave undeclared reads as scope there.

Two rules on what may be a connection key, both refused by name by the conformance kit:

Declaring no connection is the ordinary answer. commits and docs declare none — their connection is [git.credentials] and the filesystem — and a connector that names none has a table that is scope from end to end, inherited exactly as it was before this existed.

Declaring none of these five still works, and changes nothing. They are all optional for a component and mandatory only for the engine's own options: an unstated tier reads as advanced, an unstated subject as the source this connector reads, an unstated topic as "source" through that same fallback, an unstated order as last, and an unstated label as the key. A settings table written before any of this existed renders exactly as it did — revealed by the Advanced settings switch, on its source's card, with the key for a name — which is the promise this declaration made when it arrived, kept.

Three outcomes, and they are the same three the source declaration has:

Three rules worth stating outright:

The kit checks the declaration is well-formed — with no provider, no graph and no instance (§8), so a malformed one is named against your connector in your own suite rather than surfacing inside somebody's paid pass.

4c. Offer a value your source already knows

A board's name, a team project, the work item types one process actually has — every one of those is a value the system on the other end already holds, and every one of them was a text box somebody typed into from memory, from a tab open on the tracker. values above cannot help, because it is a set this engine can state by itself: its callable takes no arguments, so it has no credential, no address and no idea which project it is being asked for; it is synchronous, so a slow endpoint blocks the paint; and it swallows a failure into an empty tuple, so nothing downstream can tell "this account has no boards" from "nobody could ask".

So the live one sits beside it rather than widening it. offers is a callable handed an Ask and answering an Offer:

from revetly.connectors.base import Ask, Offer, Option, STR, TIER_BASIC
from revetly.connectors import offer_context
from revetly.connectors.errors import ConnectorError


def offer_boards(ask: Ask) -> Offer:
    settings, secrets = offer_context(ask, "acme")
    token = secrets.get(str(settings.get("token_env", "ACME_TOKEN")))
    if not token:
        # Waiting on a credential, said the way the person reading it would say
        # it. They have done nothing wrong and there is nothing here to repair.
        return Offer.could_not(
            "paste the access token above and your boards will be listed here"
        )
    try:
        boards = _api(settings, token).pages("/boards")
    except ConnectorError as exc:
        # Already typed on the way out, already carrying a sentence somebody can
        # act on. Passed through rather than re-worded here.
        return Offer.could_not(exc.message, exc.fix)
    return Offer.of(board["name"] for board in boards)


# ... and in the declaration:
Option(
    "board", type=STR, default="",
    label="Board", tier=TIER_BASIC,
    offers=offer_boards,
    summary="Which Acme board this instance reads.",
)

What the Ask carries is everything such a source needs and nothing it does not: the resolved settings of whatever owns the option — for you, your own [connectors.<name>] table — and the project this option is being resolved for, because on a multi-project instance one project's vocabulary is not another's. Ask.project is the Revetly project key, empty for an option belonging to the instance rather than to one of its projects, and it earns its place wherever an offer has to agree with something else that project declares: the Azure DevOps type map offers the board's own item types beside the ones that project's [backlog] types names, and offering another project's would offer a value this instance would then refuse.

offer_context is how you get your two readers out of it: your settings, and a secret resolver. It copes with all three shapes an Ask really arrives in — the whole context a built connector holds, a settings table already parsed, or the raw table as a page holds it while somebody is still typing into it — so you write one function rather than three, and a credential typed into revetly.toml is refused inside a listing exactly as it is refused at load. Nothing in it reaches the network and nothing in it reads a secret: it hands back the two readers, and asking them is yours.

It may be asynchronous. Return a coroutine and it is awaited; return an Offer and it is not. A source reading something already in memory should not have to pretend to be a network call, and a network call must not block a paint.

Say could not ask. Never raise it, and never return an empty tuple. The three answers are listed; nothing to offer, which is the account that answered and named nothing and is a true fact rather than a fault; and could not ask, which is not a fact about anything. Offer.of(values) is the first two — an empty one is a listing of nothing — and Offer.could_not(why, fix) is the third. Raise, and it is read as could not ask with your exception's type named and your words lost, which is a worse sentence beside a field than one you wrote on purpose; return () and an account with no boards and a refused token become the same rendering.

A 200 is not by itself an answer. The source answered; whether it said what it holds is a separate question. A body with no list in it, a body that is not the shape the route promised, and an error a vendor carried inside a 200 are all the source not saying — so they are could not ask, with a way out, and only a source that answered and named nothing is nothing to offer. The providers met all three of these live and read every one of them as an account running nothing, which is the mistake this note exists to save you.

And say it in your own words where the words are not the source's. A sentence a vendor wrote is an answer and belongs beside the field. A sentence a parser raised is not: 'str' object has no attribute '_set_private_attributes' is what the openai SDK says about a captive portal's HTML, and it names nothing anybody can act on.

Two connectors here answer for a vocabulary, and they are worth reading in opposite ways. Todoist's board list is the call it was already making — the read, the doctor check, the window walk and the history read all open by listing projects, because every one of them has to resolve a declared name against what the account actually has, so offering that list costs the request somebody was going to spend in any case, and asks the source nothing new. Azure DevOps's team-project listing did not exist. The only project call that connector ever made was /_apis/projects/{project}, which verifies the one team project it was handed and cannot answer "what else is there" — which is the whole of the question the person filling the field in has. Its work item types are a third shape: that listing did exist, it is project-scoped, and so it answers only once a board has been named — which is the order the page asks them in regardless, and which is why the board's absence is said in words rather than reported as a process with no types in it.

A connector that offers nothing is not broken by any of this, and renders as the text box it has always been. Most of the settings the components shipped here declare do exactly that, and not one of them changed.

Five rules a listing does not get to break

5. Say when you could not see everything

complete=False with a reason is you saying "I could not vouch for a full view". Ingest then refuses to read absence as deletion for your artifacts, and the reason is surfaced in the report. A rate limit, a paging failure, an unreachable API — all of them are this, none of them are a deletion.

And say what a successful read covered. notes on the same ExtractResult is one line per fact worth knowing about a read that worked — which scope it ran over, how far back it reached:

return ExtractResult(artifacts, notes=("UNSCOPED — read all 14 projects",))

They print per connector in the ingest report and in connectors sample, and nothing in the engine reads inside one. The case they exist for is an instance that believed it had narrowed a tracker to one project: reason only ever speaks for a view you could not vouch for, so without this the difference between one project and a whole account first showed up on the bill.

A completion that looks like a deletion is the trap. Trackers routinely drop finished work from the endpoint they serve open work from. Read only that endpoint and every ticket the team completes goes absent from a complete view — which is a deletion, and the tombstone takes the state transition they cared about most with it. Fetch the finished ones explicitly, and if your source only answers that over a bounded window, say the horizon in a note.

6. Fail at the right size

raise means what happens
ConnectorConfigError cannot run as declared — credential missing or rejected, scope unusable hard stop naming the fix
SourceUnavailable the far side is having a bad minute incomplete view, no tombstoning
ArtifactContractError you built an artifact the envelope refuses your connector's pass fails, alone

The engine raises the third for you when a mapping breaks the envelope. All three are ordinary exceptions, so one connector's failure never reaches another's artifacts — and the first two exist because "I am misconfigured" and "the far side is down" look identical at the socket and mean the opposite to a deletion pass.

Each takes named= beside its sentence: a mapping over the one closed vocabulary in refusal.Named — setting, keys, variable, values, ids, native_ids, path, instead — holding what the sentence names, so a reader that wants the variable or the setting reads it there rather than searching your words:

raise ConnectorConfigError(
    self.name, "ACME_TOKEN is not set", "export it in the instance's .env",
    named={"variable": "ACME_TOKEN"},
)

It never changes the sentence. A key outside the vocabulary is a TypeError where you wrote it.

7. Answer for yourself before a pass bills

Optional, and worth it for anything with a credential:

from revetly.connectors.base import ConnectorCheck, not_run

def doctor_checks(self, offline: bool = False):
    checks = [ConnectorCheck("credential", ok, self.ctx.secrets.describe("ACME_TOKEN"))]
    if offline:
        return checks + not_run("reachable")
    return checks + [
        ConnectorCheck("reachable", ok, "api.acme.example answers", needs_network=True),
    ]

These join revetly doctor's chain with a pass/fail and a fix line. needs_network=True marks the ones --offline skips. A connector that contributes none is not reported, because a source that was never asked about must not read as a source that passed.

Under revetly doctor --offline core calls doctor_checks(offline=True). Return the free lines and name each network line with not_run(...) rather than making the call: doctor prints those as SKIP under the same names an online run prints, and the run opens no connection. A doctor_checks() that takes no offline argument still works online, but under --offline core does not call it at all and prints one SKIP line for the whole connector, because it has no way to stop it reaching the source.

warn=True on a check that passed is the third state: working, and worth saying out loud even so. Use it for a fallback the operator did not choose and would want to know about — a scope nobody narrowed, an identity key falling back to a default so that every id you mint depends on it. It is ignored on a check that failed; a failure is not softened by asking.

7b. Say cheaply whether anything moved

Optional, and what makes an unattended instance cheap. Watch mode asks this before it reads you:

def changed_since(self) -> ChangeCheck:
    cursor = self.ctx.state.read().get("cursor")
    moved, cursor = self.api.changed_since(cursor)     # an ETag, a sync token, mtimes
    if not moved:
        return ChangeCheck(changed=False, detail="nothing since the last look")
    return ChangeCheck(changed=True, detail="12 tickets moved", commit={"cursor": cursor})

Four rules, and the first is the one everything else follows from:

A commit is plain json, and the kit holds you to it. A mapping of string keys to strings, numbers, booleans, null, lists and objects — plus a datetime, which is written as ISO 8601, the one coercion the slot makes. A set, a tuple, a Path, bytes, a dataclass or a cycle is refused by name, with the key, in assert_conforms — before it costs anything. Serialise whatever your cursor really is in your own code, where you know how to read it back: a set as a sorted list, an id as an id.

Whatever you hand back, writing it can never fail the pass that earned it. The engine commits after the pass has landed, catches everything, and reports the source and the reason in the ingest report, in revetly status and on the durable health record. The cost of a refused cursor is one repeated read on the next pass — never a pass that spent and then threw its report away.

And what the kit cannot prove: that your check tells the truth. It runs against whatever source you point it at and cannot make that source move, so an answer of unchanged is taken as given and a clean report says so out loud. A check that answers unchanged while the source has changed passes every rule there is and costs watch mode every pass it skips — test that against your own fixture, where you are the one who can move it.

A connector that defines none is read in full on every pass, exactly as it was before this existed. revetly connectors list reports which of your sources can answer, and revetly status reports what each one last said.

Anything the check needs, it needs cheaply. Todoist's is one request for a sync token, which means the check may not spend a second one turning declared project names into the ids that place what comes back — so the pass writes what the scope resolved to into the same slot it commits the token to, and a check that finds no scope there reads every row as in scope rather than asking. The slot is merged rather than overwritten, so several readers can keep something in it; a first look, and a cursor the source no longer honours, both read as changed rather than as an error.

Ask a feed of what happened, not a query over what still exists. Both live connectors here learned the same lesson from opposite ends. Azure DevOps's check was a query for work items changed since a mark, and a deleted work item leaves no changed row — so the one change that most needs to reach CONTEXT quickly was the one the check could not see. Todoist's read the newest entry in an activity log, and a board quiet for longer than the log's retention answers with nothing, which is indistinguishable from a log the account's plan withholds. A cursor a source hands you over events that happened has neither hole. And say which instrument answered: ChangeCheck(instrument=...) is what lets revetly status tell a signal that is working from one that has been reading in full for a week.

7c. Say what history you can serve

Optional, and none is the honest answer for most sources. What a snapshot connector returns is the current state of every in-scope item; what it never returns is the record of how any item got that way — the state transitions, the field changes, the comment thread where the decision was actually argued. A project adopting Revetly with a five-year backlog gets three thousand items that all appear to have been decided the morning of the first ingest.

If your source can answer for that record, declare it:

from revetly.connectors import (
    DEPTH_COMMENTS, DEPTH_TRANSITIONS, ExtractResult, HistoryDeclaration,
    HistoryWindow, RawArtifact, Relation,
)

class AcmeConnector:
    name = "acme"
    history = HistoryDeclaration(
        depths=(DEPTH_TRANSITIONS, DEPTH_COMMENTS),   # and not field revisions
        windowed=True,                                # the feed takes a date range
        horizon="90 days on the current account plan",
        beyond_horizon="a ticket closed before that is in neither list",
    )

The declaration is read without calling you and without spending anything. That is the whole point: an instance deciding whether adopting a decade of tickets is worth it must not have to run the pass to find out what is on offer. revetly connectors list, revetly doctor and revetly backfill all read it, and none of them touches your source to do so.

A declaration you do not make is read as none, not as unknown. A connector serving no history is not a defect — but revetly backfill then refuses it by name, saying which connector and what it declares, rather than returning an empty result somebody reads as "this tracker had no history".

It is not one bit, because sources differ in all three of these and a single history: yes would make each of them lie about the others:

Then serve it. Two methods, and the conformance kit holds you to both:

    def history_windows(self, since=None, until=None) -> list[HistoryWindow]:
        """Tile [since, until) — half-open, no gap, no overlap."""

    def extract_history(self, window: HistoryWindow) -> ExtractResult:
        """One slice, as ordinary artifacts."""
        return ExtractResult(
            artifacts=[
                RawArtifact(
                    kind="comment",              # your own word for it
                    immutable=True,              # a moment that is over
                    title="re: cannot log in",
                    body=comment["text"],
                    source_created_at=comment["posted_at"],   # when it HAPPENED
                    relations=[Relation("comment-on", "ACME-412")],
                    source_path=..., connector=self.name, provenance="source-of-record",
                    id=f"ACME-412#c{comment['id']}",
                )
            ],
            complete=True,       # per window, not once for the archive
        )

Five rules about those artifacts, all of them enforced:

Which pass reads a history artifact decides who may miss it. An artifact only ever returned by extract_history is exempt from the ordinary removal rules: no snapshot pass looks at 2019, so none has standing to read its absence as a deletion, and without that exemption the first watch tick after a backfill would tombstone the whole decade. The exemption lifts the moment extract() returns the same artifact — which is the shape to reach for when the history is also current state. Todoist's comment threads are the case: they come back from both methods, so a comment deleted at the source is absent from a complete snapshot view and tombstones under the ordinary rules, while a completion filed in 2019 comes back only from a window and is left alone.

The exemption is from a pass that never looked — not from every pass there will ever be. Left there, a completion the source retracts stays asserted for the life of the corpus, and history whose work item was deleted outlives it. So a pass may say which moments it actually read, per kind:

        return ExtractResult(
            artifacts,
            history_view=(HistoryView("completion", since, until),),
        )

A record of that kind whose own moment falls inside is judged by the ordinary rules: this pass would have seen it if the source still asserted it, so its absence is a withdrawal and is reported as one. Everything outside stays protected.

The claim bounds you as well as arming you. A view says which moments you looked at, so it also says which you did not — and a record of that kind whose moment falls outside every span you claimed is one the deletion pass leaves alone, whichever pass returned it. That is what keeps a source you read less of from reading as a source that withdrew what you stopped reading: a connector floored at a later date hands back a complete view that no longer contains what fell below the floor, and without the bound every one of those ids is tombstoned as a withdrawal the source never made. If your snapshot is unbounded (bounded_snapshot=False), claim a view per unit on the snapshot path and the conformance kit will tell you when you have not:

        return ExtractResult(
            artifacts,
            history_view=tuple(
                HistoryView("commit", floor, now, scope=str(repo)) for repo in read
            ),
        )

scope is which unit the claim is about — a repository path, as your artifacts carry it in source_path. Two repositories read from two different floors are two claims, and one of them saying "I saw everything since July" must not be read as a statement about the other, whose floor is August. An unscoped view speaks for the whole source, which is what every view meant before floors became per unit. Three silences leave the ordinary rules exactly as they were: a kind you claimed nothing for, a record with no moment of its own, and a unit you claimed nothing for — the last being a unit that left your declaration, which is a source this project no longer reads rather than one it reads less of.

One field you do not have to claim a view for, and cannot: your connector's own name leaving [connectors] enabled. The engine holds every row you contributed rather than tombstoning it, and reports the source as unconfigured in the pass report, in revetly status and in context_health — because nothing asked you, so nothing may read your silence. That is the engine's rule and not yours; there is nothing for a connector to declare about a pass it was not part of.

One fact you do have to state, if your source has units. declared_units is the units you currently read, as source_path prefixes — which is a different claim from history_view, and the difference is what keeps a corpus:

        return ExtractResult(
            artifacts,
            declared_units=tuple(str(root) for root in self.roots),
        )

A unit that is declared and could not be read is an incomplete view and suppresses the deletion pass, which is complete=False's job. A unit that is not declared at all was never asked, and everything the ledger holds for it is held rather than tombstoned. Without this, a line leaving your repos or roots is how somebody deletes from CONTEXT with a one-word edit.

Where your unit is not a path, stamp it. A tracker's project, area path, board or saved query narrows what a pass reads exactly the way a repos line does, and appears in no source_path — an API record's path is the request that produced it. So say which unit each artifact came from, and the ledger matches your declaration against that rather than against a path:

        RawArtifact(..., unit=f"project:{project_id}")
        # and, on the result:
        return ExtractResult(artifacts, declared_units=("project:123", "project:456"))

Record the declared unit the artifact matched, not the deepest true unit about it. A work item three areas below the one you declared carries the declared area, because the question the ledger has to answer later is "does this instance still declare the scope that asked for this row" — so matching is exact and no hierarchy has to be taught to the engine. It is also what makes the hold self-correcting: narrow your scope and every row still being read is re-stamped onto the new declaration, leaving only the rows nothing asks for on the old one.

Three rules keep this from disarming the deletion pass by accident:

Answer declared_units as a property too, if you can do it offline — revetly status and revetly doctor draw the same line when no pass is running and may not spend a request for it. A connector whose units are its roots already answers this by existing. One that cannot resolve its own declaration without the source says nothing, and the report stays silent rather than wrong; the deletion rule never comes through there, and resolves its scope live in the pass it judges.

Claim it only where it is true, and that is narrower than "the range I was asked for". Todoist claims its completions, because that feed answers an explicit date range; it claims its comment threads only when the sweep started at the project's own beginning, because a thread is read per task and a task finished before --since was never in view; and it claims logged transitions only inside what the account's plan retains, because a transition that rolled off the log is absent for a reason that is not a deletion. The conformance kit refuses a span reaching outside the window you were handed, and refuses one at all from a read that reported itself incomplete.

Per-window completeness is a claim about the slice, not about the archive: a window that was rate-limited says complete=False with a reason, and the pass reports that beside the window rather than folding four windows' answers into one. Beyond the coverage you claim, it decides nothing about deletion — a historical pass tombstones nothing it did not say it read, because a window over 2019 is not a view of your source and absence in it is evidence of nothing.

Being partial about one kind. complete=False speaks for your whole corpus, which is too blunt for a read that saw all of one unit and some of another — a comment budget that reads two hundred threads of a thousand has a complete view of the tasks and a partial view of the threads. partial_kinds={"comment": why} scopes it: nothing of that kind is read as deleted this pass, everything else is judged normally, and the report says what the bound cost.

Remembering where you got to. A bounded read has to carry on rather than re-reading the same head of the list for ever, and the cursor for that is handed back — ExtractResult(commit={"comment_cursor": ...}) — never written by you. The engine merges it into your state slot once the pass has landed, which is the same contract ChangeCheck.commit holds and for the same reason: a cursor moved by a read whose pass then died is a slice nothing ever ingested.

And coming round at all. Where you got to is only half of it: something has to wake the next pass, and on a quiet board the source will not. So the engine records what your partial_kinds left behind, in the same slot under the same land-first rule, and your changed_since reads it before it asks anybody anything — see §7b. Skip that and your rotation comes round every full-pass interval rather than every tick, which the conformance kit reports by name.

An instance can withhold a whole class of your artifacts from the provider without withholding the source: [egress] withhold_kinds = ["acme:comment"] sends the tickets and never the comment threads, which is where credentials and customer names actually live.

What drives your windows

revetly backfill --connector acme --since 2019-01-01 --until 2026-01-01. Read it once before you write either method, because it is the shape your two methods have to survive:

If your source has no current state, say so

Almost every source has a snapshot: the open work items, the files under a root, the tasks in a project. A few do not — a git repository's history is its snapshot, and "read it as it stands" means every commit back to the first. For that shape the ordinary pass is the problem, not the backfill: it is what watch ticks, what runs after revetly init, and what an MCP write tool queues behind a capture, and none of those three asked anybody what it would cost.

So declare it, and the engine floors you:

declaration = SourceDeclaration(
    reads="each commit's message, hash, author and author date",
    bounded_snapshot=False,     # my history IS my snapshot
    snapshot_unit="repository",  # what a floor is one of
)

def snapshot_units(self) -> list[str]:
    return [str(repo) for repo in self.roots]

def unread_past(self) -> str:
    """What sits below my floors and has never been read, or ""."""

What you get for it: the engine stamps, durably and per unit, the moment each unit was connected to the instance, and hands you that floor to read forward from. revetly doctor reports the floor per unit and where it came from — declared by the operator, or the connection — and revetly status and the read tools report how far back each unit has actually been read, so a query about anything older is answered as an unread past rather than an empty one.

What you owe alongside it: a history tier (history_windows / extract_history) so that past is reachable at all, reads bounded per unit rather than by one floor for the whole source, and unread_past() — a source whose every artifact is already at or above its floors is not an error, and a backfill against it says so by name rather than refusing. The conformance kit checks the first two.

8. Prove it before it costs anything

Everything here is free to run, and all of it comes before your first ingest. The first honest feedback on a mapping used to be a full pass — a provider bill, a graph write, and a manifest that now believes whatever you got wrong.

Start from a skeleton

revetly connectors new acme        # connectors/acme.py + the [connectors.acme] block
revetly connectors prove acme      # the conformance kit, as a command

new writes a working connector into the instance and declares where its code lives. It does not enable it: until you add the name to [connectors] enabled yourself, the next ingest reads and costs exactly what it did before, and the command prints the line to add. The skeleton passes the kit as written, so your first prove passes against a file you have not edited yet — which is what makes the first failing run after you edit it information rather than noise.

The envelope and context tables inside the generated module are rendered from the engine's own dataclasses, not copied from this page, so a field the engine adds or drops cannot leave the skeleton teaching a shape it no longer accepts.

The conformance kit

Passing it is what "this connector satisfies the contract" means. One call, in your own test suite, with no revetly.toml, no graph and no provider:

from revetly.conformance import assert_conforms

def test_the_acme_connector_satisfies_the_contract():
    assert_conforms(lambda: AcmeConnector(fixture_ctx))

and the same kit as a command, for an instance whose engine is a container with no test runner in it:

revetly connectors prove acme      # exits non-zero if the kit finds anything

One implementation behind both, so a command and a suite cannot report two different verdicts on one connector. Neither opens the manifest, connects to the graph or calls a provider.

Hand the kit what builds your connector — a factory, a partial, a lambda over your own fixture — rather than the built connector. It then builds twice as well as extracting twice, which is the only way to see a clock captured in __init__. A built connector is still judged, under every rule but that one, and the pass says which rule it could not run.

rule what it holds you to
stability two extracts over an unchanged source agree on every id and every content hash
identity no native key issued twice, and no two identities deriving one id
provenance declared from the vocabulary; a mirror names its record source
content format one the engine can serialise
relations every declared relation carries a non-empty type and target
source time the source's own or absent — never Revetly's clock in their place, whether it was read in extract(), captured in __init__, or assigned straight from observed_at; and never later than a version, comment or transition the same source hands back of that artifact
completeness an incomplete view always carries a reason
read policy one more extract() under the policy that permits nothing moves no file under your declared roots and no repository off the revision it held

The last clause of the time rule is the one a perfectly real date fails. A creation time can be a genuine timestamp, declared by the source, identical on every read and on every build, and still not be when the artifact began — that is what a truncated log produces, and no rule looking at one time by itself can see it. What sees it is the source's other half: an artifact has no version, comment or transition older than itself, so the kit reads one window of history below the earliest creation your snapshot claims and expects it empty. Only for a source that declares it can be asked for a bounded slice — proving this by sweeping a whole archive is a bill the kit will not run up on your behalf.

A failure names your connector, the artifact and the rule, with the fix — never a diff and never a traceback. check_conformance(connector) returns the same findings rather than raising, if you would rather assert on them yourself.

Instability is the expensive kind of wrong. An id that changes shape between passes re-episodizes the corpus at extraction price; a body carrying a rendered timestamp re-pays extraction for that artifact every run.

The body — and the format it is encoded in — is the whole of what re-extraction costs money for. A record whose source timestamp moves while its body stays byte-identical is learned, not re-sent: the ledger and the node stamp take the new time, and the episode already in the graph keeps the instant its content first said what it says. So a field of yours that churns by itself (a status, a sync marker, a view count) belongs in metadata rather than in the body, and costs nothing when it moves.

What you may import

A connector inside the instance travels with the mount and runs inside the engine, so it can import exactly what the engine environment already carries:

revetly connectors budget          # what is importable here, and what to do when it is not

The standard library and revetly itself are always in it. Beyond that the set is a fact about the image, not a promise: httpx is there because a provider client brought it, so a REST source works, and a vendor SDK is not there at all. A hand-install into the engine environment is gone at the next image load — the instance is mounted, the environment is not. A connector importing something the image lacks is a named hard stop pointing at this budget, never a bare traceback.

When you need something that is not there: reach the source over the httpx that is already in the image, vendor the few calls you need, or ship the connector as an installed distribution (the first door in section 1) in an image built with it.

A tracker connector that ships inside the engine builds its client on connectors.tracker.Transport rather than its own httpx.Client. The write adapter for the same tracker shares that client, and the transport refuses any request that is not a read unless a class in backends/ lifts the refusal. tools/houselint/architecture.py fails a second client.

The sample pass

For the part a kit cannot judge: whether the mapping is right.

revetly connectors list            # what resolves here, from where, enabled, capabilities
revetly connectors sample acme     # a handful of artifacts, as the engine sees them

sample prints the derived id, the namespace, kind, PBI type, provenance, relations, what rides the body and what is recorded — with no provider call, no graph write and the manifest untouched. It is where you find out that your due dates are in the body and about to cost re-extraction on every reschedule, or that your parent relation points at a key nothing resolves. No resolved credential is ever printed by either command, or by the kit.

9. Check it in the instance

revetly doctor      # your checks run here, and your settings are reported
revetly check       # your declaration, and — if file-backed — your files
revetly backfill --connector acme           # what history you declare, or a named refusal
revetly backfill --connector acme --dry-run # your whole horizon, tiled, read and priced
revetly ingest --dry-run --connector acme   # price this source alone
revetly ingest --connector acme             # and pay for this source alone

revetly backfill --connector acme --since 2024-01-01   # your windows, read and priced, nothing sent
revetly backfill --connector acme --since 2024-01-01 --run --max-episodes 20

--connector scopes deletion detection to the connectors it names, so the first paid pass over a new source neither tombstones nor re-sends anything from the others.

10. The whole connector, once, end to end

Everything above, in the order it actually happens, against an imaginary tracker called Acme. Nothing before the last two commands sends anything anywhere or costs anything.

1. Scaffold it. Declared, not enabled — what the next ingest reads and costs is unchanged until you say otherwise:

revetly connectors new acme
#   created  connectors/acme.py
#   appended revetly.toml ([connectors.acme])

2. Say what it is before you say what it reads. Identity first, because it is the one decision that cannot be corrected later without a rehome and a paid re-extraction:

from revetly.connectors import (
    ChangeCheck,
    DEPTH_COMMENTS,
    DEPTH_TRANSITIONS,
    ExtractResult,
    HistoryDeclaration,
    HistoryWindow,
    RawArtifact,
    Relation,
    SourceDeclaration,
    SourceField,
    owed_work_check,
)

NAME = "acme"


class AcmeConnector:
    name = NAME

    declaration = SourceDeclaration(
        reads="issues in one Acme workspace",
        file_backed=False,
        fields=(
            SourceField("key", "Acme's own key for the issue"),
            SourceField("epic", "the epic this rolls up under", mints="parent"),
        ),
    )

    history = HistoryDeclaration(
        depths=(DEPTH_TRANSITIONS, DEPTH_COMMENTS),
        windowed=True,
        horizon="90 days of activity on the current plan",
        beyond_horizon="a transition older than that is gone from Acme's log",
    )

    def __init__(self, ctx):
        self.ctx = ctx
        # Required, not defaulted: on a multi-project instance a re-stated
        # settings table replaces this one whole (§2b), and an omitted account
        # would otherwise change every id this project mints.
        self.account = ctx.settings.require("workspace")
        self.token = ctx.secrets.require("ACME_TOKEN")

    @property
    def roots(self):
        return []      # an API has no local root for preflight to check

    def _artifact(self, issue) -> RawArtifact:
        return RawArtifact(
            kind="issue",                     # Acme's own word, not "pbi"
            title=issue["title"],
            body=issue["description"],
            connector=self.name,
            source_path=f"acme://{self.account}/{issue['key']}",
            provenance="source-of-record",    # Acme owns these facts
            source_system=NAME,
            source_instance=self.account,     # the namespace half of identity
            native_id=issue["key"],
            source_created_at=issue["created"],
            source_updated_at=issue["updated"],
            relations=[Relation("parent", issue["epic"])] if issue.get("epic") else [],
            metadata={"extra": {"priority": issue.get("priority")}},
        )

    def extract(self) -> ExtractResult:
        issues, whole = self._api_read_everything()
        return ExtractResult(
            [self._artifact(issue) for issue in issues],
            complete=whole,
            reason=None if whole else "Acme rate-limited this read at page 4",
        )

    def changed_since(self) -> ChangeCheck:
        if owed := owed_work_check(self.ctx.state):   # a slice my last pass left
            return owed
        cursor = self.ctx.state.read().get("cursor")
        moved, cursor = self._api_changed_since(cursor)
        if not moved:
            return ChangeCheck(changed=False, detail="nothing since the last look")
        return ChangeCheck(changed=True, detail="12 issues moved", commit={"cursor": cursor})

    def history_windows(self, since=None, until=None) -> list[HistoryWindow]:
        return self._month_windows(since, until)      # half-open, no gap, no overlap

    def extract_history(self, window: HistoryWindow) -> ExtractResult:
        return ExtractResult(self._events_in(window), complete=True)


def build(ctx):
    return AcmeConnector(ctx)

3. Prove it, before it reads anything real. The kit needs no source, no credential, no graph and no provider:

revetly connectors prove acme      # the contract, twice-built, non-zero if it slips
revetly connectors budget          # what this image lets your module import
revetly connectors sample acme     # your mapping, as the engine sees it

sample is where the mapping is judged: derived id, namespace, kind, PBI type, provenance, relations, what rides the body, what is recorded. A due date in the body shows up here, before it costs re-extraction on every reschedule.

4. Enable it, and only then spend. Two lines, and neither of them is in the engine:

[connectors]
enabled = ["pm", "docs", "conversations", "deliveries", "acme"]

[connectors.acme]
module = "connectors/acme.py"
workspace = "acme-prod"
token_env = "ACME_TOKEN"
revetly doctor                              # your checks, your settings, your namespace
revetly ingest --dry-run --connector acme   # what this source alone would send, priced
revetly ingest --connector acme             # and pay for this source alone

5. History is a separate decision and a separate bill. An ordinary pass reads the source's current state and nothing older, whatever you declared:

revetly backfill --connector acme --dry-run             # your horizon, tiled and priced
revetly backfill --connector acme --since 2024-01-01 --run --max-episodes 20

6. Leave it running. Your changed_since is what makes this cheap: watch ticks ask the question and read only the sources that answered yes.

revetly watch