Who this is for: anyone whose backlog lives in a store this engine cannot
yet write to, once its read half exists. You need: a working read connector
for that store (or one this engine ships), write credentials for it, and an
instance where revetly doctor passes. Not here: reading the store
(writing-a-connector.md), the review gate itself
(nothing you write changes it), and driving the gate from a client
(mcp-client-surface.md).
A backend has two halves. A connector reads one source and hands Revetly artifacts — that is writing-a-connector.md, and it is the half you need first. A write adapter is the other one: it lands an approved Product Backlog Item in a store, amends it there, and closes it when it ships.
This page is only what differs. Everything about arriving as a plugin, being
handed a context, keeping credentials out of revetly.toml, and proving the
half before it costs anything works the same way on both, because it is
the same machinery.
Nothing here requires editing the engine.
Three differences worth having in mind before the details:
Four adapters ship in this engine, and between them they have already answered
most of what you are about to ask. All four arrive through the first door below
like anyone else's — there is no privileged in-tree path beside the one you use,
and revetly backends list shows yours beside them.
| adapter | what it is a worked answer to |
|---|---|
repo |
the reference implementation: this instance's own git files, ids minted locally, a flip the engine commits in the same commit as the delivery record |
todoist |
a store with fewer states than Revetly has. Only done writes; draft, ready, in-progress, parked and discarded are refused with a stop that says why, because labelling them would assert a lifecycle onto somebody's board that the store does not have. Criteria and citations ride the description, and the parent becomes a real subtask link |
azure-devops |
a store that mints its own ids and has more states than Revetly — assigns_ids_on_create, so the gate names no id the store would discard, and a declared status map validated against the process's real states before anything is written; in-progress needs no declaration either, landing in the type's first InProgress-category state |
github |
a store whose status is a state plus a reason plus a label, and whose type is native or a label depending on the owner. Every one of the six statuses writes; draft, in-progress and parked are labels on an open issue and set_status keeps only the one the target status names. A type is written as the repository's native issue type where the organisation offers one and as the mapped label:<name> otherwise, refused before any call when the map cannot make it. A parent becomes a real sub-issue link, and a create whose link fails reports the half-state rather than success. Over a repos list it is also the worked answer to several places a new item can land: repository_refusal names the ones it accepts, and every later write goes to the repository the item's id names |
The non-repo adapters also demonstrate the settings rule below: each reads its account, token variable and target from the read connector's table rather than a copy of its own, so the two halves cannot end up pointed at different accounts.
Three doors, the same three the read half has.
An installed distribution. Declare an entry point in the revetly.backends
group:
# your package's pyproject.toml
[project.entry-points."revetly.backends"]
acme = "revetly_acme:build"
Install it into the same environment as the engine and the name is selectable. Nothing is imported until an instance selects it — which matters more here than on the read half, because loading is the moment code capable of writing to a store enters the process.
A module inside the instance. No packaging, no image rebuild — the instance is mounted, so the file travels with it:
# the instance's revetly.toml
[backend]
pbi = "acme"
[backend.acme]
module = "backends/acme.py" # relative to the project root
factory = "build" # optional; "build" is the default
project_key = "PROJ" # anything else is yours
register_write_adapter(name, factory), for a process that embeds the
engine and for tests. It wins over both of the above.
An instance selects the adapter by naming it in [backend] pbi. A name that
resolves to nothing, an entry point that will not load, a module path that does
not exist, and a module shadowing an installed name are all hard stops that name
the fix and list what is registered. None of them falls back to another
store: a reviewer approves a draft knowing where it is going, and a store they
did not choose is a worse answer than no store at all.
pbi = "" is a declaration, not an omission — the instance writes nowhere,
and an approved draft is told so. A missing key is the repo store, so an
instance that says nothing is unaffected.
The factory takes an AdapterContext and returns your adapter:
def build(ctx):
return AcmeStore(ctx)
| on the context | what it is |
|---|---|
ctx.config |
the instance — project root, group id, routing, backlog shape |
ctx.settings |
your own [backend.<name>] table |
ctx.secrets |
credentials, from the environment and nowhere else |
ctx.log |
a logger named for your adapter |
ctx.name |
the name this instance selected you under |
Settings and secrets behave exactly as they do for a connector: reads are
recorded, so a key you never ask for is reported by revetly doctor as
declared-but-never-read; a credential value in revetly.toml is refused at
load, because that file is committed. Name the variable that holds it
(token_env = "ACME_TOKEN") and reach it with ctx.secrets.require("ACME_TOKEN").
There is deliberately no state slot. A connector keeps a cursor because it answers "what changed since last time"; you are handed one approved intent at a time and have nothing to remember between them.
If your store already has a read connector in the same instance, consider taking your settings from its table rather than your own — the Todoist adapter does, so the two halves cannot end up pointed at different accounts.
Declare it and revetly config renders your table as typed controls, 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. Declare nothing and the table is carried
to you verbatim, byte-identical on save, exactly as it was before this existed,
and reported unchecked rather than as passed.
from revetly.backends.base import Option, SettingsDeclaration, STR, TABLE
class AcmeStore:
name = "acme"
settings = SettingsDeclaration(
reads="which Acme project an approved draft lands in, and how this "
"engine's statuses map onto that board's own",
options=(
Option(
"project", type=STR, required=True,
summary="The Acme project new work items are created in.",
),
Option(
"states", type=TABLE, default={},
summary="This project's delivery status onto the board's own state. "
"Empty uses the store's defaults.",
),
),
)
The record is configspec.Option — the same one the engine's own options use,
not a second vocabulary — and everything §4b of
writing-a-connector.md says about it applies here
unchanged: the six types, required meaning no default, a secret = True key
that is never written into the committed file, and a key the declaration does
not name being kept and reported rather than dropped or refused.
If you read the read half's table, name it rather than declaring a second
one. A SettingsDeclaration(table="connectors.acme") says this adapter reads
that table, and the checked vocabulary for it becomes the union of both halves'
declarations — so a key only the write half reads is declared where the table
actually is, and neither half reports the other's keys as undeclared:
settings = SettingsDeclaration(
reads="nothing of its own: this adapter reads the [connectors.acme] table "
"its read half reads, so both halves are pointed at one board",
table="connectors.acme",
)
The conformance kit checks the declaration is well-formed (§6), with no provider, no graph and no instance.
One instance can hold several projects, each with its own namespace, root and ledger (multi-project.md). Four consequences for the store half, and the first two are the ones that write to the wrong place.
Every project selects its own store. [backend] pbi is inherited like any
other key, so a project that says nothing writes where the instance writes. A
project that re-states it selects for itself:
[backend]
pbi = "repo"
[projects.beta]
group_id = "acme-beta"
root = "../beta"
[projects.beta.backend]
pbi = "acme" # this project's approved drafts land in Acme
Your settings table is replaced, not merged — exactly as on the read half. A
[projects.beta.backend.acme] table drops every key it does not re-state, and
if you defaulted an account or a target rather than requiring it, that project
writes somewhere nobody named. Require what decides where a write lands.
A write must name its project, and there is no default for one. Landing a draft in a project nobody chose is a write into the wrong corpus, and the corpus is what the next person reads — so the mistake is invisible until it has been believed. That refusal is core's, not yours; you are simply never built for a project nobody named.
You are built once per project. ctx.config is that project's — its root,
its group_id, its records store. Keep nothing at module scope that belongs to
one instance's account, and read the ledger through the manifest you are handed
rather than one you cached: two projects pointed at the same store are two
ledgers, and get() refusing what this project does not hold (§3) is what keeps
them from amending each other's tickets.
revetly doctor --project NAME and --all walk the whole pre-flight chain per
project, including the write target each one resolved to. revetly backends list answers for the default project.
The whole contract is ProductBacklogItemWriteAdapter in src/revetly/backends/base.py. Eleven
members, and the load-bearing inversion is the first one: the store assigns the
id. create() returns the id the store minted, so a repo minting STORY-000
and Jira handing back PROJ-123 look identical to the loop above.
| member | what it answers |
|---|---|
name |
what this store is called in every message |
statuses |
the store's own delivery-status vocabulary |
mint_id(manifest, type) |
the next free id, or "" if the store assigns on create |
id_taken(manifest, id) / id_error(id, type) |
whether a reviewer's override is free, and well-formed for your family |
get(manifest, id) |
a live PBI of yours as a writable ProductBacklogItemRef, or None |
current(ref) |
what you hold right now, so a revision can be diffed against it |
criteria(ref) |
the criteria as written, read back for a delivery record |
create / amend |
the two mutations a reviewer's approval lands |
set_status |
flip the status, and say what the store now holds |
Three of them are where an adapter goes wrong quietly:
get must refuse what you do not own. The manifest carries every source the
instance reads, so an adapter that resolves by id shape alone will amend another
system's record the first time two sources share one ledger. Read access to a
source must never imply write access to it. Return None — and implement
unresolved_detail(manifest, id) to name who does hold it, because an id you
never had and an id a tracker holds both come back as None, and only the second
one has an answer worth giving.
Every ref carries a type. Core reads no type off a key, so a
ref that will not say leaves every parent rule with nothing to hold a draft to. A
store with one sort of backlog item answers that for everything — and declares
the shape in [backlog] rather than leaving core to assume three.
type was called level until this engine renamed it, when [backlog] levels became
[backlog] types: they are the sorts of PBI a project's backlog holds, and
whatever hierarchy exists between them is [backlog.parents] and never the
order of the list. ProductBacklogItemIntent.type and
ProductBacklogItemRef.type are the names.
level kept working beside type for a while, then was removed. Passing or
reading level on either is now a named stop that says the field is type —
because the alternative on a published contract is a bare TypeError about a
keyword, which names what stopped working and not what replaced it. mint_id
and id_error are still called positionally, so an adapter whose own
parameter is named level is called exactly as it always was.
A later pass finished the job on the surfaces that teach it. When the
rename above landed, the tools an adapter author would actually reach for
still said level, so this page described a contract the kit contradicted.
They say type now:
| what you call | said level until recently |
says now |
|---|---|---|
| the connector envelope | RawArtifact(level=…) |
RawArtifact(type=…) |
| the rehearsal helper | sample_intent(level="epic") |
sample_intent("epic") — first positional, or pbi_type= |
| the conformance kit | conformance.sample_intent(level=…) |
conformance.sample_intent("epic") |
| the CLI | revetly backends rehearse --level |
revetly backends rehearse --type |
| the ledger column | artifacts.level |
artifacts.type, renamed in place on connect |
Both sample_intents still accept level= in order to refuse it by name.
Dropping the keyword silently would have let a conformance run go green while
rehearsing the default type rather than the one you asked for, which is a wrong
answer wearing a clean report.
Your id family is yours. The family you mint into and the family you accept
are one family: a reviewer is shown the minted id at the gate and approves into
it — there is no field to type another — so a store whose own id_error refuses
its own suggestion refuses every draft. A store that mints on create declares
assigns_ids_on_create = True, returns "" from mint_id, and returns None
from id_error — there is no id to offer and none to refuse.
Two edges CONTEXT needs from a work item have no home in most trackers: what
grounded it (cited_sources, which mints CITES) and what it replaces
(supersedes). If your store has fields for them, use them. If it has none, put
both in the one free-text field it does have, with the codec every other tracker
uses — never a copy of your own:
from revetly import trailer
class AcmeAdapter:
# This store has no field for a citation, so both edges ride the description
# under a `## Revetly` heading, and the Acme *connector* splits them back out.
carries_trailer = True
def _payload(self, intent):
body = trailer.append(
capture.product_backlog_item_body(
intent.title, intent.description, intent.acceptance_criteria
),
intent.cited_sources,
intent.supersedes or [],
)
return {"title": intent.title, "description": body}
Writing it is half a mechanism, and the missing half is invisible. If the
connector of the same name does not call trailer.split and declare
*trailer.fields(), every artifact this engine authors into your store reaches
CONTEXT with the ids sitting in its body as prose and no CITES edge behind them.
The write succeeds, the body is intact, the ingest succeeds, and nothing reports
it — an edge that was never made leaves no wrong row to find. It shipped that way
on a real instance, uncited on every task it authored, which is why
carries_trailer is a
declaration rather than a comment: revetly doctor pairs it against what the
connector declares, and the conformance kit reads your rehearsed body back
through the same codec. Show the trailer in WriteRehearsal.body — it is part of
what would land.
The vocabulary is not yours to state. [backlog] types is the project's, a
draft is validated against it before it ever reaches you, and every type in it is
one you can create unless you say otherwise:
def creatable_types(self):
return tuple(t for t in self.project_types if self.can_translate(t))
Answer nothing at all and you create whatever the project declares — which is
the right answer for a store with one shape of artifact, and the answer the repo
adapter gives. Narrow it when there are types you genuinely cannot make, and a
draft naming one of the others is refused before a human is at the gate, listing
the ones you can, exactly the way an undeclared PBI type already is.
Implement type_refusal(pbi_type) to say it in your store's own
words.
Derive it; never write it down twice. Adapters used to each declare a
creatable_kinds() vocabulary of its own beside the type map, which is one fact
in two places: an Azure DevOps instance said Bug = "bug" in its map and bug
again in a second table, and the two could disagree. Read your map backwards
in its place — a store can create exactly the project types it can produce a source
word for — and there is one statement and nothing to keep in step.
There is one axis. This engine briefly carried a kind beside the type,
because a Bug had no tier of its own to be while the vocabulary was tiers.
The vocabulary became PBI types, connectors were made to translate onto
it, and the second axis was retired: ProductBacklogItemIntent.kind is gone, and a
submit_draft call still passing kind is told that pbi_type replaced it
rather than having it dropped.
Two consequences follow, and each is optional:
[connectors.azure-devops.types] forwards to ingest and
backwards to create, and two parsers over one table is how two halves of one
backend end up disagreeing about what a Bug is.Microsoft.VSTS.TCM.ReproSteps and carries no Description on its form — that
is a property of the target system's own configuration and not something you
can assume from here. Declare it ([connectors.azure-devops.fields] is that
connector's answer), read it on both halves so the ask and its citation trailer
round-trip through one field, and fail in a doctor_checks line rather than in
a write after somebody approved.Where a type carries a name your store also uses on the read side, keep it the
same word on both halves: an artifact created as a Bug that ingests as something
else is two artifacts in CONTEXT wearing one id. And everything your read half
translates onto a declared type ingests as kind = "pbi" — the umbrella over
stories, features, epics and bugs alike — with the project's word in
artifact_type and your store's own word kept in recorded metadata.
A type you translate onto nothing keeps its own word as its kind, stays out of
the hierarchy, and is never silently filed as a story when it is not one.
Every member past the eleven above is optional: assigns_ids_on_create,
carries_trailer, note_style, post_delivery_note, claim_of with
record_claim, hold, id_prefixes,
creatable_types, type_refusal, status_refusal, unresolved_detail,
owns, rehearse, doctor_checks and repository_refusal. Define the ones
your store has an answer for and leave the rest out. repository_refusal(name)
is for a store with several places a new item can land — the GitHub adapter
over a repos list — and answers "" for a name it accepts; it receives the
ProductBacklogItemIntent.repository a draft named. Leave it out and a draft
naming any repository is refused before the gate. When the engine builds your adapter it reads which
of them you define once, into a backends.Capabilities value, and core asks
that value from then on (backends.capabilities(adapter)). The value lives as
long as your adapter: the engine builds a store per act, and each build is read
afresh. Two consequences:
__init__. A flag you set on the adapter
after it was built is not read again for that adapter.declared capabilities rule (section 6) checks
that it does what it says.The answers that take an argument or read the project stay calls, made when
core asks: which types you create, which prefix a type mints under, the words of
a refusal. backends.holds_notes, backends.note_text, backends.hold,
backends.creatable_types and the other helpers keep their names and read the
same value.
Your failures arrive in front of a reviewer who has just approved something. Two rules follow from that.
Never swallow. A write that did not happen must never return as though it
did. Raise ConnectorConfigError when the store cannot be written to as declared
(a credential rejected, a target that does not exist) and SourceUnavailable
when the far side is having a bad minute — the same two the read half uses,
because "I am misconfigured" and "the far side is down" look identical at the
socket. Both take named=, a mapping over the one closed vocabulary in
refusal.Named (setting, keys, variable, values, ids, native_ids,
path, instead), for what your sentence names — named={"variable": "ACME_TOKEN"} — so a reader that wants the variable or id reads it there rather
than searching your words. It never changes the sentence, and a key outside the
vocabulary is a TypeError where you wrote it.
Say what to do. The engine catches your failure and finishes the draft's quarantine record with your message, then hands the caller a status and a detail. Nothing is retried and nothing reaches your store twice — a create that may or may not have landed is exactly the case where a retry mints a duplicate ticket in somebody's board. So your message is the whole of what the approving human gets: name the fix in it.
A create that landed and did not finish is CreateHalfLanded. Some creates
are two writes — the GitHub adapter creates an issue and then links it as a
native sub-issue — and only the first is atomic. When the second fails, the item
exists and a bare error would tell core nothing happened: the draft would be
finished as an error carrying no id, and the next approval would mint a second
item. Raise CreateHalfLanded(cause, ref=..., holds=..., missing=..., fix=...)
when the second step fails, carrying the ref the store assigned. Core records the
draft under that id exactly as on success, reports that the item exists but is
incomplete, and the next approval is refused naming the id already landed —
rather than repeating the create. holds and missing are phrases in your
store's own
terms — "GitHub issue #12 exists", "the sub-issue link under #10" — and
fix is what to do by hand. The exception's created_ref reads the same ref
for a caller that only knows the ad-hoc attribute it replaced. It is the create-side twin of
ShipHalfLanded below: nothing is retried and nothing is rolled back.
Your half of a ship is the flip. set_status(ref, status, evidence="", reason="") closes the PBI in your store and returns the ref. The engine does
the rest of the ship: it writes the delivery record into this instance's records
store, names the holder in held_by from the ledger row it already holds, says
in criteria_declared whether your criteria(ref) answered any, and commits the
record — with the flip beside it when your store names the file it edited.
The record lands here whichever store owns the ask, because no tracker has a
field for "criterion 3 departed, decided by the builder, because X". The flip is
ordered first, and the ordering is the engine's rather than yours: no record may
ever claim a ship that did not happen.
Say what your store now holds. WriteResult.holds is one phrase for a
person — f"the Acme ticket for {ref.id} is closed" — and the engine reports it
verbatim when the record then does not land. That half-state comes back as data
rather than as a failure: a status of incomplete, your phrase under
store_holds, and what was not written beside it. Nothing is retried and
nothing is rolled back. Do not reopen the ticket: a rollback is a second write
against a store that has just proved it cannot be relied on, and set_status is
the tool that reverses set_status.
Two fields answer for a store that is a file in this repository.
WriteResult.pending names the paths your flip left uncommitted in the instance
repository, and the engine commits them with the record — the repo adapter
returns the story file it just edited, and a store reached over an API leaves the
field empty. An optional hold(ref) returns a context manager the engine holds
open across the flip and the record together, so a writer amending between the
two is excluded; the repo adapter locks the story file, and an API-backed store
needs no hold.
Nothing about the record is yours. No adapter calls
capture.write_delivery_record, and none raises ShipHalfLanded — the engine
builds that half-state from holds. An adapter written to the earlier contract
that raises it is still understood; one that still writes a record of its own
writes a second copy beside the engine's, uncommitted and unread — remove that
write. And where the ask
lives in a store you only read, set_status … done produces the record alone,
with no flip and no store call (did: recorded-only) — so a read-only source of
record still gets a delivery history.
A ticket in somebody's board has nowhere to put the delivery record, so after the flip and the record land the engine renders the record into one comment and hands it to you. The member is optional in the protocol:
def post_delivery_note(self, ref: ProductBacklogItemRef, note) -> NoteResult: ...
note is a revetly.delivery_note.DeliveryNote, built from the written record.
You do not render it, find it or compare it:
delivery_note.upsert(thread, note, self.note_style) does the whole round
trip. What you write is the thread — seven small calls on one item, in your
store's own API — and one note_style on the adapter:
class AcmeThread:
def comments(self): ... # the item's comments, lazily, page by page
def text(self, comment): ... # a comment's stored text
def post(self, text): ... # post one; the store's row
def edit(self, comment, text): ... # replace its text; a row whose id names it
def link(self, comment): ... # where a reader of the board finds it
def read_body(self): ... # the item body as stored
def write_body(self, text): ... # replace it
note_style is a delivery_note.NoteStyle, how the store keeps text. The note ends in a marker carrying the
delivery id and a sha256 of its words; hides_comments=True spells it as an
HTML comment, which GitHub hides. A store that strips HTML comments (Azure
DevOps) or shows them raw (Todoist) declares hides_comments=False and the
marker is a visible last line. A store that keeps HTML adds
to_store/from_store (Azure DevOps: markdown_to_html/html_to_markdown),
and upsert converts both the note and the body through them. The engine reads
the same note_style to know the exact bytes it is about to send, so the
sensitivity scan reads those.
upsert holds you to what the shipped adapters do: one comment per delivery id,
edited in place when its words moved and left alone when they did not (changed
False, nothing written — shipping again and revetly deliveries post-note <DEL-id> both rely on that); a new delivery id posts its own; and one
Delivered: line in the body's ## Revetly section, written only when it
moved. It returns Posted(comment_id, comment_uri, changed). Two rules stay
yours:
notes author line to doctor_checks
naming that account where the store can say it.Return NoteResult from what upsert answered, adding holds, a phrase like
WriteResult.holds. Whether a store holds notes at all has one answer,
backends.holds_notes(adapter): the member is there. Core calls you through
backends.post_delivery_note(adapter, ref, note), which answers None for an
adapter without it — the repo adapter, whose record already sits beside the
story. It runs the exact text you will post (backends.note_text) through
sensitivity.scan like any outbound write, and
never calls you when the project sets [backend] tracker_note = false. A post
that raises after the record landed is reported as half-landed rather than as a
failed ship, and revetly deliveries post-note <DEL-id> completes it later,
which is why the member has to be safe to run twice.
set_status … in-progress is a claim: it records who started the item and when,
and refuses a second claimant by name unless their reason says why they are
taking it over. The engine asks your store, live, through two
optional members that come as a pair:
def claim_of(self, ref: ProductBacklogItemRef): ... # a claim_note.Claim, or None
def record_claim(self, ref: ProductBacklogItemRef, claim): ... # WriteResult or NoteResult
claim_of answers None when the store does not hold the item in progress, and
a claim by nobody named when it does but no claim was recorded (someone moved
it by hand), so a second claimant still stops to look. record_claim runs after
the flip, under the same hold. The repo adapter keeps the claim as a claim
block in frontmatter and names the file in pending, so it lands in the flip's
commit; a tracker posts one comment through claim_note.upsert(thread, claim, self.note_style) with the same thread a delivery note uses, edited in place on
every later claim. Clearing it is the flip's job: any other status removes the
repo's block, and on a tracker the status is the truth, so an old comment is
history.
A store without the pair still takes in-progress where its statuses hold it,
and the engine keeps the claim itself: one JSON file per item in a claims/
folder beside the deliveries folder in this instance's records store, written
in the flip's commit and removed in the commit of the next flip that moves the
item on (a done ship included). A second claimant is refused exactly as above,
and the answer carries claim_kept saying where the claim lives. What the
fallback cannot do is show the claim to people reading your store, so implement
the pair if your store has anywhere to put it. Nothing ingests these files; the
set_status gesture is their only reader. An item moved on by hand in your
store leaves its claim file behind until the next flip through set_status; a
takeover with a reason gets past it.
The conformance kit checks the pair: defining only one of the two members is a
finding naming the missing one, and assert_claim_round_trips(adapter, ref)
flips an item on your own fake to in-progress, records a claim, reads it back
and moves the item on.
The read half has its part: the connector of the same name reads a marked
comment back as a delivery-note artifact rather than a comment
(writing-a-connector.md, section 4).
Optional, and worth it for anything with a credential:
from revetly.backends.base import AdapterCheck, not_run
def doctor_checks(self, offline: bool = False):
checks = [AdapterCheck("credential", ok, self.ctx.secrets.describe("ACME_TOKEN"))]
if offline:
return checks + not_run("write project")
return checks + [
AdapterCheck("write project", ok, "'Roadmap' resolves", needs_network=True),
]
These join revetly doctor's chain with a pass/fail and a fix line, and
needs_network=True marks the ones --offline skips. Note that these are not
the read half's questions: a token that reads a board fine and a project that
accepts new tasks are different permissions.
Under revetly doctor --offline (and revetly backends prove --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 adapter, because it has no way to stop it reaching the store.
Everything here is free, and none of it touches a store.
from revetly.backends.conformance import assert_write_adapter_conforms
def test_the_acme_adapter_satisfies_the_contract():
assert_write_adapter_conforms(lambda: AcmeStore(fixture_ctx))
and the same kit as a command:
revetly backends prove acme # exits non-zero if the kit finds anything
One implementation behind both. It never calls create, amend or
set_status, on any branch, and the ledger it hands you is rows in memory — so
running it cannot land anything anywhere. It needs no store, no credential, no
graph and no active instance of its own.
The pass line also reports which PBI types this instance can create, which is
the question with no other answer — and the command runs your own doctor_checks
against your store afterwards, so a type you can translate that the target cannot
actually make fails here rather than at the gate. That half reads and writes
nothing; revetly backends prove --offline skips it.
| rule | what it holds you to |
|---|---|
protocol |
every member core calls is there and callable |
ownership |
an artifact another source holds resolves to nothing, naming the holder |
id family |
the id you mint validates against the family you declare, and is not already taken |
type |
every ref you return carries the type you assigned |
status vocabulary |
a non-empty vocabulary, stated by the store |
creatable types |
every type you say you can create is one this project declares, and rehearses like any other draft |
failure at the gate |
a rehearsal returns what would not land rather than raising |
citation trailer |
a store that carries_trailer writes a rehearsed body whose ## Revetly section reads back to the intent's citations and supersessions |
delivery note |
a tracker — a store that assigns_ids_on_create or carries_trailer — implements post_delivery_note |
delivery note round trip |
a note you post reads back through your read connector as one verified delivery-note, and posting it again writes nothing (checked by assert_delivery_note_round_trips(adapter, ref, read), against your own fake) |
create half-state |
a create whose later step fails raises CreateHalfLanded, not a bare error (checked by assert_create_half_state_conforms, against your own fake) |
declared capabilities |
each optional member you define behaves: a member core calls is callable, a note_style has a post_delivery_note to post with, the note text your store would be sent reads back as that note with its marker's hash intact, a store that narrows its types names the type it refused, and claim_of and record_claim are defined together and both callable. Nothing is posted |
hold |
hold(ref) answers a context manager that keeps a second holder out and lets it in once released (checked by assert_hold_excludes(adapter, ref), against your own fixture; a store with no hold is reported not checked) |
create mints the id |
a store that assigns_ids_on_create returns the id it minted from create (checked by assert_create_mints_id(factory), against your own fake) |
claim round trip |
a store whose statuses lists in-progress can be flipped to it and on again; with the claim pair, claim_of names the claimant record_claim was given (by name, without the email) while the item is in progress, and answers None once it has moved on (checked by assert_claim_round_trips(adapter, ref), against your own fake; a flip that raises is a status vocabulary finding, and a store that does not list in-progress is reported not checked) |
A failure names your adapter, the intent and the rule, with the fix — never a
diff, never a traceback, and redacted on the way out.
check_write_adapter(adapter) returns the same findings rather than raising.
Two rules need something from you before they can run, and the pass line says so
rather than implying they held: the type rule needs an id your store holds (pass
holds="PROJ-1" if your store assigns ids on create), and failure at the gate
needs a rehearsal.
Five checks write, and each is deliberately separate. If your store's create
is two writes, wire it to your own fake whose step after the create fails and
call assert_create_half_state_conforms(factory, intent=...): it holds you to
raising CreateHalfLanded rather than a bare error, naming the created ref.
assert_create_mints_id(factory) calls create once and holds a store that
mints on create to returning that id. assert_delivery_note_round_trips posts a
note, assert_hold_excludes(adapter, ref) takes your hold on a fixture item
twice, from two threads, and assert_claim_round_trips(adapter, ref) flips an
item's status twice and records a claim on it. Each mutates only against your fake or fixture, is
never reached by assert_write_adapter_conforms or revetly backends prove, and
the create check needs the intent when the failing step is only taken for a
draft with a parent.
For the part a kit cannot judge: whether what you would write is right.
revetly backends list # what is selected, from where, what it declares
revetly backends rehearse acme # a complete intent, driven through, nothing mutated
On an instance declaring more than one project, both take --project NAME and
answer for that project's [backend] pbi, settings table, credential and
ledger; naming none answers for the declared default.
Implement rehearse(intent, reviewer) returning a WriteRehearsal — the ref you
would hand back, the body you would put in the store, the StoreCalls you would
make, and, in failure, whatever would not land and why. Say in notes what
you cannot answer without a credential rather than guessing it.
def rehearse(self, intent, reviewer):
return WriteRehearsal(
ref=ProductBacklogItemRef(intent.id, f"acme://{intent.id}", intent.type),
body=capture.product_backlog_item_body(
intent.title, intent.description, intent.acceptance_criteria
),
calls=(StoreCall("POST /issues", f"project {self.project}"),),
)
It earns its keep twice. It is where an author sees the payload before a reviewer does — and the gate reads the ref off it to tell that reviewer where their approval sends this, in your store's own words rather than a repo file path.
A rehearsal is not a write and not an approval: nothing about it reaches a mutation, and the human gate stands in front of every real write exactly as it did.
revetly backends list # selected, origin, what it declares
revetly doctor # your checks run here, and your settings are reported
The gate is the one step you cannot change. It runs identically for every
store, because it is an AI-provenance trust primitive rather than a repo detail:
the authoring flow is the only
caller of create and amend, and it calls them only after a human approved.
There is no adapter-side route past it.
The read half has revetly connectors new; the write half has no scaffold
command, so the scaffold is below. Copy it into the instance, and nothing before
step 4 touches a store.
1. Start from this. One file inside the instance, no packaging and no image rebuild:
# backends/acme.py — anywhere under the instance root
from revetly import delivery_note
from revetly.backends import (
NoteResult, ProductBacklogItemContent, ProductBacklogItemRef, WriteResult,
)
from revetly.backends.base import (
AdapterCheck, SettingsDeclaration, StoreCall, WriteRehearsal, not_run,
)
class AcmeThread:
"""One Acme issue's comments and description, for `delivery_note.upsert`."""
def __init__(self, store, issue):
self.store, self.issue = store, issue
def comments(self):
yield from self.store._api_comments(self.issue)
def text(self, comment):
return comment["text"]
def post(self, text):
return self.store._api_comment(self.issue, text)
def edit(self, comment, text):
return self.store._api_edit_comment(self.issue, comment["id"], text)
def link(self, comment):
return f"https://acme.example/issues/{self.issue}#comment-{comment['id']}"
def read_body(self):
return self.store._api_get(self.issue)["description"]
def write_body(self, text):
self.store._api_set_description(self.issue, text)
class AcmeStore:
name = "acme"
# Acme numbers its own issues, so no id is offered at the gate and none is
# refused. A store you mint into declares neither of these.
assigns_ids_on_create = True
settings = SettingsDeclaration(
reads="nothing of its own: the [connectors.acme] table its read half reads",
table="connectors.acme",
)
def __init__(self, ctx):
self.ctx = ctx
# Read from the READ connector's table, so the two halves cannot end up
# pointed at different accounts.
acme = ctx.config.connector_settings.get("acme", {})
self.workspace = acme["workspace"]
self.token = ctx.secrets.require("ACME_TOKEN")
@property
def statuses(self) -> tuple[str, ...]:
return ("open", "closed") # the store's vocabulary, not Revetly's
def mint_id(self, manifest, type: str) -> str:
return "" # the store assigns on create
def id_taken(self, manifest, final_id: str) -> bool:
return False
def id_error(self, final_id: str, type: str):
return None # nothing to refuse; see mint_id
def get(self, manifest, artifact_id: str) -> ProductBacklogItemRef | None:
# Refuse what this instance merely READS. Sharing a ledger with another
# source is normal; amending that source's records is not.
if not artifact_id.startswith(f"acme:{self.workspace}:"):
return None
return ProductBacklogItemRef(artifact_id, f"acme://{artifact_id}", type="story")
def unresolved_detail(self, manifest, artifact_id: str) -> str:
# Name who does hold an id this store refused, so the reader is pointed
# at the right store rather than told the id is gone.
row = manifest.get(artifact_id)
if row is not None and row["connector"] != "acme":
return f"'{artifact_id}' is held by the '{row['connector']}' source, not by Acme"
return f"'{artifact_id}' is not a live Acme issue"
def current(self, ref: ProductBacklogItemRef) -> ProductBacklogItemContent:
issue = self._api_get(ref.id)
return ProductBacklogItemContent(title=issue["title"], body=issue["description"])
def criteria(self, ref: ProductBacklogItemRef) -> list[str]:
return self._criteria_from(self._api_get(ref.id)["description"])
def create(self, intent, reviewer: str) -> WriteResult:
issue = self._api_create(intent, reviewer)
ref = ProductBacklogItemRef(issue["key"], f"acme://{issue['key']}", type=intent.type)
return WriteResult(ref=ref)
def amend(self, ref, intent, reviewer, comment=None) -> WriteResult:
self._api_patch(ref.id, intent, reviewer, comment)
return WriteResult(ref=ref)
def set_status(self, ref, status, evidence="", reason="") -> WriteResult:
# Close it in the store, and say what the store now holds. The engine
# writes the delivery record and reports the half-state.
self._api_close(ref.id, status)
return WriteResult(ref=ref, holds=f"the Acme issue {ref.id} is closed")
note_style = delivery_note.NoteStyle(hides_comments=True)
def post_delivery_note(self, ref, note) -> NoteResult:
# The engine rendered the record; upsert finds, edits or posts the one
# comment for this delivery and points the description at it.
posted = delivery_note.upsert(AcmeThread(self, ref.id), note, self.note_style)
return NoteResult(
comment_id=posted.comment_id, comment_uri=posted.comment_uri,
holds=f"the Acme issue {ref.id} carries note {note.delivery_id}",
changed=posted.changed,
)
def doctor_checks(self, offline=False):
checks = [AdapterCheck("credential", True, self.ctx.secrets.describe("ACME_TOKEN"))]
if offline:
return checks + not_run("write target")
return checks + [
AdapterCheck("write target", True, f"{self.workspace} accepts issues",
needs_network=True),
]
def rehearse(self, intent, reviewer) -> WriteRehearsal:
return WriteRehearsal(
ref=ProductBacklogItemRef(
intent.id or "acme:new",
"acme://(assigned on create)",
intent.type,
),
body=intent.description,
calls=(StoreCall("POST /issues", f"workspace {self.workspace}"),),
notes=("the id is Acme's and is not known until the create lands",),
)
def build(ctx):
return AcmeStore(ctx)
2. Select it — and nothing else changes yet. The read half is untouched; enabling a connector still grants no write access and this grants no read:
[backend]
pbi = "acme"
[backend.acme]
module = "backends/acme.py"
3. Prove and rehearse it. Neither touches your store, on any branch:
revetly backends list # what is selected, from where, what it declares
revetly backends prove acme # the kit's rules; non-zero if one slips
revetly backends rehearse acme # a complete intent driven through, nothing mutated
revetly doctor # your checks, in the instance's own chain
The rehearsal is where you read the payload before a reviewer does — and where the gate gets the words it shows them for where this approval sends the draft.
4. The first landed write is a human's, not yours. There is no adapter-side way to trigger one: draft in the loop, and a person approves it in the chat.
/revetly-draft an issue that proves the Acme adapter lands what it rehearsed
On approval the engine calls create once, and the id in the response is the
one Acme minted. Read it back with the read half — revetly ingest --connector acme — and a ticket Revetly created and a ticket somebody typed into the board
are the same kind of artifact in CONTEXT, which is the whole point of the store
being theirs.