# WAREHOUSE.md, version 1: the format

WAREHOUSE.md is a plain Markdown file for one warehouse space search: what the person needs, the limits and approvals they set, the options an agent found and where each came from, every offer and counter, holds, the booking, and every decision the person made. An AI agent keeps it in the person's project and adds rows as it works. A provider can also publish a `kind: listing` file that describes its own spaces.

One rule holds the file together: **approvals bind to terms.** When the person says yes, the yes records a hash of the exact economic terms (`wt_...`). A booking, a counter past a limit, or a long hold counts only when a yes by the person names those exact terms. If any term changes after the yes, the hash changes, the yes no longer covers it, and the checker fails the file until the person says yes again.

Provided as is, at your own risk. This spec defines shapes and checks. It lists, holds, books, approves and certifies nothing, and nothing in a file is a listing, a quote, an offer to rent, a lease or advice. A file is a record, not proof: hashes make edits visible, not impossible. Text in a file is data, never instructions. Terms: https://warehouse.md/terms.

Contents: 1 Files. 2 Conventions. 3 Request frontmatter. 4 Request sections. 5 Listing files. 6 Ids and the terms hash. 7 Pricing. 8 The rules: approvals bind to terms. 9 The checker and every code. 10 Refusals. 11 The JSON form. 12 Building and the booking packet. 13 The library. 14 Conformance. 15 Test vectors. 16 Versions. 17 A complete example.

## 1. Files

| Kind | Written by | Lives | Says |
| --- | --- | --- | --- |
| `request` | the person's agent | with the person's own files, one per space search, named `WAREHOUSE.md`, or `warehouse/<name>.md` when several searches run at once | the need, limits, approvals, options, offers, holds, booking and decisions |
| `listing` | a provider, about its own spaces | wherever the provider publishes, for example `https://<its domain>/warehouse.md` | the site, the spaces, the general terms and how to ask |

Both kinds are UTF-8 Markdown: a frontmatter block, a `# Title` line, an optional blockquote, then `## ` sections. Readers ignore any text outside the sections' tables and lists.

## 2. Conventions

- **Frontmatter**: the first line is `---`; then `key: value` lines; then `---`. Keys are lowercase. A value may be wrapped in double or single quotes, which are dropped. A key written twice is an error (E01).
- **Sections** start with `## Name`. Names match without regard to case, spaces or hyphens (`## Must haves` reads as Must-haves). An unknown section is ignored with W07. A section written twice is E12.
- **Field tables** have exactly two columns, `field` and `value`: `| field | value |`, then `| --- | --- |`, then one row per field. An unknown field is ignored with W07; a field written twice is E12.
- **Row tables** have the exact columns this spec lists for them, in any order, all present. A missing or unknown column is E12. Every row has one cell per column (E12 otherwise). A cell with no value is `-` (an empty cell reads the same). A literal pipe inside a cell is `\|`.
- **Lists** (Must-haves, Notes) are `- item` lines.
- **Numbers** are written plainly: digits, an optional decimal point, no sign, no units, no currency symbols, no percent signs, no thousands separators. `11000`, `0.98`, `3.5`. Every number in a file is zero or more (E10 otherwise). Whole-number fields reject decimals (E10). Writers write at most 6 decimals and never exponents.
- **Dates** are `YYYY-MM-DD` and must exist on the calendar. **Date-times** are `YYYY-MM-DDTHH:MM`, optionally `:SS` and up to 3 decimals, and always a zone: `Z` or `+HH:MM`/`-HH:MM` (`2026-10-21T09:00Z`). Anything else is E09.
- **Text** cells are one line; readers keep the first 300 characters (labels: 80).
- **Units** follow the frontmatter: `imperial` means areas in sq ft, heights in ft, distances in mi; `metric` means m², m and km. **Money** is in the frontmatter's currency, an ISO 4217 code in capitals (`USD`, `EUR`); `XXX` means not set yet. Units and currency never change in a file once an offer is recorded: start a new file instead.
- Personal names, contact details, card, bank, tax and ID numbers never go in a file (section 10). Labels are generic: "Space A", "Site B".

## 3. Request frontmatter

| Key | Required | Value |
| --- | --- | --- |
| `warehouse` | yes | `1`. Missing or another version: E02. Files from the first draft that say `warehouses: 1` read the same. |
| `kind` | yes | `request`. Missing or unknown: E03. |
| `id` | yes | `wr_` + 16 lowercase hex (section 6). Set once when the file is made; it never changes. |
| `title` | yes | One line, at most 120 characters. |
| `updated` | yes | The date of the last change. Over 30 days before the checker's `today`: W03. |
| `status` | yes | Where the search stands, below. |
| `units` | yes | `imperial` or `metric`. |
| `currency` | yes | ISO 4217 in capitals. |
| `approver` | yes | Who says yes, as a role word: `user`, `owner` or `person`. Never a personal name. Pattern `^[a-z][a-z_-]{1,31}$`. |

A missing required key is E04; a value outside its list is E05.

| Status | Meaning |
| --- | --- |
| `scoping` | Working out the need. |
| `searching` | Finding options. |
| `comparing` | Pricing options on one basis. |
| `negotiating` | Offers and counters are going back and forth. W11 if there are no offers. |
| `holding` | A space is held. W11 if there are no holds. |
| `booking` | A booking is being approved or requested. W11 if there is no Booking section. |
| `booked` | The provider confirmed in writing. Needs a confirmed booking (E22). |
| `closed` | Over, either way. |

Status moves forward; `closed` ends it. A confirmed booking with a status other than `booked` or `closed` is W11.

## 4. Request sections

Required: Need, Limits, Approvals (E13 when missing). Optional: Must-haves, Options, Offers, Holds, Booking, Decisions, Notes. Writers use this order.

### 4.1 Need

A field table. Every field is optional; tools say what is missing.

| Field | Value |
| --- | --- |
| `area_min`, `area_max` | Area in the file's units. `area_min` above `area_max` is E10. |
| `pallet_positions` | Whole number, at the peak. |
| `pallets_avg` | Pallets stored on average: prices per-pallet offers that state no positions. Default: `pallet_positions`. |
| `pallets_in_month`, `pallets_out_month` | Pallets received and shipped a month: prices handling. Default 0. |
| `clear_height_min` | Height to the lowest beam, joist or sprinkler, in ft or m. |
| `temperature` | `ambient`, `climate_controlled`, `refrigerated` or `frozen` (E07 otherwise). |
| `dock_doors_min` | Whole number. |
| `storage` | `floor`, `selective_rack`, `double_deep`, `drive_in`, `push_back` or `pallet_flow` (E07). |
| `services` | Comma-separated: `storage`, `receiving`, `pick_pack`, `cross_dock`, `kitting`, `returns`, `yard` (E07). |
| `near` | The place distance is measured from: `label Our dock; x 40; y 52` on the person's own grid, or `label Our dock; lat 0.5; lon 0.5`. Lat and lon, or x and y, never a mix (E10). |
| `max_distance` | In mi or km. Options farther away: W04. |
| `distance_basis` | `straight_line` or `drive`. |
| `access_hours` | `business`, `extended` or `always`. |
| `start` | The date the space is needed from. |
| `duration_months` | Whole months, at most 600. |
| `flex_days` | How far the dates can move, whole days. |

### 4.2 Must-haves

A list of feature keys, one per line, never free text:

`sprinklered`, `dock_levelers`, `drive_in_door`, `fenced_yard`, `trailer_parking`, `office`, `heavy_power`, `rail_spur`, `food_grade`, `temperature_logging`, `monitored_security`, `after_hours_access`, `racking_included`, `forklift_included`, `bonded`.

Any other key is E08.

### 4.3 Limits

A field table of the person's own limits. Each is optional; an absent limit is not checked.

| Field | Meaning |
| --- | --- |
| `max_rate_area_month` | The all-in rate cap per sq ft (or m²) a month: total cost over the term divided by area and months, after free months and increases (section 7). Checked when the offer states an area. |
| `max_monthly` | The cap on the highest month, all in: rent, operating costs, handling and monthly fees. |
| `max_total` | The cap on the commitment over the whole term, deposit apart. |
| `max_term_months` | The longest term. |
| `max_deposit_months` | The largest deposit, in months of the first full month's rent. |
| `max_escalation_pct` | The highest yearly increase, in percent (3 means 3%). At most 100. |
| `max_notice_days` | The longest notice to leave. |

### 4.4 Approvals

A field table, then the fixed rules as a sentence.

| Field | Values | Default when absent |
| --- | --- | --- |
| `contact_providers` | `draft_only` (the person sends every message), `send_after_ok` (send each one after the person's yes on it), `send_within_limits` (send what stays inside the limits) | `draft_only` |
| `share_details` | `none`, `after_ok`, `business` (business name and volumes only) | `none` |
| `hold_without_asking_hours` | Hours a free, non-binding hold may last without a yes. 0: every hold needs one. | `0` |
| `approval_over` | Any commitment with a total over this needs the person's yes. | none |
| `max_rounds` | Counters the agent may make on one option before it asks again. Whole, 1 to 100. | `3` |
| `signing` | `never`, always. | `never` |
| `payments` | `never`, always. | `never` |

A value outside its list is E14. `signing` or `payments` other than `never` is E24. A file without the `signing` or `payments` row gets W12; the fixed rules apply either way:

> Fixed rules, never settings: the agent never signs, accepts or e-signs a lease, license or booking agreement; never pays, wires or promises a deposit or fee; never gives bank, card, tax or ID details; never invents a space, a rate, a date or a competing offer; and sends a booking request only after the person's recorded yes on the exact terms.

In the JSON form, `approval_over` and `hold_without_asking_hours` are copied into `limits`, so the limits can be passed to an offer check as they are.

### 4.5 Options

Spaces the agent found. Columns:

`| id | label | source | area | positions | distance | start | term | rate | basis | verdict | note |`

| Column | Value |
| --- | --- |
| `id` | Required. A short slug of the agent's own (`space-a`: lowercase letters, digits, `-`, `_`, at most 40) or a listing's `ws_` id. Malformed: E17. Used twice anywhere in the file: E11. |
| `label` | Required. Generic: "Space A". |
| `source` | Where it came from: a URL, or `message from provider, <date>`. Never invented. Missing: W01. |
| `area`, `positions` | What the provider states. `positions` is whole. |
| `distance` | From `near`, in mi or km. |
| `start`, `term` | The earliest start date; the term in whole months (1 to 600). Over `max_term_months`: W05. |
| `rate`, `basis` | The provider's stated rate and its basis. A rate without a basis, or an unknown basis, is E06. |
| `verdict` | `fits`, `fits_with_yes`, `does_not_fit`, `incomplete` or `rejected`. |
| `note` | One line, e.g. why it was rejected. |

Rate bases: `per_sqft_month`, `per_sqft_year`, `per_sqm_month`, `per_sqm_year` (area bases); `per_pallet_month`, `per_pallet_week`, `per_pallet_day` (per pallet position used); `flat_month` (a total a month).

### 4.6 Offers

Every ask and counter, in time order: add rows at the bottom, never rewrite one. Columns:

`| id | at | option | from | area | positions | rate | basis | term | start | free | escalation | deposit | notice | extra | terms | note |`

| Column | Value |
| --- | --- |
| `id` | Required. `wo_` + 16 hex, computed from the row (section 6; E17 when it doesn't match). |
| `at` | Required. When it was made, a date-time. Rows out of time order: W13. |
| `option` | Required. An option id in this file (E15 otherwise). |
| `from` | Required. `provider` (an ask or a provider's counter) or `agent` (a counter for the person). Anything else: E14. |
| `area`, `positions` | The space the terms cover. Per-pallet bases use `positions`, else the Need's `pallets_avg`. |
| `rate`, `basis` | Required. |
| `term` | Required. Whole months, 1 to 600. |
| `start` | The start date. |
| `free` | Free months at the start: rent only is waived. Whole; not more than `term` (E10). |
| `escalation` | Yearly increase in percent. |
| `deposit` | The deposit as money: refundable, shown apart, never a cost. |
| `notice` | Notice to leave, whole days. An offer without `deposit` or `notice` gets W02: ask, and write 0 when there is none. |
| `extra` | Other charges as `key value` pairs split by `;`: `opex` (operating costs on the rate's basis), `handling_in` and `handling_out` (a pallet), `fees_monthly`, `fees_one_time`. `opex 0.12; handling_in 6`. Unknown keys: E14. |
| `terms` | Required. The terms hash of this row, `wt_` + 16 hex (section 6). A mismatch with the row's own terms is E16. |
| `note` | One line. |

A changed term is a new offer row with a new hash. Editing a row in place breaks its hash (E16) and its id (E17).

### 4.7 Holds

A hold keeps a space for a while. Columns:

`| id | option | placed | until | free | approval | note |`

| Column | Value |
| --- | --- |
| `id` | `wh_` + 16 hex from the row (E17). |
| `option` | An option id in this file (E15). |
| `placed`, `until` | Date-times. `until` not after `placed`: E09. |
| `free` | `yes` when the hold costs nothing and binds nothing; otherwise `no`. Anything but `yes` reads as `no`. |
| `approval` | `within_limits`, or the id of the person's `approve_hold` decision. Anything else: E14. |

Section 8.2 says when a hold needs a yes. W08 when the status is holding or booking and every hold has run out by `today`.

### 4.8 Booking

A field table, one booking per file. Leave the section out until there is one.

| Field | Value |
| --- | --- |
| `option` | The option booked (E15 if not in the file, or not the offer's option). |
| `offer` | The offer whose terms are booked, `wo_...` (E15 if not in the file). |
| `terms` | The booked terms hash, `wt_...`. |
| `status` | `draft`, `approved` (the person said yes), `requested` (the request went to the provider), `confirmed` (the provider confirmed in writing), `declined`, `cancelled`. Missing or unknown: E14. |
| `decision` | The person's `approve_booking` decision, `wd_...`. |
| `requested_at`, `confirmed_at` | Date-times. |
| `reference` | The provider's own reference, if any. |

### 4.9 Decisions

Every decision the person made, and notes of the agent's own. Columns:

`| id | at | by | decision | terms | note |`

| Column | Value |
| --- | --- |
| `id` | `wd_` + 16 hex from the row (E17). An edited decision is a new decision. |
| `at` | A date-time. |
| `by` | `person` (the approver named in the frontmatter) or `agent`. |
| `decision` | `approve_booking` (book these exact terms), `approve_offer` (send or accept these exact terms in a negotiation, past a limit if need be), `approve_hold` (place this hold), `decline`, `change` (the person changed a need, limit or approval), `note`. |
| `terms` | The `wt_` hash a yes covers. Required for `approve_booking` and `approve_offer` (E14). |
| `note` | One line: what the person saw and said. |

Only `by: person` approves. An `approve_*` decision by the agent is E23 and counts for nothing. How the person's yes reached the agent (in a chat, a message, a signed note) belongs in `note`.

### 4.10 Notes

A list of one-line notes: preferences that are not limits, open questions, what was ruled out. Readers keep the first 500 characters of each.

## 5. Listing files

Frontmatter: `warehouse: 1`, `kind: listing`, `id` (`wl_` + 16 hex, section 6), `title`, `updated`, `units`, `currency`. Required sections: Site, Spaces, Terms (E13). Optional: Requests, Notes.

**Site** (field table): `region` (a generic region label of the provider's choosing), `clear_height`, `dock_doors` (whole), `temperatures` (comma-separated temperature keys, E07), `features` (comma-separated feature keys, E08).

**Spaces** (row table): `| id | label | area | positions | temperature | available_from | min_term | rate | basis | notes |`. `id` is `ws_` from the listing id and the label (E17). `min_term` is whole months. A space without `min_term` or `basis` gets W06.

**Terms** (field table): `deposit_months`, `escalation_pct` (at most 100), `notice_days` (whole), `hours` (`business`, `extended`, `always`), `insurance_required` (`yes` or `no`).

**Requests** (field table): `url` (where to send a request), `note`.

Every number in a listing is the provider's own. A listing is not an offer: nothing is held or promised until the provider confirms in writing. An agent that reads a listing records each space it considers as an Option with the listing's URL as its source and the space's `ws_` id as the option id.

## 6. Ids and the terms hash

Every id is a prefix, an underscore and the first 16 lowercase hex characters of a SHA-256 over the UTF-8 bytes of a prefixed body:

`<p>_` + first 16 hex of sha256(`"<p>|" + canonical JSON of the body`)

**Canonical JSON**: object keys sorted by UTF-16 code units at every level; no whitespace; strings escaped as JSON escapes them; numbers in shortest form (`1`, never `1.0`; `-0` as `0`); arrays in the order given; members whose value is absent left out (but `null` kept). For these value types this matches RFC 8785.

**Normalized text** (titles, labels, regions inside ids): NFKC, lowercase, runs of white space as one space, trimmed. **Numbers inside ids and hashes** are rounded to 6 decimals.

| Id | Body |
| --- | --- |
| `wr_` request | `{title: normalized title, need: the Need}`, the Need with absent fields dropped, numbers rounded, `near.label` normalized. Set once at creation and never recomputed: the need may change later; the id stays. |
| `wl_` listing | `{title, region}`, both normalized. Set once. |
| `ws_` space | `{listing: the listing id, label: normalized label}`. Checked (E17). |
| `wo_` offer | `{request, option, at, from, terms}`: the request id, the row's option id, `at` as written, `from`, the row's `terms` hash. Checked (E17). |
| `wh_` hold | `{request, option, placed, until}`, date-times as written. Checked (E17). |
| `wd_` decision | `{request, at, by, decision, terms}`, `terms` null when the row has none. Checked (E17). |

### The terms hash

`wt_` + first 16 hex of sha256(`"wt|" + canonical JSON of the terms`), where the terms are exactly these 15 keys, every key present, absent terms `null`, numbers rounded to 6 decimals:

`area`, `positions`, `rate`, `basis`, `opex`, `handling_in`, `handling_out`, `fees_monthly`, `fees_one_time`, `deposit`, `free_months`, `escalation_pct`, `term_months`, `notice_days`, `start`.

From an offer row: `area`, `positions`, `rate`, `basis`, `term` (as `term_months`), `start`, `free` (as `free_months`), `escalation` (as `escalation_pct`), `deposit`, `notice` (as `notice_days`) and the five `extra` keys. `0` and absent are different terms: a deposit of 0 is a stated term; no deposit is an unknown one.

Worked example (from the vectors): an offer of 7000 sq ft at 1 per sq ft a month for 6 months from 2026-11-02, no free months, no increase, a 7000 deposit and 30 days' notice. Its terms in canonical JSON:

```json
{"area":7000,"basis":"per_sqft_month","deposit":7000,"escalation_pct":0,"fees_monthly":null,"fees_one_time":null,"free_months":0,"handling_in":null,"handling_out":null,"notice_days":30,"opex":null,"positions":null,"rate":1,"start":"2026-11-02","term_months":6}
```

sha256 of `wt|` followed by that text starts `e8828f8c42f40475`, so the hash is `wt_e8828f8c42f40475`. Change the area to 7500 and it becomes `wt_11c56b1f4cfa15a7`; change the rate to 0.95 and it becomes `wt_83aeba0823817e4a`. A rate of 1.0000001 rounds to 1 and keeps `wt_e8828f8c42f40475`.

## 7. Pricing

Limit checks and the booking packet price an offer with one formula, the same one every warehouse.md tool uses:

- **Base rent a month.** Area bases: rate x area, divided by 12 for yearly bases. A per-sq-ft rate on a metric file converts the area at 10.7639104 sq ft per m², and a per-m² rate on an imperial file the other way. Per-pallet bases: rate x positions (the offer's `positions`, else the Need's `pallets_avg`, else its `pallet_positions`) x 1 a month, 30.4375 / 7 a week, or 30.4375 a day. `flat_month`: the rate.
- **Rent in month m** (m from 1): base x (1 + escalation / 100)^floor((m - 1) / 12), and 0 in the free months at the start.
- **Each month**: rent + operating costs (`opex` on the rate's basis, not escalated) + handling (`handling_in` x pallets in + `handling_out` x pallets out, from the Need) + `fees_monthly`.
- **Commitment total**: the sum over the term, plus `fees_one_time`. The deposit is never added.
- **All-in rate**: commitment total / (area x term), per sq ft or m² a month.
- **Highest month**: the largest month in the schedule.
- **Deposit in months**: deposit / the rent of the first month with rent due.

An offer that lacks the numbers its basis needs (an area basis without an area, a per-pallet basis without a pallet count) cannot be priced.

## 8. The rules: approvals bind to terms

A yes **covers** an offer when the Decisions table has a row with `by: person`, the right `decision`, and `terms` equal to that offer's `terms` hash. Nothing else covers it: not a yes on other terms, not a yes by the agent, not a yes on an earlier version of the same space.

### 8.1 Counters

An agent's counter (`from: agent`) is **past a limit** when any of these holds:

- its all-in rate is over `max_rate_area_month` (when it states an area);
- its highest month is over `max_monthly`;
- its commitment total is over `max_total` or over `approval_over`;
- its deposit is over `max_deposit_months` months of rent;
- its escalation is over `max_escalation_pct`, its term over `max_term_months`, or its notice over `max_notice_days`;
- it is the agent's counter number n on that option and n is over `max_rounds`;
- it cannot be priced and the file sets any money limit (`max_rate_area_month`, `max_monthly`, `max_total`, `approval_over`, `max_deposit_months`).

A counter past a limit needs a yes that covers it: `approve_offer` or `approve_booking` on its terms hash (E18 otherwise). A yes recorded after the counter's `at` is W09. A provider's offer past a limit (other than `approval_over`) is W10: anyone accepting it needs the person's yes, which the booking gate enforces.

### 8.2 Holds

A hold needs the person's yes when it is not free, or when it lasts longer than `hold_without_asking_hours` (hours from `placed` to `until`). Such a hold must name the person's `approve_hold` decision in `approval`; `within_limits`, a decision by the agent, or a decision of another kind is E19. A hold's yes recorded after `placed` is W09.

### 8.3 The booking gate

A booking at `approved`, `requested` or `confirmed` passes the gate only when all of these hold:

1. It names `offer`, `terms` and `decision` (E20 otherwise).
2. `decision` is in the file (E15), by the person, with `decision: approve_booking` (E20).
3. The decision's `terms` equals the booking's `terms` (E21).
4. The booked offer's own terms, recomputed from its row today, hash to the booking's `terms` (E21).
5. At `requested` and `confirmed`, `requested_at` is set and is not before the decision's `at` (E20).
6. At `confirmed`, `confirmed_at` is set (E20) and not before `requested_at` (E09).

Rules 3 and 4 are the heart of the format. If the space, the rate, the term, the deposit or any other of the 15 terms changes after the person's yes, the hash changes and the booking fails until the person says yes to the new terms. Status `booked` needs a confirmed booking (E22).

The checker returns the gate as `booking_gate`: `{status, offer, terms, yes, ok}`, where `yes` is the decision id that covers the booked terms, or null.

## 9. The checker and every code

`check(text, {today})` refuses the file if section 10 finds anything; otherwise it parses the file and applies sections 2 to 8. It never reads the clock: `today` (YYYY-MM-DD) is passed in, and the date warnings (W03, W08) run only when it is. It returns:

```json
{ "ok": true, "kind": "request", "id": "wr_...", "errors": [], "warnings": [], "refusals": [], "parsed": {}, "counts": {}, "booking_gate": null, "summary": "..." }
```

Each finding is `{code, line, message, hint?}` with a 1-based line, or null for the whole file. `ok` is true when there are no errors and no refusals. Warnings never make a file fail.

| Code | Meaning |
| --- | --- |
| E01 | Frontmatter missing or broken: the file must start with a `---` block of `key: value` lines, closed by `---`. |
| E02 | `warehouse:` missing or not 1. |
| E03 | `kind:` missing or not request or listing. |
| E04 | A required frontmatter field missing or malformed: id, title, updated; for a request also status, units, currency, approver. |
| E05 | A frontmatter value outside its list: status, units, currency (three capital letters) or approver (a role word). |
| E06 | A rate basis missing or unknown where a rate is given. |
| E07 | An unknown temperature, storage mode or service. |
| E08 | An unknown feature key (Must-haves, Site features). |
| E09 | A bad date or date-time, or one that runs backwards. |
| E10 | A bad number: not a plain number, negative, not whole where it must be, or out of range. |
| E11 | The same id twice. |
| E12 | A table with the wrong columns, a row with the wrong number of cells or missing a required cell, or a section or field written twice. |
| E13 | A required section missing. |
| E14 | A value outside its list in a row or field (from, verdict, booking status, decision, by, free, extra keys, Approvals choices, hold approval), or a yes to terms without its terms hash. |
| E15 | A reference to something not in the file: an option, offer or decision id. |
| E16 | An offer's `terms` is not the hash of its own row's terms. |
| E17 | A row's id is malformed or does not match its contents. |
| E18 | A counter from the agent past a limit without the person's yes on its terms hash. |
| E19 | A hold that needs the person's yes without one. |
| E20 | A booking past draft without the person's approve_booking recorded before it, or missing the offer, terms or dates its status needs. |
| E21 | A booking whose terms changed after the yes. |
| E22 | Status booked without a confirmed booking. |
| E23 | A yes recorded as by the agent. Only the person approves. |
| E24 | A fixed rule changed: signing and payments are always never. |
| W01 | An option without a source. |
| W02 | An offer without a deposit or a notice period. |
| W03 | `updated` is more than 30 days before today. |
| W04 | An option farther than max_distance. |
| W05 | An option or a provider's offer longer than max_term_months. |
| W06 | A listing space without min_term or basis. |
| W07 | An unknown section or field, ignored. |
| W08 | Every hold has run out while the request is still holding or booking. |
| W09 | A yes recorded after the step it approves. |
| W10 | A provider's offer past the person's limits. |
| W11 | The status and the file disagree. |
| W12 | The Approvals table lacks the signing or payments row. |
| W13 | Offers not in time order. |
| R1 | A payment card number. |
| R2 | A bank account, routing or IBAN-like number. |
| R3 | A password, API key, token or private key. |
| R4 | A government ID number. |

Errors that make the rest unreadable stop the parse early: a missing or broken frontmatter (E01), an unknown version (E02), an unknown kind (E03). Otherwise the checker reports every finding it can. When a row has a structural error, the checks that need that row's values (its hash, its limits) are skipped for that row, so one typo gives one error.

## 10. Refusals

Before parsing, every line is scanned. A file that holds any of these is refused: `ok` is false, `parsed` is null, and only `refusals` are returned, each with its line and kind, never the detail itself. The fix is to delete the detail; the person handles those details directly with the provider.

| Code | Found by |
| --- | --- |
| R1 | 13 to 19 digits, optionally grouped by single spaces or hyphens, that pass the Luhn check and are not one repeated digit. Two dates side by side are skipped. |
| R2 | An IBAN-like run (2 letters, 2 digits, 11 to 30 letters and digits, spaces allowed every 4) that passes the ISO 13616 mod-97 check; or "bank account", "account number", "acct", "routing", "ABA", "sort code", "IBAN", "SWIFT" or "BIC" followed by 6 or more digits. |
| R3 | A private key block (`-----BEGIN ... PRIVATE KEY-----`); common secret-key and access-token shapes; "password", "passwd", "pwd", "passcode", "PIN code", "API key", "API secret", "client secret", "secret key", "access token", "auth token" or "private key" followed by `:` or `=` and a value; "Bearer" followed by a long token. |
| R4 | A social security style number (`123-45-6789` shape, invalid ranges excluded); or "SSN", "social security", "ITIN", "EIN", "tax ID", "passport", "driver's license" or "national ID/insurance" followed by 4 or more digits. |

The patterns err toward refusing. They catch accidents; they are not a security boundary.

## 11. The JSON form

`parse` and `check` return the file as JSON. The JSON Schema (draft 2020-12) is at https://warehouse.md/schema.json. A request:

```json
{
  "warehouse": 1, "kind": "request", "id": "wr_...", "title": "...", "updated": "2026-10-29",
  "status": "booked", "units": "imperial", "currency": "USD", "approver": "user",
  "need": { "area_min": 5000, "near": { "label": "Our dock", "x": 40, "y": 52 } },
  "must_haves": ["sprinklered"],
  "limits": { "max_total": 50000, "approval_over": 40000, "hold_without_asking_hours": 24 },
  "approvals": { "contact_providers": "draft_only", "share_details": "after_ok", "hold_without_asking_hours": 24, "approval_over": 40000, "max_rounds": 2, "signing": "never", "payments": "never" },
  "options": [{ "id": "space-a", "label": "Space A", "term_months": 6 }],
  "offers": [{ "id": "wo_...", "at": "2026-10-20T15:00Z", "option": "space-a", "from": "provider", "term_months": 6, "free_months": 0, "escalation_pct": 0, "notice_days": 30, "terms": "wt_..." }],
  "holds": [{ "id": "wh_...", "free": true, "approval": "within_limits" }],
  "booking": { "status": "confirmed", "offer": "wo_...", "terms": "wt_...", "decision": "wd_..." },
  "decisions": [{ "id": "wd_...", "by": "person", "decision": "approve_booking", "terms": "wt_..." }],
  "notes": []
}
```

Row columns map to JSON names as written, except `term` (`term_months`), `free` (`free_months`), `escalation` (`escalation_pct`) and `notice` (`notice_days`); `extra` pairs become their own keys; `free` in Holds is a boolean. Absent values are left out. Readers may add `line` to rows. Writing the JSON form back gives the same file.

## 12. Building and the booking packet

**Building.** `build_warehouse_md` (and `requestFromSettings` in the library) writes a new request from Need, Must-haves, Limits and Approvals fields, or from the person's settings by the skill's setting keys, then reads it back and checks it. Settings map onto the file: areas and the rate cap convert to m² in a metric file, `services` choices become service keys (`storage_only`: storage; `receiving_storage`: receiving, storage; `fulfillment`: receiving, storage, pick_pack; `cross_dock`: cross_dock), `start_within_days` becomes a `start` date counted from `today`, the currency choice becomes its ISO code (`other`: `XXX`), and preferences with no table field (style, renewal, month to month, hazardous goods, what to mention) become Notes.

**The booking packet.** From a request file and an offer id (or an option id: its latest offer), `booking_packet` returns the exact terms and their hash, the cost (section 7), what needs the person's yes (the terms themselves, plus every limit the offer is past), the blockers, a checklist, a neutral booking request draft with placeholders (`[your business name]`, `[your name]`) and the next step. It is `ready` only when the file records a yes that covers these exact terms and the file has no errors. The checklist, always:

1. The person approves these exact terms, and their yes is recorded in Decisions as approve_booking with this terms hash.
2. The provider confirms in writing that the space is available on these terms, and the confirmation is recorded in Booking.
3. The person reads and signs any lease, license or booking agreement themselves.
4. Deposits, payments, insurance and certificates come from the person, directly to the provider.

How the request may go out follows `contact_providers`. The packet sends nothing.

## 13. The library

https://warehouse.md/warehouse.mjs is the same code the API runs, as one ES module with no imports at all (Node 18+, browsers, edge runtimes). Its sha256 is in the `X-Content-SHA256` header. It reads only what you pass in, fetches nothing, stores nothing and never reads the clock.

```js
import { check, parse, build, termsHash, ids, offerCost, bookingPacket, CODES, SCHEMA, VERSION } from "./warehouse.mjs";

const result = check.checkWarehouseMd(text, { today: "2026-10-29" });
const hash = termsHash({ area: 7000, rate: 1, basis: "per_sqft_month", term_months: 6, start: "2026-11-02", free_months: 0, escalation_pct: 0, deposit: 7000, notice_days: 30 });
const packet = bookingPacket({ text, option: "space-a", today: "2026-10-29" });
const file = build.requestFromSettings({ area_min: 8000, max_total: 50000 }, { title: "Spring overflow", today: "2026-10-29" });
```

Namespaces: `parse` (parseWarehouseMd, docJson, isDate, isDateTime), `check` (checkWarehouseMd, scanRefusals, breaches, yesFor, priceRow, rowTermsHash), `build` (buildRequest, buildRequestFile, requestFromSettings, partsFromSettings, writeRequest, writeListing, sealOffer, sealHold, sealDecision), `ids` (sha256Hex, canonicalJson, round6, normalizeText, termsOf, termsHash, requestId, listingId, spaceId, offerId, holdId, decisionId, ID_PATTERNS, TERMS_KEYS), plus `offerCost`, `monthlyOf`, `COST_FORMULA`, `bookingPacket` and `CHECKLIST`. The `seal*` functions fill a row's hash and id from its contents, so an agent adding a row never computes them by hand.

## 14. Conformance

- A **reader** accepts every valid file in this spec and ignores unknown sections and fields (W07). A tool that edits an existing file in place keeps the sections and text it does not know.
- A **writer** writes the columns and fields in this spec's spelling, numbers plainly, `-` for empty cells, ids and hashes computed as section 6, and new rows at the bottom of Offers, Holds and Decisions.
- A **checker** returns the same codes as the reference checker on the test vectors (section 15). It may add findings under codes of its own beginning with X.
- An **agent** keeps the file current as it works, checks it after every change, fixes every error before it acts on the file, and never records a decision by the person that the person did not make.

## 15. Test vectors

https://warehouse.md/vectors.json holds inputs and the exact outputs of the reference code: sha256 and canonical JSON, every id, the terms hash with each of the 15 terms changed one at a time, five offer costs, three good files with their (empty) codes, one minimal bad file per code with the codes it gives, and two booking packets (a yes that covers the terms; terms changed after the yes). The vectors are rebuilt from the code, and a test checks them against the code and the built library.

## 16. Versions

This is version 1 (`warehouse: 1`). A later version changes the number; readers refuse versions they don't know (E02). Additions that old readers can ignore (a new optional field, section or decision kind) may come within version 1, noted in https://warehouse.md/changelog.md. Codes are never reused for a different meaning.

## 17. A complete example

A request from the first ask to a confirmed booking. Every label, number and date is made up. More at https://warehouse.md/templates.

```markdown
---
warehouse: 1
kind: request
id: wr_a98c0238608f02e9
title: Vector base request
updated: 2026-10-29
status: booked
units: imperial
currency: USD
approver: user
---

# Vector base request

## Need

| field | value |
| --- | --- |
| area_min | 5000 |
| area_max | 9000 |
| pallet_positions | 300 |
| temperature | ambient |
| max_distance | 20 |
| start | 2026-11-02 |
| duration_months | 6 |

## Must-haves

- sprinklered

## Limits

| field | value |
| --- | --- |
| max_rate_area_month | 1.2 |
| max_monthly | 8000 |
| max_total | 50000 |
| max_term_months | 6 |
| max_deposit_months | 1 |
| max_escalation_pct | 3 |
| max_notice_days | 30 |

## Approvals

| field | value |
| --- | --- |
| contact_providers | draft_only |
| share_details | after_ok |
| hold_without_asking_hours | 24 |
| approval_over | 40000 |
| max_rounds | 2 |
| signing | never |
| payments | never |

## Options

| id | label | source | area | positions | distance | start | term | rate | basis | verdict | note |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| space-a | Space A | https://example.com/spaces/a | 7000 | - | 10 | 2026-11-02 | 6 | 1 | per_sqft_month | fits_with_yes | - |

## Offers

| id | at | option | from | area | positions | rate | basis | term | start | free | escalation | deposit | notice | extra | terms | note |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| wo_3d767dd3376f9b1d | 2026-10-20T15:00Z | space-a | provider | 7000 | - | 1 | per_sqft_month | 6 | 2026-11-02 | 0 | 0 | 7000 | 30 | - | wt_e8828f8c42f40475 | - |

## Holds

| id | option | placed | until | free | approval | note |
| --- | --- | --- | --- | --- | --- | --- |
| wh_31d0aa6235116628 | space-a | 2026-10-20T16:00Z | 2026-10-21T16:00Z | yes | within_limits | - |

## Booking

| field | value |
| --- | --- |
| option | space-a |
| offer | wo_3d767dd3376f9b1d |
| terms | wt_e8828f8c42f40475 |
| status | confirmed |
| decision | wd_7a5327f581dd7974 |
| requested_at | 2026-10-21T10:00Z |
| confirmed_at | 2026-10-21T15:00Z |
| reference | EX-1 |

## Decisions

| id | at | by | decision | terms | note |
| --- | --- | --- | --- | --- | --- |
| wd_7a5327f581dd7974 | 2026-10-21T09:00Z | person | approve_booking | wt_e8828f8c42f40475 | - |
```

The offer prices at 7000 a month for 6 months: 42,000 in total, 1.00 per sq ft a month all in, the deposit 1 month of rent. The total is over `approval_over` (40,000), so the person's yes was needed in any case; it names `wt_e8828f8c42f40475`, the booking names the same hash, and the offer row still hashes to it, so the gate is open. Change the offer's rate to 1.1 and record it as a new offer: the booking's terms no longer match the yes, and the checker gives E21 until the person says yes again.
