OpenListing 0.1: one file a seller serves about a thing on offer

A house for sale, an apartment to rent, a car. One document per listing,
on the seller's own origin, so a directory reads the seller's file
instead of licensing somebody else's database or scraping a marketplace
that forbids it.

**The name `OpenRental` was the obvious one and it is taken.** It
already means renting OpenAgent and OpenSwarm members through CoinPay,
with a published schema, SDK, validators and fixtures. Its own document
records surviving one collision already: it was OpenFleet until
2026-09-13. Reusing the name for housing would have broken a shipped
0.1 spec, so the family is OpenListing instead.

Two axes rather than a spec per combination. A house for sale and a
house to rent are the same house; only the offer differs. A house for
sale and a car for sale are the same offer; only the subject differs.
Writing OpenHouseForSale, OpenHouseForRent, OpenCarForSale and the rest
produces one spec per cell of a grid, each repeating most of the others.
So `offer.type` carries sale/rent/lease/auction/free/wanted and
`subject.type` carries property/vehicle, and a subject profile adds only
the fields peculiar to its subject. A reader that knows OpenListing but
not a profile still reads price, location, offer and dates correctly.

`openhouse` is not used as a name: in real estate an open house is a
viewing event, not a kind of listing. An actual one is an entry in
`showings`.

The problem section is grounded rather than asserted: RESO tracks 484
separate MLSs as of August 2026, down from nearly twice that in 2015;
each does publish a RESO Web API feed, and each requires a real estate
licence, a signed per-MLS data agreement and vendor credentials. Four
hundred and eighty-four negotiations for one national view, not
redistributable. Vehicle parts are the same shape with different
letters (ACES/VCdb, TecDoc).

Decisions worth naming. Prices are decimal STRINGS, because 2450.10 is
not representable in binary floating point and a listing is a price.
Absent means unstated, never zero -- with `bedrooms: 0` called out in
the property profile as the one place where zero genuinely differs and a
studio is not an unstated bedroom count. `location.precision` lets a
seller publish a postal code rather than a street for an occupied home,
and forbids a reader from drawing a pin on a building anyway. A closed
listing SHOULD stay served for 90 days with `closed_at`, because a
directory that learns from a 404 cannot tell "sold" from "outage" --
the failure that makes every coupon site a graveyard.

The vehicle profile treats a VIN as a claim rather than a proof, points
readers at NHTSA vPIC (free, keyless, public domain) for decoding rather
than making sellers restate what the VIN encodes, and puts parts fitment
explicitly out of scope: that is ACES/VCdb or TecDoc, and a public spec
cannot restate licensed data.

Registered as one parent and two blocks under it, using the registry's
existing `parent` field the way OpenServer's blocks already do. Flat
slugs rather than nested paths because /docs/[slug] is a single dynamic
segment, not a catch-all -- a nested slug would not have routed at all.

nichedb.dev is named as the reference directory.

50 contract tests pass including spec-discovery and page-metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-25 10:10:21 +00:00
parent 699eff427a
commit 7aeba32908
4 changed files with 359 additions and 0 deletions

View file

@ -95,6 +95,9 @@ export const FAMILIES: Family[] = [
s("opensaas", "OpenSaaS", "One file a subscription service serves about the way in and the way out of every plan, for a person and for an agent"),
s("openmodel", "OpenModel", "One file a model provider serves about the models it serves and what they cost: price per million tokens, context, modalities and what each model can do", { landing: undefined }),
s("openaffiliate", "OpenAffiliate", "One file a merchant serves about the commission it pays"),
s("openlisting", "OpenListing", "One file a seller serves about one thing it is offering: what it is, what it costs, on what terms and where, with the offer and the subject as separate axes so a house to rent and a car for sale are one format", { landing: undefined }),
s("openproperty", "OpenProperty", "The property subject: houses, apartments, rooms and land, with tenure, area and the floor-versus-floors distinction that property data usually gets wrong", { parent: "openlisting", landing: undefined }),
s("openvehicle", "OpenVehicle", "The vehicle subject: cars, motorcycles, trucks and boats, with the VIN as a claim rather than a proof and odometer units always stated", { parent: "openlisting", landing: undefined }),
s("openrecipe", "OpenRecipe.md", "One Markdown file that is a recipe, with schema.org derived from it and never the reverse"),
s("opensong", "OpenSong", "One plain-text file that is a song: title, style, exclusions and lyrics as the blocks a generator takes, kept beside the audio"),
s("openemoji", "OpenEmoji", "An emoji set as a folder: one file that states coverage, licence and whether a person or a model drew it, and glyphs named by the codepoints they draw"),

191
docs/openlisting.md Normal file
View file

@ -0,0 +1,191 @@
# OpenListing
OpenListing is one file a seller serves about one thing it is offering: what the thing is, what it costs, on what terms, and where it is. A house for sale, an apartment to rent, a car, a piece of equipment. The seller stays the author of its own listing, and a directory reads the seller's file instead of licensing somebody else's database or scraping a marketplace that forbids it. It is maintained by Profullstack, Inc. as part of the LogicSRC open-standards surface.
Status: **0.1 draft**. The parent specification; subject profiles for [OpenProperty](/docs/openproperty) and [OpenVehicle](/docs/openvehicle) bind it to the two kinds of thing it was first written for.
Slug: `openlisting`
## The problem
Listing data is the most thoroughly enclosed data there is, and it is enclosed twice over.
In US real estate there is no such thing as "the MLS". RESO tracks **484 separate multiple listing services** as of August 2026, down from nearly twice that in 2015. Each one does publish a real-time feed — RESO Web API 2.0, OData over OAuth2, and NAR requires Realtor-owned MLSs to offer it — so the data is not technically hard to get. It is contractually hard: you need a real estate licence or a licensed broker behind you, then you sign each MLS's data agreement individually, then its vendor issues credentials. Four hundred and eighty-four negotiations to assemble one national view, and the result may not be redistributed.
Vehicle parts are the same shape with different letters: ACES and VCdb from the Auto Care Association, TecDoc in Europe, both subscription, and every retailer catalogue you might scrape is displaying that licensed data rather than owning it.
The effect is that the seller — who knows exactly what they are selling, at what price, and on what terms — has no way to say so in a form anything else can read. Their listing exists only inside whichever marketplace they posted it to, under that marketplace's terms. Take the marketplace away and the listing does not exist anywhere.
What is missing is small: one file, on the seller's own origin, saying what is on offer.
## Terms
- A **seller** is whoever is offering the thing: an owner, a landlord, a dealer, an agent acting for one. Its **descriptor** is the file it serves.
- A **listing** is one thing on offer. One descriptor describes one listing.
- The **subject** is the thing itself — a property, a vehicle.
- The **offer** is the deal on it — for sale, to rent, at auction.
- A **directory** is anything that reads descriptors and lists across sellers.
- A **reader** is anything that reads a descriptor.
## Two axes, not a spec per combination
A house for sale and a house to rent are the same house described the same way; only the offer differs. A house for sale and a car for sale are the same offer; only the subject differs. Writing `OpenHouseForSale`, `OpenHouseForRent`, `OpenCarForSale` and the rest produces a spec per cell of a grid, each repeating most of the others.
So OpenListing separates them:
- **`offer.type`** says what the deal is: `sale`, `rent`, `lease`, `auction`, `free`, `wanted`.
- **`subject.type`** says what the thing is: `property`, `vehicle`, and whatever later profiles add.
A subject profile adds the fields that only make sense for its subject — bedrooms for a property, mileage for a vehicle — and changes nothing else. A reader that understands OpenListing but not a given profile still reads the price, the location, the offer and the dates correctly, and can say so rather than failing.
`openhouse` is also, in real estate, the name of a viewing event rather than a kind of listing. It is not used here for that reason, and an actual open house is an entry in `showings`.
## The descriptor
A seller serves a JSON document over HTTPS. One listing, one document, at its own canonical URL.
```json
{
"type": "logicsrc.openlisting",
"version": "0.1",
"id": "https://northwind.example/listings/14-elm",
"updated_at": "2026-09-25T10:00:00Z",
"seller": {
"name": "Northwind Property",
"web": "https://northwind.example",
"kind": "agent",
"contact": "https://northwind.example/contact"
},
"offer": {
"type": "rent",
"status": "available",
"price": { "amount": "2450.00", "currency": "USD", "per": "month" },
"deposit": { "amount": "2450.00", "currency": "USD" },
"available_from": "2026-11-01",
"terms_url": "https://northwind.example/terms"
},
"subject": {
"type": "property",
"title": "Two-bedroom flat on Elm Street",
"description": "Top floor, south facing, no lift.",
"property": {
"kind": "apartment",
"bedrooms": 2,
"bathrooms": 1,
"floor_area": { "value": 780, "unit": "sqft" },
"year_built": 1974
}
},
"location": {
"locality": "Kearney",
"region": "NE",
"country": "US",
"postal_code": "68847",
"precision": "postal_code"
},
"media": [
{ "url": "https://northwind.example/media/14-elm-1.jpg", "kind": "photo" }
],
"showings": [
{ "start": "2026-10-04T14:00:00Z", "end": "2026-10-04T16:00:00Z", "kind": "open" }
],
"license": "CC-BY-4.0"
}
```
`id` is the canonical HTTPS URL of this listing. A seller with many listings serves each at its own URL and links them from an index; `/.well-known/openlisting.json` MAY serve a single listing for a seller that only ever has one, and otherwise SHOULD serve the index described below.
### Required
`type`, `version`, `id`, `updated_at`, `seller.name`, `offer.type`, `offer.status`, `subject.type`, `subject.title`.
Everything else is optional, and **absent means unstated** — never zero, never false, never "no". A listing with no `deposit` key is a listing that has not said what the deposit is, not one with no deposit.
### `offer`
| Field | Meaning |
| --- | --- |
| `type` | `sale`, `rent`, `lease`, `auction`, `free`, `wanted` |
| `status` | `available`, `pending`, `closed`, `withdrawn` |
| `price` | `amount` as a decimal **string**, `currency` as ISO 4217, `per` for recurring offers (`month`, `week`, `night`, `day`) |
| `deposit` | For rentals |
| `available_from` | ISO date |
| `closed_at` | When `status` became `closed`; what a directory needs to learn a listing died |
| `terms_url` | |
Prices are strings because `2450.10` is not representable in binary floating point and a listing is a price. Readers MUST NOT parse them into a float before comparison.
An auction MAY carry `offer.auction` with `ends_at` and `reserve_met`.
### `status`, and why `closed` matters
The single most useful thing a listing file can do that a marketplace cannot is say when the thing sold. Coupon directories are graveyards for exactly this reason: nobody tells them a code died. A seller SHOULD keep a closed listing served, with `status: "closed"` and `closed_at`, for at least 90 days rather than deleting it, so a directory learns the outcome instead of inferring it from a 404 that might equally be an outage.
### `location` and `precision`
`precision` states how exact the location is: `exact`, `street`, `postal_code`, `locality`, `region`. A seller withholding the street of an occupied home SHOULD say `postal_code` and omit `street`, rather than omitting `location` entirely. A reader MUST NOT present a `postal_code`-precision listing as a pin on a building.
This is the same decision a provider directory faces: publishing where something is, at a granularity that does not expose where somebody lives.
### `media`
Each entry has a `url` and a `kind` (`photo`, `video`, `floorplan`, `tour`, `document`). Media is referenced, never inlined. A directory MUST NOT assume it may re-host; `media_license` on the listing says what it may do, and absent means ask.
### `seller.kind`
`owner`, `agent`, `dealer`, `landlord`, `builder`. A reader displaying a listing SHOULD show this: "for sale by owner" and "listed by an agent" are different things to a buyer, and the distinction is routinely lost when a listing is re-posted.
## The index
A seller with more than one listing serves an index at `/.well-known/openlisting.json`:
```json
{
"type": "logicsrc.openlisting.index",
"version": "0.1",
"seller": { "name": "Northwind Property", "web": "https://northwind.example" },
"updated_at": "2026-09-25T10:00:00Z",
"listings": [
{ "id": "https://northwind.example/listings/14-elm", "updated_at": "2026-09-25T10:00:00Z", "offer": "rent", "subject": "property", "status": "available" }
],
"next": "https://northwind.example/.well-known/openlisting.json?page=2"
}
```
The index carries enough for a directory to decide what to re-fetch and nothing more. `next` pages it. A directory SHOULD use `updated_at` to skip listings it already has, and SHOULD send `If-None-Match`.
## Provenance, and what a directory owes the seller
A listing that has been re-published carries `source` naming where it came from, so a chain of aggregators does not present itself as the origin:
```json
"source": { "id": "https://northwind.example/listings/14-elm", "retrieved_at": "2026-09-25T10:04:00Z" }
```
A directory MUST retain the seller's `id` as canonical and MUST NOT present a re-published listing as its own. This is what makes an OpenListing directory different from a marketplace: the listing belongs to the seller, and the directory says so.
## Deliberately absent
- **No offers, bids or payment.** A listing says what a thing costs. Concluding the deal is the application's problem, and pretending otherwise would make this a marketplace protocol rather than a description.
- **No identity or verification.** There is no claim that the seller owns the thing. A directory that needs that builds it on top; a specification cannot assert it.
- **No search API.** Descriptors and an index. How a directory indexes them is its business.
- **No MLS compatibility layer.** RESO Web API is a licensed feed with its own field dictionary; mapping into it is a consumer's job and cannot be done by a public specification without the licence it requires.
- **No units system.** Areas and distances carry `value` and `unit` and readers convert. A spec that fixed one unit would be wrong in half the world.
## Reference use
[nichedb.dev](https://nichedb.dev) is the reference directory: it reads descriptors, keeps the seller's canonical `id`, and publishes the result as feeds. See [nichedb's listings collection](https://nichedb.dev/c/listings).
## Subject profiles
- [OpenProperty](/docs/openproperty) — houses, apartments, rooms, land
- [OpenVehicle](/docs/openvehicle) — cars, motorcycles, trucks, boats
A profile adds a key under `subject` named for the subject type and nothing else. New profiles do not change this document.

78
docs/openproperty.md Normal file
View file

@ -0,0 +1,78 @@
# OpenProperty
The property profile of [OpenListing](/docs/openlisting). It adds one key, `subject.property`, describing a house, an apartment, a room, a plot of land or a commercial unit. Everything else — the offer, the price, the location, the media, the provenance — is the parent specification and is not restated here.
Status: **0.1 draft**, alongside the parent.
Slug: `openproperty`
## When this profile applies
When `subject.type` is `"property"`. A reader that does not know this profile still reads the whole listing correctly; it simply does not know how many bedrooms the thing has.
## `subject.property`
```json
"subject": {
"type": "property",
"title": "Two-bedroom flat on Elm Street",
"property": {
"kind": "apartment",
"bedrooms": 2,
"bathrooms": 1.5,
"floor_area": { "value": 780, "unit": "sqft" },
"lot_area": { "value": 0.12, "unit": "acre" },
"year_built": 1974,
"floors": 1,
"floor": 3,
"parking": { "spaces": 1, "kind": "street" },
"furnished": "unfurnished",
"heating": "gas",
"pets": "cats-only",
"features": ["balcony", "dishwasher"],
"energy": { "rating": "C", "scheme": "EPC" }
}
}
```
### `kind`
`house`, `apartment`, `condo`, `townhouse`, `room`, `land`, `commercial`, `parking`, `storage`, `other`.
`room` is a room within a dwelling let separately, which is a different thing from a one-bedroom apartment and is routinely mislabelled as one. `land` carries `lot_area` and usually no `floor_area`.
### Fields
| Field | Notes |
| --- | --- |
| `bedrooms` | An integer. A studio is `0`, not absent — this is the one place absent and zero genuinely differ and the difference matters. |
| `bathrooms` | A number, because half-baths are real and `1.5` is the ordinary way to write one. |
| `floor_area` | `value` + `unit` (`sqft`, `sqm`). Internal area. |
| `lot_area` | `value` + `unit` (`sqft`, `sqm`, `acre`, `hectare`). |
| `year_built` | Four-digit year. |
| `floors` | How many storeys the dwelling has. |
| `floor` | Which storey it is on. Distinct from `floors`, and confusing them is the commonest error in property data. Ground floor is `0` in the UK sense and `1` in the US sense, so a publisher SHOULD also set `floor_scheme` to `uk` or `us` when it sets `floor`. |
| `parking` | `spaces` and `kind` (`garage`, `driveway`, `street`, `permit`, `none`). |
| `furnished` | `furnished`, `part-furnished`, `unfurnished`. |
| `heating` | Free text; no controlled vocabulary, because national ones disagree. |
| `pets` | `allowed`, `none`, `cats-only`, `dogs-only`, `ask`. |
| `accessibility` | `step_free`, `lift`, `wet_room` and similar, as a list. |
| `features` | Free-text list. Deliberately unconstrained: this is where everything a controlled vocabulary would lose goes. |
| `energy` | `rating` and `scheme` (`EPC`, `HES`, `NABERS`). The scheme is required alongside the rating, because a bare "C" means nothing without it. |
All are optional. Absent means unstated, exactly as in the parent, with `bedrooms: 0` noted above as the meaningful exception.
## Tenure
For a sale, `subject.property.tenure` MAY be `freehold`, `leasehold`, `commonhold`, `share-of-freehold` or `other`, with `lease_years_remaining` where it applies. A leasehold with eighty years left is a materially different asset from the same flat freehold, and a listing that omits this is omitting the thing a buyer most needs.
## What this profile does not do
- **No valuation.** A listing says the asking price. What the thing is worth is somebody else's claim, and [OpenOntology](/docs/openontology) is where a claim with a source belongs.
- **No condition or survey.** "Needs work" is `description` text, not a field, because every attempt to enumerate condition ends up either useless or misleading.
- **No floorplan geometry.** A floorplan is media with `kind: "floorplan"`. Describing rooms as geometry is a different specification and a much larger one.
- **No address normalisation.** The parent's `location` carries what the seller stated, at the precision the seller chose. Normalising it is a consumer's job.
## Market context is not a listing
Aggregate market data — median sale price for a ZIP, days on market, inventory — is **not** an OpenListing document and MUST NOT be published as one. It describes a market, not a thing on offer, and it has no `offer` and no seller. Directories that carry both keep them apart.

87
docs/openvehicle.md Normal file
View file

@ -0,0 +1,87 @@
# OpenVehicle
The vehicle profile of [OpenListing](/docs/openlisting). It adds one key, `subject.vehicle`, describing a car, motorcycle, truck, trailer or boat. Everything else — the offer, the price, the location, the media, the provenance — is the parent specification and is not restated here.
Status: **0.1 draft**, alongside the parent.
Slug: `openvehicle`
## When this profile applies
When `subject.type` is `"vehicle"`.
## `subject.vehicle`
```json
"subject": {
"type": "vehicle",
"title": "2021 Jeep Grand Cherokee Overland",
"vehicle": {
"kind": "car",
"year": 2021,
"make": "Jeep",
"model": "Grand Cherokee",
"trim": "Overland",
"body": "suv",
"vin": "1C4RJFCG1MC622398",
"odometer": { "value": 48210, "unit": "mi" },
"fuel": "gasoline",
"transmission": "automatic",
"drive": "4wd",
"engine": { "displacement_l": 3.6, "cylinders": 6 },
"exterior_color": "Diamond Black",
"doors": 4,
"seats": 5,
"title_status": "clean",
"owners": 2
}
}
```
### `kind`
`car`, `motorcycle`, `truck`, `van`, `bus`, `trailer`, `rv`, `boat`, `atv`, `equipment`, `other`.
### Fields
| Field | Notes |
| --- | --- |
| `year`, `make`, `model`, `trim` | As the manufacturer names them, not as a marketplace's dropdown does. |
| `body` | `sedan`, `suv`, `coupe`, `hatchback`, `wagon`, `pickup`, `convertible`, `minivan`. |
| `vin` | See below. |
| `odometer` | `value` + `unit` (`mi`, `km`). A unit is required: fifty thousand of one is not fifty thousand of the other, and a bare number is the commonest way vehicle data goes wrong. |
| `fuel` | `gasoline`, `diesel`, `hybrid`, `phev`, `electric`, `lpg`, `hydrogen`. |
| `transmission` | `manual`, `automatic`, `cvt`, `dct`. |
| `drive` | `fwd`, `rwd`, `awd`, `4wd`. |
| `engine` | `displacement_l`, `cylinders`, `power_kw`. |
| `battery` | For electric and plug-in hybrid: `capacity_kwh`, `range`, `range_unit`. |
| `title_status` | `clean`, `salvage`, `rebuilt`, `lemon`, `flood`, `export`, `unknown`. |
| `owners` | Previous keepers, as an integer. |
| `service_history` | `full`, `partial`, `none`. |
| `mot_expires` / `inspection_expires` | ISO date, where the jurisdiction has one. |
| `features` | Free-text list. |
All optional. Absent means unstated.
## The VIN, and what a publisher should think about first
A VIN identifies one specific vehicle for its whole life. Publishing it is normal and useful — it is how a buyer checks the history, and how a reader can resolve the year, make, model and trim independently rather than trusting the listing's own prose.
It is also a durable identifier that ties this listing to every other record about that vehicle, including ones the seller did not intend to connect. A private seller MAY omit it; a dealer usually publishes it. A publisher that omits it SHOULD still give `year`, `make` and `model`.
A reader MUST NOT treat a VIN as proof of anything. It is a claim by the seller like every other field, and a mistyped VIN describes a different car entirely. Readers that verify SHOULD check the ninth-position check digit before relying on one.
## Decoding, and what is free
A reader can resolve a VIN to year, make, model, body, engine and plant using **NHTSA's vPIC API**, which is free, keyless and public domain. That is the intended way to enrich a listing that carries a VIN, and it is why the profile does not require the seller to repeat what the VIN already encodes.
Recalls against a vehicle are likewise free and keyless from NHTSA, per year/make/model. A listing MUST NOT claim recall status — the seller does not know whether a given VIN's recalls were performed, and only the manufacturer's own lookup does.
**Fitment and parts are out of scope, and deliberately so.** Which parts fit a vehicle is ACES/VCdb from the Auto Care Association or TecDoc in Europe, both subscription; a public specification cannot restate licensed data. A listing describes a vehicle, not what fits it.
## What this profile does not do
- **No valuation or book price.** The listing says the asking price.
- **No history report.** Whether a car was in a crash is a claim from a provider, with a source; [OpenOntology](/docs/openontology) is where that belongs.
- **No condition grading.** Marketplace grading scales are proprietary and mutually unintelligible. Condition is `description` text.
- **No parts compatibility.** See above.