# Suite integration contract — how satellite apps reference entities & projects in `with`

**Status:** contract frozen for the fields marked *stable*; the registry
API is *built* on the `with` side as R2 (v0.11.0). **Audience:** any app
in the Bowden Works suite that is NOT `with` itself — today `hosting`
(the sync rebuild), later `volleyboard` (review portal) and
`submitstream`. **Owner:** `with` is the system-of-record for people,
organizations, and projects; this contract is its public edge.
**Date:** 2026-07-10.

`with` is the hub. Satellites are project-/client-aware tools that hang
off it. This doc is the ONLY thing a satellite needs to know to link its
own records (a hosted site, a review board, a submission) to a client or
project in `with`. Read it standalone — you do not need the `with`
codebase to build against it.

---

## TL;DR for a satellite build

1. **To link your record to a client/company, store its `party_id`** — a
   UUID from `with`. One column, e.g. `hosted_site.owner_party_id`.
2. **Never store the entity's name as your own truth.** Store the id;
   resolve the display name through the registry API. `with` owns names,
   spelling, merges, and archival — if you cache a name, cache it as a
   hint you refresh, never as the key.
3. **User identity is separate and already solved** — the BW Auth
   username (Pattern B, `/srv/system/id-auth/app-auth/`). *Entity*
   identity (clients, companies, people) is the `party_id`. Don't conflate
   them: a person who logs in has BOTH a BW username AND a person party,
   and they are linked inside `with`, not by you.
4. **The `party_id` is stable.** It does not change if/when `with` splits
   its internal identity model into separate People and Organizations
   tables (see [ADR #012](decisions.md)). Build against it today; it will
   not move under you.

---

## Vocabulary (the words, and what they mean everywhere in the suite)

| term | meaning | who you link to |
|---|---|---|
| **party** | the umbrella identity for "a **person** OR an **organization**." A stable UUID. Invisible to end users — plumbing, never UI copy. | the thing every satellite stores |
| **organization** | a company — Brentwood, PlusROI, Google. A *kind of* party. | shown to users as the company name |
| **person** | a human — Heather, Adi. A *kind of* party. May log in; may belong to zero or many organizations. | shown as the person's name |
| **project** | a unit of client work inside `with`. Its own canonical UUID. | link ONLY if your record is genuinely project-scoped (most aren't — see below) |
| **role** (client, operator, vendor, partner, referrer, reseller…) | lives on the **relationship** between parties, **never** on the party itself. The same organization is a *client* in one context and a *vendor* in another. | you never store a role as an entity attribute — store the `party_id` |
| **tenant** | a set of books (Bowden Works, PlusROI). Operational rows belong to one tenant. | entities are shared across tenants; you link to the entity, not the tenant |

The one idea behind the whole table: **an entity is a neutral identity;
what it *is to you* is a role on a relationship, not a property of the
record.** That is why you store a `party_id` and nothing role-shaped.

---

## The linking rule (what to store)

**A hosted site belongs to its owner → store `owner_party_id` (a party id).**

- The owner is *usually* an **organization** (Brentwood) but MAY be a
  **person** (a freelancer hosting their own site, e.g. Heather with no
  company). `owner_party_id` accepts either without a schema change —
  that is the entire reason you point at the party, not at an
  organization-specific id. If you stored an "organization id" you would
  be dead the first time the owner is a person.
- **Do NOT make the ownership link a project.** A site outlives any one
  project; a project is a bounded engagement, hosting is ongoing. (This
  was the original question that started this contract — *"does the app
  reference a project or a client?"* — and the answer is: the **owner
  entity** (its `party_id`), not the project.) A project association, if
  you ever want one, is optional secondary metadata (`project_id`,
  nullable), never the ownership key.

### Reference facts about a client (Drive folder, Mosiah folder, …)

Decide with the **data-ownership rule**:

- **Operational to your tool → your tool stores it.** A hosting-only
  detail (server node, deploy hook, DNS record) lives in `hosting`.
- **A shared fact about the client with no single operational owner →
  it lives in `with`, as a field on the organization**, and every
  satellite reads it by `party_id`. A client's Google Drive folder and
  its "Mosiah folder" are shared client facts → they belong on the
  organization in `with`, not privately in hosting. Store the `party_id`;
  read the folder from the registry. (If a field is genuinely two
  different things per tool, it's two fields — don't overload one.)

**The only lock-in you accept is sharing the canonical id.** Everything
else you own.

---

## Why party, not organization (the one thing to internalize)

There are two layers and both are true:

- **Stored key (what your DB holds):** the `party_id`. The umbrella.
- **Displayed label (what your UI shows):** "Brentwood, an organization."

You store the id; you render the label by resolving the id. Three reasons
the key is the party and not an organization id:

1. **It accepts either kind.** Org today (Brentwood), person tomorrow
   (Heather). One column, no special case.
2. **You couple to the anchor, never to a subtype.** `party_id` is the id
   `with` guarantees. Internally `with` may keep a single identity table
   or split it into People + Organizations detail tables ([ADR #012](decisions.md));
   either way the `party_id` is the stable outer identity. If you linked
   to an "organization row id," an internal restructure could move it.
3. **Uniformity across the suite.** Every satellite references entities
   the same way — a `party_id` through one registry shape — so the hub
   exposes one contract, not one per app.

---

## The registry API (how you resolve and link)

> **Status: BUILT (v0.11.0) — live on the `with` side as R2.** The
> endpoint shapes below are the contract; treat them as stable. The
> satellite→`with` authentication mechanism is now RESOLVED — a Bearer
> service token (see the **Auth** subsection below; ADR #014). Build your
> storage (`owner_party_id` UUID column) and your linking UI against these
> shapes and wire the live calls with your provisioned token.

All ids are UUIDs. **Treat every id as opaque** — never parse or derive
meaning from it.

```
GET  /api/registry/parties/{id}
  → 200 { id, kind: "person" | "organization", name, is_active }
  → 404 if unknown
  Resolve a stored party_id to a display label. `is_active:false` = the
  entity was archived/merged in `with`; keep showing your link but flag it.

GET  /api/registry/parties?query=<str>&kind=organization&limit=20
  → 200 { results: [ { id, kind, name, is_active }, … ] }
  Typeahead for your "link this site to a client" picker. Omit `kind` to
  search people and organizations together; pass `kind=organization` for
  the common "pick a company" case.

POST /api/registry/organizations                    # the ONE write endpoint
  body { name, allow_duplicate?: bool }   # name required (trimmed, ≤200 chars);
                                          # allow_duplicate defaults to false
  → 201 { id, kind:"organization", name, is_active:true,
          near_matches: [ {id, kind, name, is_active}, … ] }   # id IS the new party_id
  → 409 { error_code:"ORG_EXISTS",
          existing: {id, kind, name, is_active}, summary, detail }
  Creates a party anchor + an organization detail row atomically — so one
  call yields both a new party_id and the organization. Entities are
  suite-global (NOT tenant-scoped): no "whose books?" question. The hub
  derives the slug; the ONLY required field is the organization name.
  DEDUP (two layers):
    • HARD guard — if an ACTIVE organization already exists with the same
      name (case-insensitive), the create is REFUSED with `409 ORG_EXISTS`
      and the response carries `existing` (the clashing party). LINK that
      one instead of minting a duplicate. To create a genuinely distinct
      org with a colliding name anyway (two real companies, same name),
      resend with `allow_duplicate: true`.
    • SOFT hint — a successful `201` carries `near_matches`: up to 5 ACTIVE
      orgs whose name is a substring of (or contains) the new name — a
      "did you mean one of these?" nudge, never an error. Prefer the
      search-first flow (typeahead, then "create new" only when nothing
      fits) so you LINK rather than duplicate.
  Authed + audited (`registry.create_org`, NULL tenant/actor) — this is a
  write into the hub's source of truth, unlike every GET above.

GET  /api/registry/projects/{id}
  → 200 { id, name, client_party_id, tenant_id }
  → 404 if unknown
  Only if a satellite record is genuinely project-scoped.

GET  /api/registry/organizations/{id}/reference
  → 200 { party_id, drive_folder_url, mosiah_folder, … }
  Shared client reference facts (see "Reference facts" above). Fields
  accrete as `with` adds them; absent = not set.
```

**Auth (RESOLVED — a Bearer service token, v0.11.0):** every registry
request carries `Authorization: Bearer <token>`. Server-to-server ONLY —
the satellite presents its own service credential, NOT an end-user browser
session. (`with` deliberately does NOT accept its app session cookie on
these routes; a session is never a substitute for the token. See
[decisions.md ADR #014](decisions.md) for why a bearer-token-in-env beat a
Pattern-B client-credentials grant for v1.)

- **The token** lives on the `with` host at `/srv/apps/with/.registry.env`
  (mode `600`, key `REGISTRY_SERVICE_TOKEN`). The owner provisions each
  satellite's copy out-of-band — it is never printed in chat, logged, or
  echoed in any response. Rotating the token in that file (and redeploying
  `with`) instantly revokes every satellite until they get the new value.
- **Fail-closed:** if `with` has no token configured, EVERY registry route
  answers `503 { error_code:"REGISTRY_DISABLED" }` — the surface is OFF
  until the owner wires the secret. A missing/malformed bearer →
  `401 REGISTRY_AUTH_REQUIRED`; a present-but-wrong token →
  `401 REGISTRY_AUTH_INVALID`. All errors speak the structured
  `{error_code, summary, detail}` triple.
- **Base URL:** for a satellite on the SAME host, prefer the internal
  Docker-bridge address `http://172.17.0.1:3126` — it skips the public
  edge (Caddy + the id-auth gate) entirely, which is both faster and one
  fewer moving part. The public `https://with.bowden.works` also works
  (the registry routes are exempt from the id-auth session gate, so they
  answer directly). Either way, the token is the ONLY credential.

---

## What is NOT locked in (so you can build without fear)

- Your **storage** commits to exactly one thing: a `party_id` (UUID)
  column, plus optional `project_id`. Everything else — your tables, your
  UI, your deploy model — is yours.
- `with`'s **internal shape is invisible to you.** Single identity table
  or People+Organizations split, it changes nothing you can see: the
  `party_id` and the registry responses are the stable surface.
- **Names are not your source of truth**, so a rename/merge in `with`
  never corrupts your data — you resolve fresh.

If a future need can't be met by "store a `party_id`, resolve via the
registry," that's a signal to extend *this contract* (add an endpoint or
a field), not to reach into `with`'s database. The database is never a
shared surface; this doc is.

---

## Cross-references

- **User identity / login** (Pattern B, "Sign in with BW"):
  `/srv/system/id-auth/app-auth/` (README, PATTERN-B.md, `bw_auth.py`).
- **The entity-model decision** (why party is an umbrella over People and
  Organizations, the Heather/vendor/login cases, the blast radius):
  [`decisions.md` ADR #012](decisions.md).
- **The founding domain model** (parties, engagements, roles-on-
  relationships): `/srv/apps/work/docs/rebuild/02-domain-model.md` §1–2.
- **This contract lives in `with`** because `with` owns the entities. A
  satellite reads it here, exactly as it reads the auth kit from
  `id-auth`. (A pointer to this file could be added next to the BW Auth
  kit; that's an owner-governed change to `id-auth`, proposed separately —
  not edited from the `with` tree.)
