Manual 0.8.0

Writing a write adapter

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.


1. How your adapter arrives

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.

2. What you are handed

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.

Say what you read from that table

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.

2b. When the instance declares more than one project

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.

3. What you implement

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.

What your store has no field for rides one shared trailer

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.

Which types you can create is derived from what you translate

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:

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.

What you declare is read once

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:

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.

4. Fail where the human is standing

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.

The delivery note is yours to post, never to write

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:

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.

Who has it in progress

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).

5. Answer for yourself before a pass approves

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.

6. Prove it before it writes

Everything here is free, and none of it touches a store.

The conformance kit

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.

The rehearsal

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.

7. Check it in the instance

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.

8. The whole adapter, once, end to end

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.