# The resource model and URL grammar

**Status: full application API/MCP parity required (13 Sep 2026); resource grammar remains RUL-171.**

**Decision reversal, 13 Sep:** Daniel requires every app capability through the API and MCP. The historical exclusions below describe the original resource-API rollout, not current capability exclusions. All 206 registered procedures across 28 domains are exposed through the existing authenticated HTTP API and generated MCP actions. File/image operations have dedicated tools. See [the current action catalog](https://rulework.app/docs/api/actions.json) and [API overview](./README.md). The URL grammar and v1 boundary are
settled; implementation work may proceed from these nouns and dispositions. Procurement remains
an explicit v2 boundary below rather than an omitted part of the domain.

Enumerated against source on 12 Aug 2026: 23 sub-routers, 167 procedures
(`packages/trpc/src/router.ts`, `router-domains-{a,b,c,d}.ts`). The disposition table at the
end accounts for every one.

## The nouns, in the practitioner's words

The tables are not the model. `schedule_entries` is a join, `items` is a **line**, `sections`
is a **section**. The API says what she says — and her vocabulary was settled with Daniel on
12 Aug (RUL-195), so the questions the ticket left open are closed:

- **`sections`, not `rooms`.** A section is a section, more often than not a room. Her word
  "room" *means* a section; the general term wins in the contract because a studio that
  sections by category (her own example: furniture, lighting, rugs) is not wrong.
- **A line's short wording is its `heading`; its long prose is its `description`** — her
  renaming, already the columns' names since migration 0046.
- **"Line" in prose, `items` in paths.** `piece` is the client document's register and never
  appears in the API.

| Resource | Path | Notes |
| -- | -- | -- |
| Project | `/v1/projects/{projectId}` | A job. |
| Section | `/v1/projects/{projectId}/sections/{sectionId}` | A grouping, usually a room. |
| Item | `/v1/projects/{projectId}/items/{itemId}` | A line. The central noun. |
| Photograph | `/v1/projects/{projectId}/items/{itemId}/photographs/{photographId}` | An attachment on a line, in display order. |
| Custom property | `/v1/projects/{projectId}/custom-properties/{propertyId}` | Her own columns; options and per-line values hang off it. |
| Client document | `/v1/projects/{projectId}/client-document` | A projection with its own schema — **not** a filtered project (RUL-176). |
| Share | `/v1/projects/{projectId}/shares/{shareId}` | An issued capability. Immutable; revocation is an append. |
| Shared document | `/v1/shares/{token}/document` | The anonymous read. The token names one document and is never an identity. |
| Audit feed | `/v1/projects/{projectId}/audit` | Read-only, `audit:read`. |
| Import | `/v1/projects/{projectId}/imports` | An operation, not a document store — see Operations. |
| Library | `/v1/libraries/{libraryId}` | Studio-level. |
| Project template | `/v1/project-templates/{templateId}` | Studio-level. |
| Member / invitation | `/v1/workspace/members`, `/v1/workspace/invitations` | Studio administration, `workspace:manage`. |
| Supplier, unit, code, client | `/v1/suppliers/{id}` etc. | **Studio-level, never nested under a project.** Nesting them would be a lie, and pretending otherwise is how `supplierId` ended up on the NO-RECORD-YET list. |

Identity everywhere: opaque string ids, never sequential, never composite. They already are;
the spec says so because an agent that infers structure from an id builds something that
breaks.

Plural collection, singular member, no verbs in paths. Actions that are genuinely verbs are
`POST` to a named sub-resource that *is* the record the act creates (RUL-177):
`/items/{id}/duplication`, `/shares/{id}/revocation`, `/projects/{id}/archival` and
`/items/{id}/archival` — each archival's description states that it is client-visible.

**Items archive; they are never deleted, and the spec says so** (corrected 14 Aug, when RUL-177 was
encoded and the two documents were found to disagree). `archiveItem` in `repositories.ts` is a soft,
versioned compare-and-set — it sets `archived_at`, bumps `version`, and guards on both the expected
version and `isNull(archivedAt)` — and there is no `.delete(items)` anywhere in `packages/db/src`.
So `DELETE /items/{id}` would have been **a lie an agent will believe**, which is the exact failure
RUL-177's deletion rule exists to prevent. The table below said "Items CRUD" and its D was that lie.

## The rule that makes nesting load-bearing

**A nested URL is a claim about ownership, and one middleware verifies it centrally.**
`GET /projects/{projectId}/items/{itemId}` → resolve the item, derive its project, **404 if
they disagree** — never 403, so a URL cannot probe for existence across a tenancy boundary.
This is RUL-158's fix (*a record derives its own project, and the pair must agree*) made
structural: in tRPC the pair was two fields of one input that could silently disagree; in a
URL the disagreement has nowhere to live.

**An item is not addressable outside its project.** `/items/{itemId}` does not exist. It
would be convenient, and it reintroduces exactly the ambiguity RUL-158 removed.

## Scope: v1 is the schedule, the client document, and imports

**Procurement is explicitly v2, not silently absent** — the fork RUL-185 posed, taken on its
own recommendation. Estimates and purchase orders are revisioned, state-machined documents;
**a revision is not a version** (a version is concurrency bookkeeping a client echoes back; a
revision is a domain object with identity, lifecycle and client-visible existence), and a
revision grammar designed without a consumer will be wrong in ways nobody discovers until
there is one. The spec's introduction says procurement is deliberately excluded from v1 and
names this document.

**One procurement act is in v1 anyway, because a client can already do it:** the estimate
decision through a share capability (RUL-184). `POST /v1/shares/{token}/decision` —
**corrected 14 Aug from `PUT`.** The `PUT`-for-idempotency reasoning assumed an anonymous
browser that could not mint an idempotency key; RUL-221 settled that the write needs a
verified session (SEC-008), and that session already mints one exactly as every other command
surface does (`apps/web/lib/idempotency.ts`), which is what `estimates.decide`'s real
implementation does (`idempotent(repositories, "estimates.decision", scope, input, fn)`,
`packages/db/src/application-services.ts`) — an ordinary idempotency-keyed create, not a
URL-idempotent replace. `decide_estimate` now matches `add_photograph`'s own shape: POST to
the action-noun the act creates, `Idempotency-Key` required, same as RUL-177's other
action-nouns. The capability must name the very revision being decided; a revoked capability
mid-decision refuses with `403 FORBIDDEN` — actionable, and it reveals nothing about whether
the estimate exists.

## Operations, not synchronous heroics

Import, bulk paste, document render, delivery: `202 Accepted` + `Location: /v1/operations/{opId}`,
`GET` answering `{ status, progress, result | problem }` (RUL-179). `imports.preview` /
`imports.commit` map onto one operation with a two-phase body, which is what they already are.

## Explicitly not resources

`schedule_entries`, `command_executions`, `audit_heads`, `outbox_jobs`, `rate_limits`,
`import_idempotency`, `capability_plate_captures`. Mechanism. Exposing them would freeze the
implementation into the contract.

**Nothing anywhere implies a read receipt.** No `lastAccessedAt`, no `openCount` — nothing
records a link being opened, and the API must not pretend otherwise (RUL-173, RUL-170).

## Disposition of all 23 sub-routers (167 procedures)

| Router | Procedures | Disposition |
| -- | -- | -- |
| `projects` | 23 | **v1.** CRUD + sections + members + archival action. `rollup`, `commercialPosition`, `adjustments`, `configuration`, `documentAddressContext`, `snapshotDocumentAddress`, `studioCurrencies` stay tRPC-only: workbench dashboard reads, not domain nouns. |
| `schedule` | 16 | **v1.** Items create/read/update plus an archival action — **never DELETE**, see above. Photographs (`listItemImages`/`add`/`reorder`/`remove` → the photographs collection), `duplicateItem` → duplication action, `moveItem`/`moveItemToSection` → one move action, `pasteItems` → an operation. `read` is the workbench's own aggregate and stays tRPC-only. |
| `customProperties` | 7 | **v1.** Properties, options, values — three nested collections. |
| `documents` | 5 | **v1 read** (`get`, `list` → client document + its renders); `materializeRenderSnapshot`/`requestDelivery` → operations; delivery ships only when Resend is configured. |
| `shareCapabilities` | 5 | **v1.** Shares collection; `issue`/`issueScheduleShare` collapse to one create; `revoke` → revocation append; `reissue` → its own action, because it is mint-plus-intent. |
| `imports` | 2 | **v1**, as one operation (preview → commit). |
| `audit` | 3 | **v1 read-only.** `integrity` stays tRPC-only — it is a self-check, not a feed. |
| `libraries` | 4 | **v1.** Studio-level CRUD. |
| `projectTemplates` | 4 | **v1.** `instantiate` is a create on `/projects` with a `templateId`, not a verb path. |
| `projectPackages` | 1 | **v1** if trivially cheap, else v2. One list. |
| `workspace` | 9 | **v1**, `workspace:manage`. `me` stays tRPC-only (session introspection). `setDefaultMarkup`/`studioDefaults` → studio settings resource. `transferOwnership` → v2: irreversible, wants its own design. |
| `specificationLibrary` | 14 | **v2.** Real domain, no external demand yet, and its drift/adopt lifecycle deserves the same care as procurement's revisions. |
| `estimates` | 13 | **v2** (procurement), except the capability decision above. |
| `purchasing` | 18 | **v2** (procurement). |
| `enquiries` | 4 | **v2** (procurement). |
| `charges` | 5 | **v2** (procurement/reconciliation). |
| `xero` | 15 | **v2.** Integration surface; handoff is irreversible and the spec will say so when it lands. |
| `studios` | 1 | **Not exposed.** Tenant creation is onboarding, not API. |
| `views` | 10 | **tRPC-only.** Saved workbench views are UI state. |
| `tableLayouts` | 2 | **tRPC-only.** Column widths. |
| `inspectorLayout` | 2 | **tRPC-only.** Panel order. |
| `railSections` | 2 | **tRPC-only.** Sidebar state. |
| `firstRunWelcome` | 2 | **tRPC-only.** Onboarding flag. |

Every procedure in a v1 router that stays tRPC-only is named above, so the acceptance
criterion — *resource, action, or an explicit not-exposed with a reason* — holds at the
procedure level for v1 and at the router level for v2.

## The test of the model

A reader who has never seen the schema should predict a URL they have never seen. If the
line about a sofa's photographs is not guessably at
`/v1/projects/{p}/items/{i}/photographs`, the model has failed regardless of what this
document says.
