Pricing Recipes — Technical Design
- Decided: one API call sets up a common pricing shape from a built-in template: a meter, its pricing rules, a plan, a payment-retry (dunning) policy and a webhook endpoint. Since 2026-10-26 a recipe creates only meters and a plan with its prices (item 12 below).
- Shipped: v0.1.0, in #23 (migration
0055_recipe_instances).- Current docs: Ready-made recipes in the AI pricing reference. The decisions made since, and where the body is out of date, are listed below. Where this page and the code disagree, the code wins.
Design document from 2026-04-25, kept as written. The code in
internal/recipe/is the live behavior. The current design is recorded in ADR-085 (applying a recipe is idempotent and additive; supersedes ADR-083), with the token pricing shape in ADR-044 and ADR-045, rule adoption without republishing in ADR-070, and the cross-currency meter refusal in ADR-100. Where the body below is now out of date:
- Only three built-ins ship (
anthropic_style,openai_style,replicate_style);b2b_saas_proandmarketplace_gmvwere dropped on 2026-05-14.- The token dimension is one
token_typeenum (input,output,cache_read,cache_write_5m,cache_write_1h), notoperation+ booleancached, so rule counts differ from the “9 rules” shown; per-unit rates are decimals (ADR-044/045).- The apply route is
POST /v1/recipes/{key}/instantiate. Uninstall (DELETE /v1/recipes/instances/{id}) was removed with no replacement. Retire a generated plan by archiving it; therecipe_instancesrecord is permanent.force=truewas removed. A plan-code collision creates a new plan (<code>_2,<code>_3, …) instead.seed_sample_dataand the YAMLsample_datablock were never wired to a writer and stay unused.- A plan is never adopted by code, so ADR-083’s plan conformance gate no longer exists. The shared
tokensmeter is still adopted byevent_name, and apply is refused with a 409 when its aggregation differs. Rating rules, and their adoption and currency checks, are gone (ADR-130, #1386).- Re-applying a recipe already installed in the same mode (test or live) is always a no-op that returns the existing instance (
201, sameid, samecreated_objects). The install record is per mode (ADR-085, 2026-07-26 amendment), so a test-mode install does not count as a live-mode one.- The dunning policy’s
final_actionwas removed from the recipe format (ADR-112).- The dunning policy’s schedule is one list of days,
retry_days(2026-10-23, ADR-036 amendment). It replacedmax_retriesandintervals_hours, which the parser now refuses.- A recipe meter is named by
event_name, notkey, and a price has noaggregation_mode: it bills under its meter’s aggregation (ADR-130 §2). The built-ins are now at 10.0.0 (anthropic), 9.0.0 (openai) and 7.0.0 (replicate), not the1.0.0shown.- A price’s rate is
unit_amount_centswithmode: per_unit(notflat/flat_amount_cents), and graduated tiers aretierswithup_to: nullon the last (ADR-130 §3, #1381).- A recipe file holds only
metersandplans, each plan with itsprices(meter,dimension_matchand the price shape), and an install creates nothing else (ADR-130 §15, #1389). Therating_rules,pricing_rules,dunningandwebhooksections below are gone, and a file that has one fails to load. So items 8 and 9 no longer apply, and the preview listsmetersandplansonly.
Motivation
Section titled “Motivation”Stripe Billing’s onboarding for an AI-native product is a multi-day chore: you create N Meters by hand (one per model × operation × cached combination), wire each into a Plan, attach a webhook, configure dunning retry logic (the automated retries after a payment fails), then mock the whole thing for QA. The first invoice is a week away. Buyers leave.
Hardcount’s AI-native wedge — its differentiating, AI-first pitch — includes pricing recipes: a single API call, ~30 seconds, walks away with a working anthropic_style setup — plans, meters, multi-dim (multi-dimensional) pricing rules, dunning, webhook endpoint, sample data. The picker UI in the dashboard means a non-technical CS rep can do the same thing.
This is the developer-experience flagship that turns the AI-native pillar from a slide into a 5-minute quickstart. Without it, the multi-dim meter machinery is invisible: no prospect builds a 12-rule anthropic_style setup by hand to evaluate it. With recipes, evaluation collapses to one POST.
This design depends on multi-dim meters being merged.
- One-call instantiation.
POST /v1/recipes/instantiateatomically creates the full surface area for a given recipe key (plans, meters, multi-dim pricing rules, dunning policy, webhook endpoint placeholder, optional sample data). All-or-nothing transaction. - Five built-ins shipping in v1:
anthropic_style,openai_style,replicate_style,b2b_saas_pro,marketplace_gmv. Each represents a real-world pricing pattern Hardcount-class buyers actually use. - Preview before commit.
POST /v1/recipes/{key}/previewreturns the full graph of objects that would be created, with no DB writes. Powers the “review and instantiate” dialog in the dashboard. - Embedded YAML, no DB recipe table. Recipes are versioned with the binary (
internal/recipe/recipes/*.yaml, compiled in via Go’sembed.FS, the standard library’s file-embedding mechanism). Tenants don’t author them in v1; built-ins only. - Per-instantiation overrides. Currency, customer-facing names, base prices accept overrides at instantiate time. Changes must round-trip through preview.
- Idempotency at the recipe-instance level. Re-running a recipe by
(tenant_id, recipe_key)returns the existing instance, no duplicates. Forced re-instantiation is a separateforce=trueflag for ops use. - Dashboard UI: pick → preview → instantiate. Track B (the frontend workstream) owns the React surface; this design IS the API contract Track B mocks against.
- Documented at
/docs/recipeswith copy-pasteable curl. Each recipe gets a one-pager describing the resulting object graph in plain English.
Non-goals (deferred)
Section titled “Non-goals (deferred)”- Tenant-authored recipes. Defer until a paying tenant asks. The maintenance cost — a tenant-recipe registry, validation, version migration, and a security review (a recipe is arbitrary infra-as-code) — is steep and unwarranted before product/market fit.
- Recipe upgrades / migrations across versions. A recipe is instantiated once. If Hardcount ships
anthropic_stylev2, instantiating it on an existing tenant either no-ops (idempotency match by key) or overwrites (force flag). No “upgrade my v1 instance to v2” path. Defer until the API stabilizes. - Marketplace / shared recipes from third parties. Phase 4+. Security model alone (signing, sandboxing) is its own design.
- Cross-tenant recipe templates. Each tenant gets its own copy. No shared plan catalog across tenants by design — RLS (Row-Level Security: Postgres itself filters every tenant-scoped query down to the current tenant’s rows) isolation is the whole point.
- Recipes that span Stripe configuration (tax codes per region, payment method preferences). Defer; the tenant’s own
tenantstripe(per-tenant Stripe configuration) settings already cover this.
Today’s surface (in repo)
Section titled “Today’s surface (in repo)”internal/pricing.Service.CreateMeter,CreateRatingRule,CreatePlan— narrow per-domain creates, no atomic-graph helper exists yet.internal/dunning.Service.UpsertPolicy— per-tenant retry schedule.internal/webhook.Service.CreateEndpoint— registers a webhook URL, returns the signing secret.internal/usage.Store.UpsertPricingRule— multi-dim rule per meter.- No coordinator. Today, building
anthropic_styleby hand is ~30 HTTP calls with manual ID-passing between them. Recipes collapse this to one.
Proposed schema changes — migration 00XX_recipe_instances
Section titled “Proposed schema changes — migration 00XX_recipe_instances”Migration number caveat: pick at PR-open time from
origin/main, not local branch (per memoryfeedback_migration_numbering). Placeholder used here.
-- 00XX_recipe_instances.up.sql
-- Tracks which recipes have been instantiated for which tenant. The-- objects themselves live in their normal tables (plans, meters,-- pricing rules, etc.); this table is a thin index that lets us:-- (a) idempotency-check on (tenant_id, recipe_key)-- (b) show the dashboard "anthropic_style instantiated 3 days ago"-- (c) clean up an instance via cascade if force-re-instantiatedCREATE TABLE recipe_instances ( id TEXT PRIMARY KEY DEFAULT 'hc_rec_' || encode(gen_random_bytes(12), 'hex'), tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, recipe_key TEXT NOT NULL, -- e.g. 'anthropic_style' recipe_version TEXT NOT NULL, -- e.g. '1.0.0' (from YAML frontmatter) overrides JSONB NOT NULL DEFAULT '{}', -- redacted operator-supplied overrides created_object_ids JSONB NOT NULL DEFAULT '{}', -- map of role → entity ID for cleanup created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by TEXT, -- operator user ID or "api" UNIQUE (tenant_id, recipe_key) -- idempotency anchor);
CREATE INDEX idx_recipe_instances_tenant ON recipe_instances (tenant_id, created_at DESC);
ALTER TABLE recipe_instances ENABLE ROW LEVEL SECURITY;CREATE POLICY recipe_instances_tenant_isolation ON recipe_instances USING (tenant_id = current_setting('app.tenant_id', true) OR current_setting('app.bypass_rls', true) = 'true');
GRANT ALL ON TABLE recipe_instances TO hardcount_app;Storing only the index and the created_object_ids map keeps the design boring. The canonical entities live in their own tables and obey their own constraints. If a tenant deletes a meter that came from a recipe, the recipe instance is unaffected — this is intentional. Cascading recipe deletion is a force=true operator flow, not a default.
API surface
Section titled “API surface”Wire-contract conventions (consistent with
/v1/*):
- Paths use hyphens:
/v1/recipes,/v1/recipes/{key}/preview,/v1/recipes/instantiate.- Recipe identity is the
keystring (e.g.anthropic_style), not an opaque ID. Keys are stable across versions;versionfield surfaces the underlying YAML revision.- Overrides are a flat JSON object (
{"currency": "EUR", "plan_name": "Inference Pro"}), keys defined per recipe. Unknown override keys for a given recipe → 400.
GET /v1/recipes — list available
Section titled “GET /v1/recipes — list available”GET /v1/recipesAuthorization: Bearer <secret_key>Response 200:
{ "data": [ { "key": "anthropic_style", "version": "1.0.0", "name": "Anthropic-style AI inference", "summary": "Per-token billing across model × operation × cached. 9 pricing rules, 1 multi-dim meter, monthly billing.", "creates": { "meters": 1, "pricing_rules": 9, "plans": 1, "rating_rules": 9, "dunning_policies": 1, "webhook_endpoints": 1 }, "overridable": ["currency", "plan_name", "plan_code"], "instantiated": { "id": "hc_rec_abc", "instantiated_at": "2026-04-22T10:00:00Z" } }, { "key": "openai_style", "...": "..." }, { "key": "replicate_style", "...": "..." }, { "key": "b2b_saas_pro", "...": "..." }, { "key": "marketplace_gmv", "...": "..." } ]}instantiated is null (or omitted) for recipes the tenant has not yet instantiated. The creates summary lets the picker UI render “12 pricing rules · 1 meter · monthly” without a separate preview call.
GET /v1/recipes/{key} — recipe detail
Section titled “GET /v1/recipes/{key} — recipe detail”GET /v1/recipes/anthropic_styleAuthorization: Bearer <secret_key>Response 200: same shape as one entry from GET /v1/recipes, plus a long-form description (markdown) and the full overridable_schema (per-key type + default + bounds).
POST /v1/recipes/{key}/preview — dry-run
Section titled “POST /v1/recipes/{key}/preview — dry-run”POST /v1/recipes/anthropic_style/previewAuthorization: Bearer <secret_key>
{ "overrides": { "currency": "USD", "plan_name": "AI API" }}Response 200:
{ "key": "anthropic_style", "version": "1.0.0", "objects": { "meters": [{ "key": "tokens", "name": "Tokens", "unit": "tokens", "aggregation": "sum" }], "rating_rules": [ { "rule_key": "gpt4_input_uncached", "mode": "flat", "currency": "USD", "flat_amount_cents": 30 }, { "rule_key": "gpt4_input_cached", "mode": "flat", "currency": "USD", "flat_amount_cents": 3 }, "..." ], "pricing_rules": [ { "meter_key": "tokens", "rating_rule_key": "gpt4_input_uncached", "dimension_match": { "model": "gpt-4", "operation": "input", "cached": false }, "aggregation_mode": "sum", "priority": 100 }, "..." ], "plans": [{ "code": "ai_api_pro", "name": "AI API", "currency": "USD", "billing_interval": "monthly", "base_amount_cents": 0, "meter_keys": ["tokens"] }], "dunning_policies": [{ "name": "AI default retry", "retry_days": [3, 1, 3, 7] }], "webhook_endpoints": [{ "url": "https://example.com/webhooks/hardcount", "events": ["invoice.finalized", "invoice.paid", "subscription.updated"], "_placeholder": true }] }, "warnings": []}warnings surfaces non-fatal conditions (“currency override EUR requires Stripe account in EUR mode”, “webhook URL is a placeholder — set a real URL post-instantiate”). The dashboard renders these inline.
POST /v1/recipes/instantiate — commit
Section titled “POST /v1/recipes/instantiate — commit”POST /v1/recipes/instantiateAuthorization: Bearer <secret_key>Idempotency-Key: <key>
{ "key": "anthropic_style", "overrides": { "currency": "USD", "plan_name": "AI API" }, "seed_sample_data": false, "force": false}Response 201:
{ "id": "hc_rec_abc", "key": "anthropic_style", "version": "1.0.0", "tenant_id": "hc_ten_...", "created_at": "2026-04-25T12:34:56Z", "created_objects": { "meter_ids": ["hc_mtr_..."], "rating_rule_ids": ["hc_rrv_..."], "pricing_rule_ids": ["hc_mpr_...", "..."], "plan_ids": ["hc_pln_..."], "dunning_policy_id": "hc_dpc_...", "webhook_endpoint_id": "hc_whk_..." }}Response 409 if the recipe is already instantiated and force=false:
{ "error": "recipe_already_instantiated", "instance": { "id": "hc_rec_abc", "created_at": "..." }, "hint": "Pass force=true to delete the existing instance and re-instantiate."}seed_sample_data=true additionally creates one demo customer + one trialing subscription against the new plan, so the tenant immediately sees usage flow. Default false; the dashboard’s onboarding wizard sets it true on first-time install.
force=true deletes the prior recipe_instances row plus all entities listed in its created_object_ids map (cascade-style cleanup), then runs the recipe fresh. Gated behind a separate operator-only API key scope (platform); secret keys cannot force.
DELETE /v1/recipes/instances/{id} — uninstall
Section titled “DELETE /v1/recipes/instances/{id} — uninstall”DELETE /v1/recipes/instances/hc_rec_abcAuthorization: Bearer <platform_key>Removes all entities listed in created_object_ids (best-effort; foreign-key blocks return 409 with the list of dependent objects, e.g. “plan still has 3 active subscriptions”). Platform-key only. Manual recipe cleanup for tenants whose recipe choice didn’t pan out.
Recipe definition format
Section titled “Recipe definition format”Recipes are YAML files in internal/recipe/recipes/, embedded into the binary via embed.FS. One file per recipe. Schema validated at boot — a malformed recipe panics at startup, not at first instantiation.
internal/recipe/recipes/anthropic_style.yaml (excerpt)
Section titled “internal/recipe/recipes/anthropic_style.yaml (excerpt)”key: anthropic_styleversion: 1.0.0name: Anthropic-style AI inferencesummary: | Per-token billing across model × operation × cached. 12 pricing rules, 1 multi-dim meter, monthly billing.description: | Models the public Anthropic pricing page as of 2026-04: 4 models (claude-3-opus, claude-3-sonnet, claude-3-haiku, claude-3.5-sonnet), 2 operations (input, output), with cache discounts on input. ...
overridable: - key: currency type: string default: USD enum: [USD, EUR, GBP, INR] - key: plan_name type: string default: AI API max_length: 80 - key: plan_code type: string default: ai_api_pro pattern: '^[a-z0-9_]+$'
meters: - key: tokens name: Tokens unit: tokens aggregation: sum # default for unclaimed events; per-rule modes override
rating_rules: - key: claude35_input_uncached name: Claude 3.5 Sonnet input (uncached) mode: flat currency: '{{ .currency }}' flat_amount_cents: 30 - key: claude35_input_cached mode: flat currency: '{{ .currency }}' flat_amount_cents: 3 # ... 10 more
pricing_rules: - meter: tokens rating_rule: claude35_input_uncached dimension_match: { model: claude-3.5-sonnet, operation: input, cached: false } aggregation_mode: sum priority: 100 - meter: tokens rating_rule: claude35_input_cached dimension_match: { model: claude-3.5-sonnet, operation: input, cached: true } aggregation_mode: sum priority: 100 # ... 10 more
plans: - code: '{{ .plan_code }}' name: '{{ .plan_name }}' currency: '{{ .currency }}' billing_interval: monthly base_amount_cents: 0 meters: [tokens]
dunning: policy: name: AI default retry retry_days: [3, 1, 3, 7] # days to wait before each retry # What exhausting all retries does — two independent decisions (ADR-112). final_subscription_action: pause # none | pause | cancel final_invoice_action: none # none | mark_uncollectible
webhook: events: - invoice.finalized - invoice.paid - invoice.payment_failed - subscription.updated - subscription.canceled url_placeholder: 'https://example.com/webhooks/hardcount'
sample_data: customer: external_id: demo-customer display_name: Demo Customer email: demo@example.com subscription: plan: '{{ .plan_code }}' trial_days: 14Templating
Section titled “Templating”{{ .currency }} etc. are resolved against the merged override map (defaults + per-instantiate overrides) using Go’s text/template. No conditionals, no functions, no loops in v1 — just substitution. Validated at YAML load time: every {{ .X }} must resolve to a declared overridable key.
Versioning
Section titled “Versioning”version field is semver. The version is recorded on recipe_instances.recipe_version so an operator can see which iteration their tenant ran. Hardcount does not migrate existing instances when a recipe’s YAML changes — the canonical entities live in their own tables and the operator owns them once they exist.
Internals
Section titled “Internals”Package layout
Section titled “Package layout”New per-domain package: internal/recipe/ with the canonical store/service/handler trio (data access, business logic, HTTP routes — the repo’s standard per-domain layering).
internal/recipe/ recipes/ # embedded YAML anthropic_style.yaml openai_style.yaml replicate_style.yaml b2b_saas_pro.yaml marketplace_gmv.yaml embed.go # embed.FS + Load() at boot parse.go # YAML → domain.Recipe; validation template.go # override merge + text/template render service.go # Preview, Instantiate, Uninstall handler.go # HTTP routes store.go # recipe_instances CRUD only postgres.go # store implService depends on:
pricing.Servicefor plan + meter + rating rule creationdunning.Servicefor policy upsertwebhook.Servicefor endpoint creationusage.ServiceforMeterPricingRuleupsertcustomer.Service+subscription.Serviceforseed_sample_data
The dependencies inject as narrow interfaces — each declared by the recipe package itself, naming only the methods it calls — so the recipe package owns no cross-domain state. Same pattern as internal/billing/engine.
Atomicity
Section titled “Atomicity”Instantiate runs in a single BeginTx(ctx, postgres.TxTenant, tenantID) — one tenant-scoped database transaction (TxTenant sets the RLS tenant variable for the transaction’s duration). All Service.Create* calls accept a *sql.Tx (already the convention via ExecContext on a transaction). Failure at any step rolls back the whole graph — no half-instantiated tenants.
// service.go (sketch)func (s *Service) Instantiate(ctx context.Context, tenantID, recipeKey string, overrides map[string]any, opts InstantiateOptions) (domain.RecipeInstance, error) { recipe, err := s.registry.Load(recipeKey) if err != nil { return domain.RecipeInstance{}, err }
if err := recipe.ValidateOverrides(overrides); err != nil { return domain.RecipeInstance{}, err }
rendered, err := recipe.Render(overrides) if err != nil { return domain.RecipeInstance{}, err }
tx, err := s.db.BeginTx(ctx, postgres.TxTenant, tenantID) if err != nil { return domain.RecipeInstance{}, err } defer postgres.Rollback(tx)
// Idempotency check first — avoids partial work if the row already exists. if existing, err := s.store.GetByKeyTx(ctx, tx, tenantID, recipeKey); err == nil && !opts.Force { return domain.RecipeInstance{}, errs.ErrAlreadyInstantiated } if opts.Force { if err := s.deleteInstanceTx(ctx, tx, tenantID, recipeKey); err != nil { return domain.RecipeInstance{}, err } }
objs := domain.CreatedObjects{}
// Create in dependency order. Each Create* takes the tx so the // graph is one atomic unit. // meters → rating_rules → pricing_rules → plans → dunning → webhook → sample_data // ... (~150 lines of similar)
inst, err := s.store.CreateTx(ctx, tx, domain.RecipeInstance{ TenantID: tenantID, RecipeKey: recipeKey, RecipeVersion: recipe.Version, Overrides: overrides, CreatedObjects: objs, }) if err != nil { return domain.RecipeInstance{}, err }
if err := tx.Commit(); err != nil { return domain.RecipeInstance{}, err } return inst, nil}Where today’s Service.Create* methods don’t accept a *sql.Tx (most don’t), Week 3 work adds the *Tx variant alongside the existing method. Both delegate to the same SQL — no dual code paths.
Preview (no-DB)
Section titled “Preview (no-DB)”Preview reuses recipe.Render(overrides) and returns the in-memory graph. No DB writes, no BeginTx. Cheap; safe to call on every dialog open.
Built-in recipes — content shape
Section titled “Built-in recipes — content shape”The five v1 recipes are calibrated against real public pricing pages for the wedge-aligned customer profile:
| Recipe | Models / Pattern | Pricing rules | Use case |
|---|---|---|---|
anthropic_style |
claude-3.5-sonnet, claude-3-opus, claude-3-sonnet, claude-3-haiku × {input, output} × {cached, uncached} | 12 | LLM API resellers / inference platforms |
openai_style |
gpt-4-turbo, gpt-4o, gpt-3.5-turbo × {input, output, embedding} × {cached, uncached} | 14 | LLM API resellers / inference platforms |
replicate_style |
per-second GPU billing across {a100, a40, t4, cpu} with last_during_period for spot duration |
4 | GPU-time platforms |
b2b_saas_pro |
seat-based with last_ever aggregation + per-seat overage |
2 | Vanilla SaaS that needs Hardcount for compliance, not AI |
marketplace_gmv |
percentage-of-volume with min/max, monthly settle | 1 plus count for transaction count |
Marketplaces using Hardcount without Connect |
Each recipe gets a one-pager at /docs/recipes/{key}.md (Track B’s /docs/recipes content surface) with copy-pasteable curl, a screenshot of the resulting dashboard, and a pricing page comparison.
Unit tests
Section titled “Unit tests”- YAML load + validate: each of the 5 built-ins parses cleanly at boot. Malformed test fixtures return clear errors.
- Override validation: unknown keys → 400; type mismatches → 400; defaults applied when omitted.
- Templating:
{{ .currency }}substitutes; missing template variable returns a clear error at load time, not at render.
Integration tests (real Postgres)
Section titled “Integration tests (real Postgres)”Instantiate(anthropic_style)produces a tenant with: 1 meter, 9 rating rules, 9 pricing rules, 1 plan, 1 dunning policy, 1 webhook endpoint. Verify object counts via direct SQL.Instantiatetwice withoutforce→ second call returns 409 with the existing instance ID.Instantiatetwice withforce=true→ second call returns a fresh instance, the first instance’s objects are gone.- Atomicity: inject a failing
pricing.CreatePlanmid-instantiation; verify zero objects exist post-rollback (no orphan meters / rules). - RLS: instantiate
anthropic_stylefor tenant A; tenant B’sGET /v1/recipesshowsanthropic_stylewithinstantiated: null. Previewon tenant A produces an identical object graph to whatInstantiatethen creates (modulo the IDs).
End-to-end test (against the running stack)
Section titled “End-to-end test (against the running stack)”cmd/hardcount-recipes-e2e/main.go: instantiate anthropic_style, post 1000 usage events through the new meter, run the cycle scan, assert the resulting invoice line items match the recipe’s expected line breakdown. Catches contract drift between this design and the multi-dim meter implementation.
Migrations
Section titled “Migrations”00XX_recipe_instances— new table per the schema section above.
Decimal & numeric considerations
Section titled “Decimal & numeric considerations”Recipes inherit the NUMERIC(38, 12) quantity type from usage_events; no recipe-specific changes. All amount fields are integer cents per ADR-005. Currency conversion is the operator’s responsibility (Stripe handles the actual settlement).
Performance
Section titled “Performance”GET /v1/recipesis a constant-time read ofembed.FS+ one indexedrecipe_instancesquery. Cheap.Previewis pure in-memory render; no DB.Instantiateis a single transaction with ~30 INSERTs for the largest recipe (openai_style). Well under 500ms on dev hardware. No batching needed — instantiation is rare per tenant.- Recipe registry loads once at boot; no per-request file I/O.
Open questions
Section titled “Open questions”- Should
instantiateaccept inline overrides forpricing_rules.dimension_matchkeys? Proposal: no for v1. Recipe authors choose dimension shapes; tenants override prices but not dimension structure. Revisit if a design partner wantsgpt-4-turbopricing on theanthropic_stylerecipe. - Should
seed_sample_datacreate atest_clock? Proposal: no. Sample sub starts withtrial_days=14real-time; ops can attach a clock manually if they want to fast-forward. Keepsseed_sample_datasemantics simple. - Should
force=truecascade-delete subscriptions that reference the recipe’s plan? Proposal: no. 409 with the dependents list, force the operator to migrate or cancel subscriptions first. Auto-cascade on subscriptions risks losing real billing data. - Where do recipe webhook endpoints get their real URL? Proposal: created with
url_placeholderfrom YAML (https://example.com/webhooks/hardcount), plus anis_placeholder=trueflag. Dashboard surfaces a “set webhook URL” prompt next to the instantiated recipe. Endpoint stays inactive (no events dispatched) until URL is updated andis_placeholdercleared. Open: does inactive-endpoint surface need a new column onwebhook_endpoints, or do we reuseenabled=false? Lean towardenabled=false— same observable behavior, no schema delta. - Should preview be cacheable? Proposal: no for v1. Reads happen at most a few times per tenant. Cache invalidation across YAML version bumps + override permutations is its own problem.
- Recipe ordering in
GET /v1/recipes— is there a “featured” notion? Proposal: alphabetical, no featuring v1. The picker UI (Track B) can apply its own ordering; backend doesn’t pick favorites. - Should built-in recipes ship with
webhook.eventsset or empty? Proposal: set per recipe.anthropic_styleandopenai_styleget the AI-relevant events (invoice.finalized,usage.threshold_crossedonce Week 5 ships);b2b_saas_progets the SaaS-classic ones (subscription.created,subscription.canceled). Concrete defaults beat empty-and-let-the-tenant-figure-it-out.
Implementation checklist
Section titled “Implementation checklist”- Migration
0055_recipe_instances.{up,down}.sql -
domain.RecipeInstancestruct,domain.Recipe(parsed YAML),domain.CreatedObjects -
internal/recipe/embed.go—embed.FSand registry -
internal/recipe/parse.go— YAML schema + validation -
internal/recipe/template.go— override merge +text/templaterender -
internal/recipe/store.go+postgres.go—recipe_instancesCRUD -
internal/recipe/service.go—Preview,Instantiate,Uninstall - HTTP handlers:
GET /v1/recipes,GET /v1/recipes/{key},POST /v1/recipes/{key}/preview,POST /v1/recipes/instantiate,DELETE /v1/recipes/instances/{id} -
*Txvariants onpricing.Service,dunning.Service,webhook.Service,usage.Servicefor transactional graph creation - 5 built-in recipes in
internal/recipe/recipes/*.yaml - Per-recipe one-pagers in
docs/recipes/*.mdrendered at/docs/recipes - Unit tests: YAML parse, override validation, template render
- Integration tests: instantiate / preview / force / RLS / atomicity rollback
- End-to-end coverage — delivered as MANUAL_TEST FLOW S2 (recipe instantiate → in_advance plan → LiteLLM ingest → hybrid cycle invoice → public cost dashboard) plus the integration tests above; the
cmd/hardcount-recipes-e2ebinary this line originally named was never built (no commit ever contained it) - OpenAPI spec update (
api/openapi.yaml) - CHANGELOG entry + public changelog rollup
Track B unblock
Section titled “Track B unblock”Track B can scaffold the recipe-picker UI against this design today. The contract Track B should mock:
GET /v1/recipes→ list withkey,name,summary,creates,overridable,instantiatedGET /v1/recipes/{key}→ detail with markdowndescriptionPOST /v1/recipes/{key}/previewbody{ overrides: {} }→ object graphPOST /v1/recipes/instantiatebody{ key, overrides, seed_sample_data, force }→ 201 withid+created_objects
The picker UI shape Track B should aim at:
- Card grid of recipes (hero name + summary + creates-count badges)
- Click → detail drawer with description + override form (per
overridable_schema) - “Preview” button → preview modal showing the object graph (collapsible per type)
- “Instantiate” button → confirmation → success state with deep-links into the created plan / meter / webhook detail pages
Track B can ship the UI against a mocked API (e.g. MSW — Mock Service Worker — handlers) before Track A finishes the backend, then swap to the real API at integration time. This is the design-first / RFC parallel-work pattern.
Review status
Section titled “Review status”- Track A author: drafted 2026-04-25
- Track B review: pending — Track B can scaffold the picker UI against this design without waiting for implementation
- Human review: pending — please flag any open question to resolve before Week 3 starts (May 9)