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.
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.
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.
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.
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:
source_system, source_instance, native_id. The id CONTEXT
keys on is derived from the triple, so Jira PROJ-123 and Linear PROJ-123
stay different work items and attaching your connector never rewrites an id
already in the graph. Never pick id yourself.provenance — required and undefaulted. source-of-record if your source
owns these facts, mirror (plus record_source) if the record lives in the
other system. A default here would have the engine assert authority on your
source's behalf.relations — what the source states outright: an epic link, a subtask
parent, a typed blocks. These become deterministic edges. Flattening them
into prose and paying an LLM to guess them back replaces certain data with
probabilistic output.source_created_at / source_updated_at from the source's
own fields. If the source cannot answer, leave them None. Substituting
Revetly's clock lands a two-year backfill as one ingest day, and a stand-in
becomes indistinguishable from a real date. If you derive the time from a
log rather than reading a field, derive it over the whole log: a query
narrowed by path, project or key prunes before the source resolves a rename
or a move, and what comes back is the date the file was moved wearing the
name of the date it was created.content_format — how the body is encoded, which is a different question
from what the artifact is. Put churn-prone fields (due date, priority,
order, labels) in metadata.extra rather than the body: the manifest hashes
the whole body, so a field in it costs re-extraction every time it changes.
A value there may be a structure — an assignee record, a review log, a list of
labelled objects. A property store cannot hold one, so it is recorded as JSON
and marked as encoded when it is; the read surface decodes what was marked,
so a reader gets back the entries you put in rather than a string to parse.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.
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"]}],
)
authority — declared, asserted or inferred. provenance says where
a fact came from; this says how strongly it is claimed, and without it an
approved epic and a passing remark in a thread reach a reader with identical
standing. Undeclared it is asserted, which is the reading that is never wrong
about a source that said nothing. Earn declared by saying so.confidence — a number on 0–1 where your source states one. Leave it
None otherwise; a confidence nobody stated must never be readable as
certainty. If your source scores out of 100, normalise here — you are the only
code that knows what its 87 was out of.sensitivity — public, internal, confidential, restricted. This is
what the redaction and consent pass gates on. A content scanner finds what it
has a pattern for; a customer ticket is confidential because of what it is,
and only you can say so. Anything above internal is reported by the outbound
scan whether or not a pattern matched, and [egress] withhold_above keeps it
from crossing to the provider at all. Comment threads and DMs are the usual
case for raising it above your source's other kinds.actors — who the artifact is about, as references rather than a formatted
string. The engine never parses identity out of one; you may, because you are
the only code that knows the shape of your own field. Where the same human has
a different key in another source, say so with same_as — that claim is the
only way two references are ever joined. The engine will not merge on a
matching name or a matching email, because two people share a name, one person
has three addresses, and an inferred merge is unmergeable afterwards.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.
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:
check parses each artifact's frontmatter
against them and reports what will and will not land.check reports the declaration. It does
not open an API artifact's source_path with a frontmatter parser, which
reported "frontmatter did not parse" on every row.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.
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.
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.
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:
label is the plain name a person reads. project is "Board";
write_project is "Board new tasks go to". The key itself appears in exactly
one place — All settings, underneath the label, where a search matches either
— because a page naming a file, a variable or a section is a page written for
whoever built the engine rather than for whoever runs it.
tier is TIER_REQUIRED, TIER_BASIC or TIER_ADVANCED, and it is
grouping and nothing else: which of the page's disclosures the field
sits behind. It is not a claim that the field is blocking. Whether a field is
blocking right now is asked of the connector that owns it at the moment the
page paints, because the same setting can be optional against one account and
unanswerable-without against another, and no flag in a static declaration says
both.
subject is SUBJECT_INSTANCE, SUBJECT_PROJECT or SUBJECT_SOURCE —
which of the three subjects on the page this setting is about. A connector's
settings are its source's, which is what an unstated one is read as. A
component that is not a source — a write adapter, a token verifier — says
SUBJECT_INSTANCE for itself rather than being guessed at.
topic decides which section of Instance settings, or which band of
a project's card, the row draws in — one name out of the closed set
configspec.TOPICS declares (the seven instance sections,
project-defaults, and the six regions a project's card draws). Unstated
on the option, it falls back to the declaration's own topic below; unstated
there, it falls back to "source" wherever the option's subject resolves
to SUBJECT_SOURCE — true of almost every connector's own settings, which
is why declaring none of this still lands your table on your own source's
card. A component whose settings answer to none of the closed set's
names — a write adapter with a genuinely standalone concern — falls back
to "component:<name>", its own section, titled with the component's own
name.
order decides the position within whatever topic placed the row
in, low first. Unset (0) draws after every row that stated one, in the
order your declaration lists them — enough for most connectors, whose
handful of settings rarely collide with anything the engine itself placed.
shown_when, optional and rarely needed, names a sibling key in
your own table and the condition on its value under which this row
exists at all: the row draws indented under that sibling and appears or
disappears the moment it changes, before Save. A webhook secret that only
matters once notifications are turned on:
from revetly.configspec import When
Option(
"notify", type=BOOL, default=False,
label="Send notifications", tier=TIER_BASIC,
summary="Post a message to this workspace when a read pass runs.",
),
Option(
"webhook_secret_env", type=STR, secret=True, env_name=True,
label="Webhook secret variable", tier=TIER_ADVANCED,
shown_when=When(key="notify", is_set=True),
summary="The environment variable holding the webhook's signing secret.",
),
The conformance kit refuses a shown_when naming a key your own
declaration does not have, and refuses one stating no condition or two at
once — the same rules the engine's own options are held to.
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:
project, projects, area_path, query, repos or
roots. Those say what is read wherever they appear in this engine, and a
project inheriting one of them is reading its neighbour's backlog. Being
required=True is not what disqualifies a key: Azure DevOps's
organization is required and is a connection key, because required is the
loader saying a value must be there and this one is half the URL.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:
revetly check
reports what is set and what required key is missing, and revetly doctor
names a value outside the set you accept. All three before a pass bills, and
all three in your own words.Three rules worth stating outright:
secret = True key is never written into revetly.toml. The page
refuses the save and points at the .env beside it, because that file is
committed and a credential in a git history is not recoverable. The pattern
to declare is the indirection: a token_env key, env_name=True,
holding the name of the variable. The page then renders a name and never
blanks it, and the value lives where the file cannot reach it.required means no default. A required setting has no fallback, so there
must be nothing for a page to prefill a cleared one with. The conformance kit
refuses a declaration that says both.required=True key must be tiered TIER_REQUIRED. These are two words
that look alike and are not: required is the loader's, and means a value has
to be there; the tier is the page's, and decides which disclosure the field is
behind. A declaration stating both and making them disagree asks for a value
and then hides the only control that could supply it — so the kit refuses that
contradiction by name, rather than rendering a field nobody can find and a
failure nobody can act on.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.
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.
revetly doctor is still where a value
outside the set you accept is named. Somebody who types a board you did not
list has told you
something you did not know — an endpoint catalogued before a board was made,
a name your paging cut off — and refusing it would be this seam asserting an
authority it has no basis for.Offer.could_not takes a fix, and it is the same seam a red
readiness line reads — so pass through what your typed errors already carry
rather than re-wording it here, because a page and revetly doctor disagreeing
about what to do next is worse than either of them saying nothing. And where
nothing can be done — an endpoint that serves no listing at all — leave fix
empty: its absence is the only signal there is that asking again is pointless.Roadmap — offering the bare name offers something your own resolve path
cannot turn into one record. Offer them apart (Roadmap (id 6cfCcrrCFg2xX7Pk)
is what this connector does), or do not offer them as interchangeable, and
refuse the ambiguous value on the read with a sentence naming every record it
could have meant. Picking whichever one the API listed last is a scope nobody
declared.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.
| 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.
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.
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:
It is a trigger, never a data path. Whatever your check returns decides
only whether the source is worth reading. When it says yes, what runs is the
same full-state extract() — never the delta your change feed handed you.
That is what makes a lost, duplicated or plain wrong signal cost freshness
only and never correctness, and it is why a full pass runs on a longer cadence
regardless of what you said.
commit is your whole next state, and the engine writes it after the pass
lands. Do not write the cursor yourself inside the check: a cursor moved
before the extract it authorised is a change nothing ever read, and your source
will not mention those records again.
A check that cannot answer is not a failure. Return
ChangeCheck(unavailable="the cursor expired") — or just raise — and the pass
falls back to the full read the check was trying to save. An expired cursor
costs the saving and nothing else.
detail is what you found, and only the half that found it speaks.
A check asking two questions — a tree and a remote, a cursor and a
webhook — has to join two answers into one sentence, and the trap is that the
half that found nothing usually says so in words: true, truthy, and one or
away from being served as the reason for a positive verdict. changed — no repository behind its roots has moved on its remote is a real row this engine
served, and it reads as a broken check rather than as the commit that had just
landed. Compose from the halves that fired; keep a fault neither half found —
a refused credential — behind whatever moved, so it is reported without
reading as the change.
Your source is only half of what "changed" means. If your last pass named
a kind in partial_kinds, it left a slice of the source unread — and no
question you can ask the source will find that out, because nobody touched it.
Ask yourself first:
def changed_since(self) -> ChangeCheck:
if owed := owed_work_check(self.ctx.state): # from revetly.connectors.base
return owed
...
The engine records what your partial_kinds left behind once the pass has
landed, under the same rule as your cursor; owed_work_check reads it and
hands you a changed answer that names the kind and how much of it is left, in
the words you wrote. It costs no request, so put it above the credential and
the config: a source that is unreachable does not stop an instance knowing it
owes itself a read. It commits nothing and narrows nothing — which threads the
triggered pass then opens is still your rotation's business.
Skip this and the kit says so by name. A bounded read that answers unchanged
on the next check is a rotation promising to cover the board every three passes
and covering it every three full-pass intervals, because nothing else was
ever going to wake it.
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.
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:
state-transitions,
field-revisions and comments, and one outside that list buys your source
nothing.windowed=False and state what a narrowed window costs: a user asking
for one quarter is entitled to know they are paying for the whole archive
before they pay for it.mutable_kinds=("comment",) says
this source lets somebody edit a record of that kind after the fact.
Empty — the default — says everything you serve as history is settled, which
is what makes a decade affordable: gated on identity, extracted once, never
re-billed. Todoist is the other case, and it is the reason this exists:
editing a comment there moves content, leaves posted_at alone and exposes
no marker of any kind, so the only signal that can see the edit is the body
hash — which is exactly what immutability switches off. A reader can then tell
"this source's history never changes" from "this source's history is watched
for changes" without opening your code, and the conformance kit holds your
artifacts to whichever you said.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:
records_a_moment=True. This is history rather than a work item: it has no
place in a backlog hierarchy, and no pass that never looked at the moment it
records gets to call it deleted. immutable=True implies it, so a connector
written before the two came apart says nothing new.
immutable=True, when it is true. A record of a moment that is over never
changes, so the manifest gates it on identity rather than on bytes: once
ingested it is never sent again, even when your serialisation differs on the
next read. Without it, a source that re-renders its own HTML re-pays extraction
for the entire archive on every pass. Immutable is not un-deletable — deletion
at the source still tombstones it under the ordinary rules.
Declare it only if your source really does forbid the edit. Where it does
not, drop it, name the kind in mutable_kinds, and the manifest gates that
kind on its bytes: an edit costs one episode, an untouched record costs
nothing. The two have to agree — an artifact claiming immutability for a kind
you declared editable is a finding, because it is precisely the shape in which
an edit never reaches the graph and nothing anywhere says so.
The time it happened. source_created_at is what makes one episode per
event that happened land at the moment it happened, which is the shape a
bi-temporal graph invalidates correctly. Leave it out and a decade of
transitions lands on the afternoon of the first ingest.
A source-declared relation to its work item. relations=[Relation(...)]
carries the source's own word for the anchor onto the deterministic path — no
provider call is spent deriving it, and it is what get_artifact on the work
item pages through as history_entries (history on that read, as on every
other, is the unread-past floor).
No PBI type. A revision is not a backlog item: it carries no type,
never appears in list_backlog, and no write adapter can address it.
Declaring one is refused at construction.
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:
projects narrows
its scope; Azure DevOps always does, because an organization and a project are
required there and there is no unnarrowed instance to be.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.
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:
--run it sends nothing. It calls history_windows, then
extract_history on each window, prices every one against this instance's own
measured history, and stops. So both methods are called, for real, in the step
a user takes to decide — they must be free of side effects and cheap enough to
run over the whole range.--dry-run calls them over your whole horizon, with since and until
both None, because the question somebody adopting a project asks is what all
of it would cost. Tile that case as your source's full reach rather than
returning nothing — history_windows(None, None) answering empty reads as "no
history on offer", which is the claim the declaration exists to avoid making
for you. docs/adopting-history.md is what your users read.--run sends under a declared ceiling, in episodes or projected spend,
and stops at a window boundary rather than partway through one. A window is
therefore the unit of everything: the unit priced, the unit checkpointed, the
unit retried after a transient provider failure. Tile a decade into four
windows and you have made the smallest stoppable increment two and a half
years wide.since, never at now. Tiled
backwards from the wall clock — the natural shape when a connector already has
a lookback — every boundary moves every time the clock does, so every key in
the checkpoint names a window nobody covered and a resumed backfill re-reads
the range it already paid for.immutable=True is what makes a resume free. The checkpoint skips windows
already covered; the manifest gate is what makes the ones it does re-read cost
nothing.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.
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.
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.
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.
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.
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.
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.
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