ADR-085: A recipe is an idempotent, additive provisioning event — one verb, no uninstall
Date: 2026-07-08 Status: Accepted (amended 2026-07-26 and 2026-10-23); superseded in part by ADR-130 (rule adoption is gone, built in PR-4 #1386; recipes create no dunning policy or webhook endpoint, built in PR-5 #1389)
Superseded in part by ADR-130, decided 2026-10-23. Built in PR-4 (#1386): rating-rule adoption, its conformance check (“Never republish an existing rule key”) and the 2026-10-23 amendment are gone, because a recipe’s prices belong to its new plan and touch no other plan. The plan’s currency comes from the recipe, not from adopted rules. The meter is read inside the apply’s transaction. Built in PR-5 (#1389): step 4 is gone, so a recipe creates no dunning policy and no webhook endpoint, and a recipe file holds only meters and plans with their prices. The fresh plan per apply, the badge and the meter aggregation check stay.
Summary
Section titled “Summary”Applying a recipe (POST /v1/recipes/{key}/instantiate) creates objects once and never removes them. There is no uninstall. The badge (a recipe_instances row) is checked before any write, so a repeat apply returns the existing row unchanged (same id, HTTP 201) and creates nothing. Amended 2026-07-26: the badge is unique per tenant, recipe and mode (test or live), so applying in test mode no longer counts as applying in live mode. A first apply always creates a new plan with a free code (<code>_2 if the code is taken), so an existing plan is never reused or changed. An existing meter is reused only if its aggregation matches the recipe. An existing rating rule is reused only if its money fields match (amended 2026-07-26). On any mismatch the whole apply is refused and rolled back. Rules are reused at their current version and never republished. Amended 2026-10-23: on a reused meter that a live subscription uses, the apply is also refused (409 meter_in_use) when one of its pricing rules would take usage from a price that bills it now. With both, an apply never changes the price of usage a live subscription is billed for. It can price usage that had no price, and the open period’s invoice then bills that usage. If a spend threshold already invoiced the subscription this period, a sum or count price bills only the usage after that invoice.
Context
Section titled “Context”ADR-083 (2026-07-07) gated recipe adoption on conformance: Instantiate adopted an existing plan by code or meter by key, and refused (409) if the adopted object’s money-affecting config diverged from what the recipe declared. That closed the silent-substitution bug, but the design panel that produced it kept spawning containment machinery on top of the underlying shape — plan conformance diff, provenance stamps (deferred), a Force flag reserved-but-unwired, a DELETE /v1/recipes/instances/{id} uninstall that only dropped the index row while the entities it named stayed live, and seed_sample_data scaffolding (RecipeSampleData/SampleCustomer/SampleSubscription, CreatedObjects.CustomerIDs/SubscriptionIDs) that was fully parsed and validated but had zero writers — nobody ever called it. Per feedback_complexity_accretion_smell: when a design keeps growing containment around a self-made bug class (adopt-by-key → conformance-diff → deferred provenance → force-flag escape hatch), the shape itself is wrong, not under-defended.
The self-made bug class was adopt-by-natural-key in the first place. A recipe adopting a plan by code requires proving the adopted plan matches the recipe’s intent — an open-ended, ever-growing surface (ADR-083 covered 5 fields and explicitly flagged tax_code as the next debatable one). The fix that dissolves the class instead of fencing it: a recipe never adopts a plan. Every apply mints a fresh plan. There is no “does this existing plan match?” question because there is no existing plan in the loop.
Once that’s the base case, the rest of the accreted machinery turns out to be answering a question nobody actually asks:
Uninstallpresumes a recipe owns what it created, the wayhelm uninstallowns a release’s rendered manifests. It doesn’t — the plan the recipe generated can carry live subscriptions the moment a customer signs up, and the rating rules/meter it adopted are shared reference data another recipe or the operator may also be using.DELETE /v1/recipes/instances/{id}(the design’s original contract) only ever dropped therecipe_instancesindex row — “no cascade” was called out as intentional indocs/dev/design/recipes.mdfrom the start. An operation that can’t retract anything isn’t an uninstall; it’s a badge deletion that makes the audit trail lie about what was applied.seed_sample_dataanswers “give me fake data to click around” — a demo-seeding need, not a provisioning one (feedback_no_seed_data_shortcut: no seed-demo-data buttons; empty + educate, or a separate demo tenant). It shipped fully parsed and validated and was never wired to a writer — the honest state of an unbuilt feature, not a working one.Forceexisted to blow away and redo an install. Once install never collides (plans are never adopted) there’s nothing to force through.
The actual repeat use case operators have is different from all of the above: re-run the same recipe key against a template that’s moved on — new model pricing, a new default plan shape — without disturbing the plan a live customer is already on. That’s an update, and Hardcount doesn’t build it here (see Deferred) — but naming it correctly is what falsifies “uninstall” as the missing piece. Nobody asks to un-provision a recipe; they ask to re-provision one, additively, next to what’s already there.
Decision
Section titled “Decision”A recipe is an idempotent, additive provisioning EVENT, not an owned resource. One verb: apply — POST /v1/recipes/{key}/instantiate.
recipe.Service.Instantiate (internal/recipe/service.go) runs, in one db.BeginTx(ctx, postgres.TxTenant, tenantID):
- Idempotency gate first.
store.GetByKeyTxchecks the badge (recipe_instances, keyedUNIQUE(tenant_id, livemode, recipe_key)since migration0157;0055shipped it mode-blind — see the 2026-07-26 amendment below). If it exists, return it — no further work, no writes attempted. - Adopt-or-create the shared catalog. Rating rules adopt an existing
rule_keyat its current version, never republish (see Money safety #2) — and only if its money fields match the recipe’s declaration, else refuse loud (2026-07-26 amendment under “Never republish”). Meters adopt an existingkeyonly ifaggregationmatches, else refuse loud (see Money safety #3). - Generate one fresh, born-unique plan.
freePlanCode(internal/recipe/service.go) takes the recipe’s desired code and, if it’s taken, walks<code>_2,<code>_3, … until it finds a free one — the incumbent is never touched, renamed, or read for comparison. Wired to the adopted/created meter; base fee amount andbase_bill_timingcome from the operator’s instantiate-time params (RecipePlan.BaseBillTiming, verbatim, no second-guessing); currency is inherited from the adopted rating rules, not from the plan’s own override. - Existence-checked create for the (optional) dunning policy and webhook endpoint —
UpsertPolicyTx/CreateEndpointTx, no adoption question because these aren’t natural-keyed shared state the way a plan code is. - Write the badge.
store.CreateTxpersistsrecipe_version+created_object_ids(domain.CreatedObjects) — the permanent record of what this apply touched.
Idempotency
Section titled “Idempotency”The badge is the gate, checked before any write. Re-applying an already-installed recipe is a no-op that returns the existing instance — same id, same created_objects, same HTTP 201. Never a 409, never a duplicate plan. Amended 2026-10-26: two applies that run at the same time can conflict. The second gets a 409 (the meter, the plan code or the badge already exists) and installs nothing; a retry returns the first one’s install.
Amendment (2026-07-26, m0157): the badge is scoped per livemode. 0055 shipped the badge mode-blind while everything a recipe provisions is mode-partitioned (0020) — so a test-mode install’s badge gated the live-mode install of the same recipe, which “no-op’d” into a Live dashboard showing Installed with zero live pricing objects. The badge now carries livemode (0020 treatment: mode-scoped unique, RLS predicate, session-stamped insert trigger), so “applied once” means once per mode, matching the objects the badge records. The class lesson: a mode-blind tenant table is only safe when its lookups ride FK-scoped mode-aware parents; the badge is addressed by a natural key (recipe_key), the one shape that argument doesn’t cover — TestRLSIsolation_AllModeAwareTablesHaveLivemodePredicate now derives the mode-aware set from the schema so the next such table can’t ship silently.
No uninstall, no dismiss
Section titled “No uninstall, no dismiss”There is no DELETE route on /v1/recipes/instances/{key} and no store method to drop one — Store (internal/recipe/store.go) exposes only GetByKey, List, GetByKeyTx, CreateTx. A recipe’s objects are owned by the operator the instant they exist: plans carry live subscriptions under ON DELETE RESTRICT with no hard-delete path, and the catalog (rating rules, the tokens meter) is shared reference data another install may also depend on. There is nothing an uninstall could safely retract. The badge is a permanent truthful record of what was applied and when. To retire the generated plan, the operator archives it — the existing plan-domain verb, no new mechanism needed.
Meter guard
Section titled “Meter guard”Adopting an existing tokens meter is gated on aggregation matching — the sole billing-consulted meter field (engine.go’s deferredBucket/mapMeterAggregation roll up usage off it; name/unit are display labels). On a mismatch, Instantiate refuses loud: errs.AlreadyExists("meter", …), naming the field ("usage aggregation: recipe wants sum, existing is max"), and the whole transaction rolls back, including any rating rules adopted or created earlier in the same call. Adoption only reads the meter row to wire it onto the fresh plan. It does add pricing bindings to the meter. Those change prices when they take events from a price that bills them now (amended 2026-10-23, see the amendment). A second AI recipe can still adopt the shared ADR-044 tokens meter post-go-live when its rules price only models no rule prices. No provenance system has to arbitrate “whose meter is it.”
Never republish an existing rule key
Section titled “Never republish an existing rule key”Usage bills the latest active version of a rule key as-of period start (resolveRatedRule → GetRuleByKeyAsOf, ADR-070) — that’s the pinned-at-period-open resolution every consumer (cycle close, preview, threshold scan) shares. If Instantiate published a new version of a key that already exists, every live subscription resolving that key would reprice at its next period open, silently, from an operator action that only asked to install a recipe. So rating rules are adopted at their current version, never republished — this is the load-bearing no-reprice invariant the whole apply design stands on. The direct consequence: recipe apply is additive, net-new only. It can never change an existing price. Changing a price is an explicit, separate pricing action (PATCH/publish through pricing.Service), out of recipe scope entirely.
Amendment (2026-07-26): rule adoption is conformance-gated, symmetric with the meter guard. As originally shipped, adoption was deliberately ungated — any same-key rule was bound at its current version regardless of its rates. That protected live subs (no republish) but let the preview lie: the dialog showed the recipe’s declared rates while the installed plan billed the existing rule’s. Same silent-substitution class this ADR killed for plans, so the same remedy: ruleConformanceDiff compares the money fields (mode, currency, per-unit rate, tiers, package size/amount, overage rate) against the key’s current active version, and a divergence refuses loud — errs.AlreadyExists("rating_rule", …) naming each diverging field, whole tx rolled back. An exactly-matching rule still adopts without republishing (CI-locked both directions: TestService_Instantiate_RefusesDivergentRatingRule / AdoptsMatchingRatingRule). Apply remains additive, net-new only.
Money safety
Section titled “Money safety”- Fresh plan is always meter-wired. Because a plan is never adopted — only ever generated fresh, in the same transaction as the meter it binds to — silent-$0 billing from an under-wired plan is structurally impossible on this path.
- Adopt-not-republish + plans immutable-once-live (the existing
UpdatePlanguard on plans carrying active subscriptions) means no re-apply of a recipe can reprice a live subscription. Rule versions don’t move under a live sub’s feet, and a live sub’s own plan can’t be rewritten by a recipe re-run. - Meter adoption is aggregation-only, and refuses loud on the one ambiguous case. The single billing-consulted field is checked; a mismatch stops the whole apply rather than mis-rolling-up usage under a silently wrong aggregation.
- Currency is inherited from the adopted/created rating rules, not independently chosen for the plan — a recipe can’t mint a plan priced in a currency its own rules don’t bill in.
Narrow honest limit: this does not close the general “a hand-authored, meter-less plan bills usage at $0 via a server-log WARN nobody tails” path. That’s a real gap, but it’s a boundary-detector problem across the whole pricing surface, not something a recipe’s own money-safety story can claim — it stays a deferred, separately-tracked item (see feedback_no_silent_fallbacks).
Why update-not-uninstall
Section titled “Why update-not-uninstall”The real repeat action operators take against a recipe isn’t “take this back” — it’s “re-run this against a newer template.” Verified against how adjacent tool classes handle exactly this shape:
- Billing platforms version prices by create-new-and-archive-old, never mutate a live price in place — Stripe Prices are immutable once created (you archive the old one and create a new one to change an amount); Orb’s pricing model is the same append-only-version shape. That’s precisely the constraint ADR-070’s adopt-not-republish rule encodes for Hardcount’s own rating rules.
- Generator/scaffolding tools hand off their output and have no uninstall —
create-react-app,vercel deploy, Backstage software templates all generate an artifact and step away; there’s no corresponding teardown command, because the generator doesn’t own what it produced once it exists. That’s the exact shape of a recipe-generated plan. - Infra-as-code tools that DO expose a teardown (Helm
uninstall, Terraformdestroy) do so because they continuously own and reconcile a set of managed resources across every subsequent change. A recipe doesn’t reconcile anything after the one apply call — it’s a single generative event, closer tohelm template | kubectl applythan to a Helm release. The tools in that same continuous-ownership category instead expose inspect + upgrade (helm get,helm upgrade), not delete-and-redo.
So “install” is the one real event a recipe performs. “Update” — re-applying additively against a newer template version — is the real repeat use case, deferred below because it needs execution semantics this ADR doesn’t build yet. “Uninstall” isn’t a real operation at all: nothing in the object graph a recipe touches is safe to retract, and the code’s own migration-era comment already said as much (docs/dev/design/recipes.md: “no cascade… this is intentional”).
Deferred
Section titled “Deferred”Each item below is cut for a named reason, with the trigger that reopens it:
- Additive update-execution, including conditional new-plan-on-packaging-change. Re-applying a recipe key today is a pure no-op once the badge exists — there’s no “the template moved, adopt what’s still compatible and mint what’s new” path. Trigger: a second version of a built-in recipe template ships and a tenant already holds an instance of the prior version.
- “Newer version available” nudge. Surfacing “anthropic_style v2 is out” on the recipe card. Trigger: update-execution above ships first — the nudge is pointless without a corresponding action.
- Re-apply preview-diff. Showing what an update-execution would change before committing, mirroring the existing install-time
Preview. Trigger: same as update-execution. - Push-reprice-to-live-subs, as an explicit opt-in migration. The only correct way to reprice existing customers is a deliberate, operator-initiated migration flow (Orb/Metronome-style price-migration primitives) — never a side effect of a recipe re-run. Deferred as its own design; recipes must never grow an implicit version of it.
- Object cleanup — archiving recipe-created meters/rules. Meters and rating rules have no archive path today at all (only plans do), so there’s no safe way to retract them even if a recipe wanted to offer it. Trigger: the first operator who needs to decommission a recipe-created meter, forcing the archive-meter design regardless of recipes.
- Real demo seeding. The removed
seed_sample_datascaffolding solved a demo-data need with provisioning-event machinery. Perfeedback_no_seed_data_shortcut, the right shape is empty-and-educate or a dedicated demo tenant, not a flag on the money-provisioning call. Trigger: none named yet — revisit if onboarding friction data shows first-run emptiness is a real drop-off point. - A general “unbilled usage is loud” detector. Closing the hand-authored-meter-less-plan $0 path named in Money Safety’s honest limit. Trigger: the first observed instance of silent $0 usage billing outside the recipe path.
Alternatives considered
Section titled “Alternatives considered”- The conformance gate (ADR-083, #407). Shipped one day prior; refuse-on-divergence for adopted plans/meters. Worked, but false-alarmed on legitimate cases (an operator’s own base-fee edit on a recipe-created plan, or a same-code plan from an unrelated source that happened to match closely but not exactly) and needed a provenance stamp to even tell “operator edited their own recipe plan” apart from “foreign collision” — a distinction the gate itself admitted it couldn’t make. Superseded here: once a plan is never adopted, there is no divergence to detect.
- Reuse-and-warn (ADR-084, #408 — never merged). A softer alternative floated between ADR-083 and this one: adopt a same-code plan if it’s “close enough,” warn instead of refuse. Rejected before merge — it dropped the meter aggregation check entirely and reopened the exact silent-under-billing hole ADR-083 had just closed. Referenced here only because it’s a real, considered, rejected design; it never shipped and
docs/adr/084-*.mddoes not exist on this branch. - A “dismiss” verb that deletes the badge but leaves the objects. Solves the UI’s “make this card go away” want without touching the entities. Rejected: the badge is the one place recording “this recipe was applied, here’s what it created” — deleting it while the plan/meter/rules live on makes the audit trail actively false. An operator or future support engineer reading
recipe_instanceswould see a smaller, wrong picture of the tenant’s history. transfer_lookup_key-style stable codes for the generated plan. Considered as a way to give a recipe’s plan a durable, human-legible slug across re-applies (mirroring Stripe’slookup_keytransfer between Prices). Rejected as unwarranted machinery:plan_codehas no functional reader in Hardcount — subscriptions bind by immutable planid, not code — so building transfer semantics for a field nothing reads is cosmetic complexity with no payoff.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- The plan-conformance / provenance / force-flag containment chain from ADR-083 is gone outright rather than grown further — the underlying adopt-by-key shape that produced the bug class no longer exists on the plan path.
- Re-applying a recipe is safe to retry blindly (network retry, double-click, at-least-once webhook-triggered automation) — it converges on the same instance every time.
- The badge (
recipe_instances) stays a permanently truthful record: everything it names is guaranteed to still exist (nothing it created is ever deleted by recipe machinery), so a future support/audit read of the table is never lying about tenant history. - Dead code shipped-but-never-wired (
seed_sample_data,Force,DELETE /instances/{id}) is removed rather than left as an attractive nuisance for the next contributor to assume works.
Risks / open items
Section titled “Risks / open items”- No update path yet. A tenant on an older recipe template has no in-product way to pick up new model pricing short of a manual pricing edit — named as the first deferred item, with its trigger stated above.
- Plan-code proliferation. A tenant that repeatedly hand-creates plans colliding with a recipe’s default code (or re-runs recipe families that share a default code) accumulates
_2,_3, … suffixed plans over time. Acceptable: plan code is a display slug, not a functional identifier, and the alternative (renaming or mutating the incumbent) is strictly worse. internal/domain/recipe.go’sRecipeSampleData/SampleCustomer/SampleSubscriptiontypes andCreatedObjects.CustomerIDs/SubscriptionIDsfields, and the YAML parsing/templating that populates them, are dead code left in place — parsed and validated at recipe load, never written byInstantiate. Tracked for removal alongside the next recipe-package cleanup pass; not a money-safety issue (nothing reads or writes through them), but it’s a doc-code-truth gap worth closing rather than leaving as a trap for the next reader who assumes a populated struct field means a working feature.
References
Section titled “References”- ADR-083 (recipe adoption conformance gate — superseded by this ADR)
- ADR-084 (#408, reuse-and-warn — never merged; referenced, not superseded, since it never shipped)
- ADR-070 (rating-rule resolve-as-of-period-start — the no-reprice invariant this design depends on)
- ADR-044 (canonical AI token-metering model — the shared
tokensmeter the aggregation guard protects) - ADR-031 (per-plan
base_bill_timing— how a recipe’s generated plan gets its timing) internal/recipe/service.go(Instantiate,freePlanCode),internal/recipe/conformance.go(meterConformanceDiff),internal/recipe/store.go,internal/recipe/handler.go,internal/domain/recipe.go- Migration
0055_recipe_instances(badge schema — unchanged by this ADR) - Memory:
feedback_complexity_accretion_smell,feedback_no_silent_fallbacks,feedback_no_seed_data_shortcut,feedback_longterm_fixes docs/dev/design/recipes.md(amended alongside this ADR)
Amendment 2026-10-23: pricing rules on a meter in use
Section titled “Amendment 2026-10-23: pricing rules on a meter in use”“No live-subscription clause” was wrong. A reused meter does not change, but the pricing rules the apply adds to it can. On a meter with a default price, a new rule takes events the default bills now. A rule that outranks an existing one takes its events. Either reprices the open period of every subscription on the meter.
UpsertMeterPricingRuleTx now runs the same check as the pricing endpoints
(ADR-070 amendment),
on the apply’s transaction. A draft, trialing or active subscription may use
the meter while a rule would change a price already billed. Then the apply
is refused with 409 meter_in_use and rolls back whole. A meter the apply
creates has no subscription, so a fresh install is never refused. Rules for
models no rule prices, on a meter with no default price, still go on. They
do change what a live subscription pays: usage of those models that had no
price, including usage already recorded this period, is billed on the
period’s invoice. One exception: if a spend threshold already invoiced the
subscription this period, a sum or count price bills only the usage after
that invoice. The usage before it stays unbilled, as the threshold invoice
left it out (ADR-096).