docs: OpenCoupon and OpenRecipe.md, the first two niche specs (#165)

Anthony: every top-level nichedb.dev niche may need its own open<niche>
spec so the serve-your-own-file pattern scales across industries. These
are the two he named first.

OpenCoupon: one JSON file a merchant serves at
/.well-known/opencoupon.json about what is on offer right now: every
code, sale and shipping threshold with kind (percent, amount, shipping,
bogo, gift, other), value, scope, min_order, dates, status, per-customer
and region limits. Expired coupons stay in the file so a directory
learns a code died from the one party that knows. No affiliate links,
no redemption, no votes. First reader: nichedb.dev/c/deals.

OpenRecipe.md: one Markdown file that is a recipe, in the OpenProfile.md
and OpenResume.md style: a summary block (Serves, Prep, Cook, Cuisine,
Course, Diet, Author, Source, Image), a description line, Ingredients
and Steps as written, Notes, Nutrition per serving. Served next to the
page, linked with rel="openrecipe", or indexed at
/.well-known/openrecipe.md. A one-way mapping to schema.org/Recipe:
the JSON-LD is generated from the Markdown, never the reverse.

Both registered in DOC_SLUGS, NAV, STATIC_ROUTES and llms.txt.


Claude-Session: https://claude.ai/code/session_014cmNRtR2vL1p89dbVQ7FZJ

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-12 19:16:34 -07:00 • committed by GitHub
parent 26295dd740
commit 8269c12b75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 742 additions and 0 deletions

168
docs/opencoupon.md Normal file
View file

@ -0,0 +1,168 @@
# OpenCoupon
OpenCoupon is one file a merchant serves about what is on offer right now: every coupon code, sale and free-shipping threshold it honours, with the terms, the scope, when it starts and when it ends. A coupon site reads the merchant's own file instead of a forum thread, a shopper's agent reads it instead of trying ten dead codes at checkout, and the merchant stays the author of its own promotions. It is maintained by Profullstack, Inc. as part of the LogicSRC open-standards surface.
Status: **0.1**. A description of a file a directory already reads, published so a merchant can serve one and any directory can read it.
Slug: `opencoupon`
## The problem
Every coupon site is a graveyard. A code is posted once, copied everywhere, and lives on for years after the merchant retired it, because no coupon site knows when a code died and the merchant has no way to tell them. Shoppers try five codes, four fail, and the one that works was for new customers only, which the listing did not say. The deal communities that do keep codes fresh do it by hand and by vote, and their terms forbid anyone else from reading the result.
The merchant already knows exactly which codes work. Its checkout is the source of truth, and the promotion lives in a table with a start date, an end date and a rule. What is missing is the one file that puts that table where a reader can fetch it.
## Terms
- A **merchant** is anyone who honours a promotion at its own checkout: a store, a service, a marketplace seller. Its **descriptor** is the file it serves.
- A **coupon** is one promotion: a code, a sale with no code, a shipping threshold, a gift with purchase.
- A **directory** is anything that reads descriptors and lists coupons across merchants: a coupon site, a browser extension, a shopping agent's cache.
- A **reader** is anything that reads a descriptor.
## The descriptor
A merchant serves a JSON document at `/.well-known/opencoupon.json` on its own origin.
```json
{
"merchant": {
"name": "Northwind Outfitters",
"web": "https://northwind.example",
"operator": "https://northwind.example/.well-known/openprofile.md",
"country": "US",
"currency": "USD",
"terms": "https://northwind.example/promotions/terms"
},
"updated": "2026-09-13T06:00:00Z",
"coupons": [
{
"id": "fall15",
"code": "FALL15",
"title": "15% off everything for fall",
"url": "https://northwind.example/?coupon=FALL15",
"kind": "percent",
"value": 15,
"min_order": 50,
"max_discount": 100,
"scope": { "excludes": ["gift-cards", "sale"] },
"starts": "2026-09-01T00:00:00Z",
"ends": "2026-09-30T23:59:59Z",
"per_customer": 1,
"new_customers": false,
"stackable": false,
"channels": ["online"],
"regions": ["US", "CA"],
"status": "active",
"updated": "2026-09-01T00:00:00Z"
},
{
"id": "ship-75",
"title": "Free shipping over 75",
"kind": "shipping",
"min_order": 75,
"regions": ["US"],
"status": "active"
},
{
"id": "boots-sale",
"title": "Trail boots, 40 off",
"url": "https://northwind.example/boots/trail",
"kind": "amount",
"value": 40,
"scope": { "products": ["trail-boot-2"] },
"price": { "was": 160, "now": 120 },
"ends": "2026-09-20T23:59:59Z",
"status": "active"
},
{
"id": "summer10",
"code": "SUMMER10",
"title": "10% off summer",
"kind": "percent",
"value": 10,
"ends": "2026-08-31T23:59:59Z",
"status": "expired"
}
]
}
```
The smallest valid descriptor is a merchant with a name and a coupon with a title:
```json
{ "merchant": { "name": "Northwind Outfitters" }, "coupons": [{ "title": "Free shipping over 75" }] }
```
The rules, and every one degrades:
1. **`merchant.name` and `coupons[].title` are the only required keys.** A reader lists what it was given and reports the rest as unstated rather than assumed.
2. **`merchant`** is who honours the promotion. `web` is the store, `country` an ISO 3166-1 alpha-2 code, `currency` the ISO 4217 code every amount in the file is in unless a coupon says otherwise, `terms` the page the promotions are under. `operator` is the person or organisation answerable, as an [OpenProfile.md](/openprofile) URL.
3. **`updated`** on the descriptor is when anything in it last changed; on a coupon, when that coupon last changed, and it wins for that coupon. Both are ISO 8601. A reader with the descriptor's `updated` unchanged since its last fetch may skip the rest.
4. **`id`** is stable for as long as the coupon is the same promotion. It is the dedupe key. Absent, the reader derives one from `code`, then from `title`, and a renamed coupon becomes a new one.
5. **`code`** is what the shopper types. Absent means there is nothing to type: the promotion applies on its own, and `url` is where it applies. A code is a string kept exactly as written, case included.
6. **`kind`** is `percent`, `amount`, `shipping`, `bogo`, `gift` or `other`. **`value`** is the number that goes with it: a percentage for `percent`, an amount in `currency` for `amount`, unused for `shipping`, the quantity bought for `bogo` (`value: 2` with `gets: 1`), and unused for `gift` where `gift` names what is given. **`price`** on a sale is `{ "was", "now" }` in `currency`, so a reader can show the cut without computing it.
7. **`min_order`** and **`max_discount`** are amounts in `currency`. **`scope`** narrows what the promotion applies to: `categories` and `products` are the merchant's own identifiers or URLs, `excludes` the same, and a scope with only `excludes` means everything but those. Absent scope means everything.
8. **`starts`** and **`ends`** are ISO 8601. Absent `starts` means already; absent `ends` means until the merchant says otherwise, and a directory shows that as no stated expiry, never as never. **`status`** is `active`, `scheduled`, `paused` or `expired`; absent is derived from the dates, and a stated status wins over the dates.
9. **`per_customer`** is how many times one customer may use it; **`uses`** how many times in total; **`new_customers`** a boolean; **`stackable`** whether it combines with another coupon; **`channels`** any of `online`, `store`, `app`; **`regions`** ISO country codes it is honoured in. Absent means unstated, and a directory that filters on one shows unstated rows as unstated.
10. **Unknown keys are kept.** A merchant says more than this document names, and a reader passes it through under the merchant's own key.
Serve it as `application/json`. The descriptor is a claim; that it came from the merchant's own origin is the verification, and it is the whole reason the file exists: a code fetched from the merchant's `/.well-known/` is a code the merchant says works.
## Expired coupons
A merchant keeps an expired coupon in the file, with `status: expired`, for at least as long as copies of it are likely to circulate. That is how the graveyard gets cleaned: a directory reading the file learns the code is dead from the one party that knows, and marks its own copy. A merchant may drop expired coupons after a while, and a directory that no longer sees an `id` marks it gone.
## Discovery
A reader finds a descriptor three ways, in this order:
1. `/.well-known/opencoupon.json` on the merchant's origin.
2. `<link rel="opencoupon" href="...">` in the HTML of the merchant's home page or checkout, or a `Link: <...>; rel="opencoupon"` header, when the file lives somewhere else.
3. A URL handed to the reader directly.
A descriptor is **verified** when it was fetched from the same origin as `merchant.web`, or from `/.well-known/` on the origin the reader was pointed at. One found by the third route on some other host is a claim about the merchant by whoever hosts it, and a directory marks it so.
A marketplace hosting many sellers serves one descriptor with the marketplace as `merchant` and a `seller` key on each coupon, or points each seller's page at the seller's own file with the link relation.
## Directories
A directory reading descriptors:
1. **Fetches on a schedule, hourly at least**, and whenever it is told the file changed. Coupons start and end on the hour.
2. **Dedupes on the merchant's origin and the coupon's `id`.** A re-read updates the row; it never adds a second.
3. **Shows expiry with the time it was read.** A code shown as active is active as of a stated moment.
4. **Keeps the merchant's words** and links to the merchant's `url`, unchanged. A directory that rewrites `url` to route through its own tracking has replaced the merchant's link with its own, and says so beside the link if it does.
5. **Reports absence as absence.** No `ends` is no stated expiry. No `regions` is unstated, not worldwide.
6. **Ranks the verified above the claimed.** A code from the merchant's origin outranks the same code from a forum, and a directory shows which is which.
The first directory reading OpenCoupon is the deals collection at [nichedb.dev](https://nichedb.dev/c/deals), which today reads the deal communities' feeds and lifts codes out of posts by hand. A merchant serving a descriptor is read in its own words instead.
## What is deliberately absent
**No affiliate links.** `url` is the merchant's. A directory that earns a commission does so in its own link, marked as its own, beside the merchant's.
**No redemption.** The file says a code exists and what it does. Whether the checkout accepts it for this shopper on this cart is the checkout's business.
**No ranking, no votes, no "verified today" badges.** The origin is the verification. A directory that adds a score labels it as its own.
**No prices except `price` on a sale.** The catalog is another document. A coupon names what it applies to; the store says what that costs.
## Serving one
By hand, from the same table the checkout reads. A store with a promotions table has everything the file needs; the file is that table, exported, at a fixed URL, with expired rows kept a while. A static site commits it next to `robots.txt`.
## Related standards
- [OpenServer](/docs/openserver): the same shape for a hosting provider's catalog. Both are a table the seller already keeps, served at a fixed URL.
- [OpenProfile.md](/openprofile): the `operator` behind a merchant.
- [OpenAccess](/openaccess): a `new_customers` or member-only promotion can name the entitlement it needs.
## Version history
| Version | Date | Change |
|---|---|---|
| 0.1 | 2026-09-13 | First publication: the descriptor, six kinds, scope, dates and status, expired coupons kept, discovery, what a directory owes a merchant. |
## License
The specification text is CC BY 4.0. Serve it, copy it, extend it.

170
docs/openrecipe.md Normal file
View file

@ -0,0 +1,170 @@
# OpenRecipe.md
OpenRecipe.md is one Markdown file that is a recipe: the ingredients, the steps, how many it feeds and how long it takes, in the form a person writes a recipe in and a printer prints it in. Any site can serve one next to the recipe page, any reader (a person, a cooking app, an agent, a grocery list, a directory) can read it without being taught a schema, and the file is the recipe rather than a copy of it. It is maintained by Profullstack, Inc. as part of the LogicSRC open-standards surface.
Status: **0.1**. Written in the same spirit as [OpenResume.md](/docs/openresume) and [OpenProfile.md](/openprofile): Markdown is canonical, every rule degrades, and the structured view is derived.
Slug: `openrecipe`
## The problem
A recipe on the web is four thousand words of memoir with the recipe at the bottom, and a block of JSON-LD in the head that search engines read and people never see. The JSON-LD is the machine copy, the page is the human copy, and they drift: the page says three eggs and the schema says two, because the author edited one and the plugin regenerated the other. A reader that wants the recipe scrolls; an agent that wants it parses the markup and hopes.
The form people actually write recipes in has not changed in a century: a title, a yield, a time, a list of ingredients, a list of steps, a note. That form is Markdown already. What is missing is the agreement on where the file lives and which lines mean what, so a cooking app and a grocery list read the same file the author wrote.
## The shape
```markdown
# Shakshuka
- **Serves**: 4
- **Prep**: 10 min
- **Cook**: 25 min
- **Cuisine**: North African
- **Course**: breakfast, dinner
- **Diet**: vegetarian, gluten-free
- **Author**: Ada Lovelace
- **Source**: https://ada.example/recipes/shakshuka
- **Image**: https://ada.example/recipes/shakshuka.jpg
Eggs poached in a spiced tomato and pepper sauce. One pan, bread on the side.
## Ingredients
- 2 tbsp olive oil
- 1 onion, diced
- 1 red bell pepper, diced
- 3 cloves garlic, minced
- 1 tsp ground cumin
- 1 tsp smoked paprika
- 800 g canned crushed tomatoes
- 6 eggs
- salt
- parsley, chopped (to serve)
## Steps
1. Heat the oil in a wide pan over medium heat. Cook the onion and pepper until soft, about 8 minutes.
2. Add the garlic, cumin and paprika. Cook 1 minute, until fragrant.
3. Pour in the tomatoes, season with salt, and simmer 10 minutes until thickened.
4. Make six wells in the sauce and crack an egg into each. Cover and cook 5 to 8 minutes, until the whites are set and the yolks still soft.
5. Scatter with parsley and serve from the pan.
## Notes
- Feta crumbled over the top before the eggs go in is not traditional and is very good.
- The sauce keeps three days; reheat it and poach the eggs fresh.
## Nutrition
- **Calories**: 260
- **Protein**: 14 g
- **Fat**: 16 g
- **Carbs**: 15 g
```
## The rules
There are eight, and every one of them degrades rather than fails.
**1. One `#` heading, and it is the name of the dish.** More than one and the first wins; none and the reader says the recipe has no name.
**2. The bullet list directly under the name is the summary block.** Each item is `Key: value`, with or without `**bold**` on the key. The keys a reader should understand:
- `Serves` or `Yield`: how much the recipe makes. `4`, `4 to 6`, `12 muffins`, `1 loaf`. A number alone is servings; a number with a word is that many of the thing.
- `Prep`, `Cook`, `Total`: durations as a person writes them, `10 min`, `1 h 30 min`, `overnight`. `Total` absent is `Prep` plus `Cook` when both are stated, and unstated otherwise. A reader that needs ISO 8601 durations derives them (`PT10M`) and never asks the author to write them.
- `Cuisine`, `Course`, `Diet`: comma-separated words, kept as written, matched loosely the way OpenProfile.md matches topics. `vegetarian`, `vegan`, `gluten-free`, `dairy-free`, `halal`, `kosher` are the diet words in common use.
- `Author`: a name, or an [OpenProfile.md](/openprofile) URL. `Source`: where this recipe was first published, which may be the page the file sits beside, and is how an adapted recipe credits the original. `Image`: an image URL.
- `Difficulty`, `Equipment`, `Keywords`: kept as written.
Unknown keys are kept as written, so `Oven`, `Season` and `Wine` all work without anyone having to add them to a list.
**3. A single prose line between the summary block and the first `##` is the description.** One line, the one a directory shows next to the name. More than one, and the rest is kept as prose.
**4. `##` opens a section.** The text is kept verbatim and normalised for matching, so `Ingredients`, `You will need` and `Shopping list` are one thing to a reader. The normalised names are `ingredients`, `steps` (also `method`, `directions`, `instructions`), `notes`, `nutrition`, `equipment` and `variations`. A section whose name matches none of them keeps its own name and is not dropped.
**5. Every bullet under Ingredients is one ingredient, written as a person writes it.** `2 tbsp olive oil`. `1 onion, diced`. `salt`. A reader that wants structure parses a leading quantity and unit when there is one, takes the rest as the ingredient, and keeps a trailing `, diced` or `(to serve)` as the preparation note; when it cannot parse, it keeps the line whole, and the line is still an ingredient. A `###` heading under Ingredients groups them: `### For the sauce`, `### For the dough`. Quantities are in the units the author cooks in; conversion is the reader's job, and doing it at write time destroys the information.
**6. Every numbered item under Steps is one step, in order.** A `###` heading groups steps the same way. A step may carry a time in its text (`about 8 minutes`), and a reader may lift it into a timer, but it stays in the text. A step may embed an image on its own line.
**7. Nutrition is per serving unless the section says otherwise.** `Key: value` with the unit written: `Protein: 14 g`. `Calories` has no unit. A `Per` key (`Per: 100 g`) changes the basis. Absent nutrition is unstated, never computed silently; a reader that estimates says it did.
**8. Source credits the original and Author names the writer.** An adapted recipe names where it came from in `Source` and says what changed in Notes. A reader that shows a recipe shows both.
## Discovery
The file is served, not registered. Three ways, and a reader should try all three.
**1. Next to the page.** A recipe page at `https://ada.example/recipes/shakshuka` serves the file at `https://ada.example/recipes/shakshuka.md` or `.../shakshuka/recipe.md`.
**2. A link element.** The page points at its own file:
```html
<link rel="openrecipe" href="https://ada.example/recipes/shakshuka.md">
```
or as a header, `Link: <...>; rel="openrecipe"`, on responses that are not HTML.
**3. A site index.** A site with many recipes serves `/.well-known/openrecipe.md`: a Markdown list, one recipe per bullet, `[Shakshuka](https://ada.example/recipes/shakshuka.md)`, newest first. A directory reads the index and then each file.
Serve it as `text/markdown; charset=utf-8`.
## schema.org
[schema.org/Recipe](https://schema.org/Recipe) is what search engines read, and a site that serves OpenRecipe.md should keep serving it. The mapping is one to one, and it goes one way:
| OpenRecipe.md | schema.org/Recipe |
|---|---|
| `#` heading | `name` |
| description line | `description` |
| `Serves` / `Yield` | `recipeYield` |
| `Prep`, `Cook`, `Total` | `prepTime`, `cookTime`, `totalTime` as ISO 8601 durations |
| `Cuisine`, `Course`, `Diet` | `recipeCuisine`, `recipeCategory`, `suitableForDiet` |
| `Author`, `Image` | `author`, `image` |
| Ingredients bullets | `recipeIngredient`, one string each, as written |
| Steps items | `recipeInstructions` as `HowToStep`, grouped by `###` into `HowToSection` |
| Nutrition | `nutrition` as `NutritionInformation` |
The JSON-LD is generated from the Markdown on every publish. **The Markdown is the canonical copy.** A site that stores the JSON-LD and renders Markdown from it has the drift this document exists to end.
## What is deliberately absent
**No required fields beyond the name.** A name and a list of ingredients is a valid recipe. So is a name and a list of steps.
**No unit system.** Grams and cups are both kept as written. A reader converts on display and says it did.
**No structured ingredient schema.** `1 onion, diced` is one line, and every cooking app already parses lines like it. A grammar for quantities is a reader's parser, not a writer's burden.
**No ratings, no comments, no story.** The page has those. The file is the recipe.
**No JSON.** A reader may derive a structured view (name, yield, times, ingredient lines with parsed quantities, steps, nutrition) and regenerate it from the Markdown on every read.
## Reading one
A conforming reader:
1. Fetches the file from any of the three discovery locations and parses it under the eight rules.
2. Keeps every line it does not understand.
3. Reports absence as absence: no stated time, no stated nutrition, no stated diet.
4. Matches Cuisine, Course and Diet loosely.
5. Shows `Source` and `Author` whenever it shows the recipe.
## Writing one
By hand, in any editor, in the time it takes to write the recipe. A site with recipes in a database exports one file per recipe from the same rows the page is rendered from, and generates its JSON-LD from the file.
## Related standards
- [OpenProfile.md](/openprofile): the `Author` behind a recipe, and the model for a Markdown file that is the canonical copy.
- [OpenResume.md](/docs/openresume): the same rules for a different document.
- [OpenCoupon](/docs/opencoupon): the same serve-your-own-file idea for a merchant's promotions.
## Version history
| Version | Date | Change |
|---|---|---|
| 0.1 | 2026-09-13 | First publication: eight rules, the summary block, ingredients and steps as written, three discovery locations, one-way mapping to schema.org/Recipe. |
## License
The specification text is CC BY 4.0. Serve it, copy it, extend it.