diff --git a/docs/api/changelog.md b/docs/api/changelog.md index 385ea53..a346abe 100644 --- a/docs/api/changelog.md +++ b/docs/api/changelog.md @@ -15,7 +15,7 @@ Initial public API release. Stable contract for paths, field names, response sha Two identifier-model clarifications that sharpen the line between the surrogate `id` and the `external_key` handle. Pre-launch; no `v1.0.0`-or-later wire baseline to break. The `external_key` item is a docs correction only (service behavior unchanged); the `id` item reflects the move to a single shared id sequence landing under the hood. -- **The surrogate `id` is globally unique across every resource type, opaque, and permanent.** No two rows — of any type — share an `id`; an `id` never changes and is never reused, because the API never hard-deletes and the shared id sequence is never reseeded. The prior docs described ids as unique only within an entity type (so the same integer could be both an asset id and a tag id). That cross-type collision is eliminated: ids are now minted from one shared sequence, so global uniqueness holds by construction. Treat `id` as opaque (don't parse it, order by it, or infer a count or creation time) and use it as your durable foreign key. See [ID format](./id-format) and [Resource identifiers → Numeric `id` is a surrogate key](./resource-identifiers#numeric-id-is-a-surrogate-key). +- **The surrogate `id` is globally unique across every resource type, opaque, and permanent.** No two rows — of any type — share an `id`; an `id` never changes and is never reused, because the API never hard-deletes and the shared id sequence is never reseeded. The prior docs described ids as unique only within an entity type (so the same integer could be both an asset id and a tag id). That cross-type collision is eliminated: ids are now minted from one shared sequence, so global uniqueness holds by construction. Treat `id` as opaque (don't parse it, order by it, or infer a count or creation time) — it is a stable internal anchor for reconciliation, not your business foreign key; join your own systems on the natural key (`external_key`). See [ID format](./id-format) and [Resource identifiers → Numeric `id` is a surrogate key](./resource-identifiers#numeric-id-is-a-surrogate-key). - **`external_key` auto-mint is `MAX(live key) + 1`, not a lowest-unused-slot allocator.** Earlier wording ("lowest unused slot among live rows," "gap-filling") was incorrect: the server takes the highest live `ASSET-NNNN` / `LOC-NNN` in your org and adds one, zero-padded (`ASSET-%04d` / `LOC-%03d`). A middle-of-range delete therefore leaves a *permanent* gap that is never backfilled; only deleting the current highest live key frees its number for re-issue. A separate, caller-initiated path still lets you re-supply a soft-deleted key string directly (the partial unique index is `WHERE deleted_at IS NULL`, so deleted keys sit outside it). The [auto-mint section on Resource identifiers](./resource-identifiers#external_key-is-optional-on-create) and the `LOC-NNN` changelog entry below are corrected to match. Service behavior is unchanged; only the docs were wrong. ### Surrogate ids are int64 end-to-end; the int32 ceiling and id `too_large` are retired {#surrogate-ids-int64} diff --git a/docs/api/resource-identifiers.md b/docs/api/resource-identifiers.md index 749a3bc..5001e0b 100644 --- a/docs/api/resource-identifiers.md +++ b/docs/api/resource-identifiers.md @@ -55,10 +55,19 @@ This is the conventional REST shape and the URL stays valid even if the asset's ### Numeric `id` is a surrogate key -Numeric `id` values are surrogate keys — server-assigned, high-entropy, and **globally unique across every resource type**: no two rows, whether two assets or an asset and a tag, ever share an `id`. They are **opaque** (don't parse an `id`, order by it, or infer a count or creation time from it) and **permanent** (an `id` never changes and is never reused — the API never hard-deletes, and ids are minted from a single shared sequence that is never reseeded). The API still carries each id with its entity context — by URL position (`/assets/{asset_id}`, `/locations/{location_id}/tags/{tag_id}`) or query-parameter name (`location_id`, `parent_id`) — and client code matches ids to their entity type as standard surrogate-key discipline. Use `id` as your durable foreign key when you mirror TrakRF data. +Numeric `id` values are surrogate keys — server-assigned, high-entropy, and **globally unique across every resource type**: no two rows, whether two assets or an asset and a tag, ever share an `id`. They are **opaque** (don't parse an `id`, order by it, or infer a count or creation time from it) and **permanent** (an `id` never changes and is never reused — the API never hard-deletes, and ids are minted from a single shared sequence that is never reseeded). The API still carries each id with its entity context — by URL position (`/assets/{asset_id}`, `/locations/{location_id}/tags/{tag_id}`) or query-parameter name (`location_id`, `parent_id`) — and client code matches ids to their entity type as standard surrogate-key discipline. `id` is a stable internal anchor — server-assigned, opaque, and not arbitrarily rekeyed — which makes it useful as a sync/reconciliation handle when you mirror TrakRF data. It is **not** your business foreign key: key your own system of record on the natural key (`external_key`) where one exists, and reach for `id` only as the durable handle when no natural key is available. See [Joining your system of record](#joining-your-system-of-record) for the per-resource rule. Every surrogate id is declared **int64** (`format: int64`, `maximum: 9007199254740991`) on the spec — the same type and ceiling on response bodies, request bodies, `_id` query filters, and path parameters. The `maximum` is JavaScript's `Number.MAX_SAFE_INTEGER` (2⁵³−1), so every admissible id is exactly representable in a `number`-typed client. See [ID format: int64 wire, int64 runtime](./id-format) for the rationale, the id-boundary error envelopes, and what it means for typed-client codegen. +### Joining your system of record {#joining-your-system-of-record} + +When you mirror TrakRF data into your own system, join on the **natural key**, not the surrogate `id`: + +- **Assets and locations** — join on `external_key`, your own handle (a SKU, an asset tag, an ERP code, a facility code). It is the value your warehouse software, ERP, or operator already recognizes, and it round-trips on every response. +- **General rule** — join on a stable natural key where one exists; where none does, `id` is the durable handle. Use `id` as a reconciliation anchor in that case, not as a business key you export into other systems. + +The public integration surface is assets, locations, and tags. Users and organization administration are not public joinable resources in v1 — an integration authenticates as a single organization through its API key (see [`/orgs/me`](./private-endpoints#orgs-me)), so there is no cross-org or cross-user join to maintain. + ## Natural-key lookup uses `?external_key=` When you have the natural key but not the canonical `id`, filter the list endpoint by `external_key`: diff --git a/docs/api/versioning.md b/docs/api/versioning.md index dd68928..0f58865 100644 --- a/docs/api/versioning.md +++ b/docs/api/versioning.md @@ -32,6 +32,10 @@ Within `/api/v1/`, TrakRF commits to the following: Clients written against v1 will continue to work as TrakRF adds features. Clients that treat unknown response fields or unknown enum values as errors will not — so don't. +### The surrogate `id` is an internal anchor, not your join key + +The `id` field is stable and won't be arbitrarily rekeyed, which makes it a usable sync / reconciliation anchor. It is **not** the integrator's business foreign key — don't key your own system of record on it. Join on the natural key (`external_key`) where one exists; see [Resource identifiers → Joining your system of record](./resource-identifiers#joining-your-system-of-record). The field-stability commitment above (names and types of returned fields don't change without a major version) is the contract that applies to `id`; TrakRF does not publish a permanence guarantee beyond it, because treating `id` as a durable external business key re-introduces the coupling the natural-key model is designed to avoid. + ## Open vs closed enums Enums on the wire come in two flavors, and the difference matters for client code: diff --git a/superpowers/plans/2026-05-30-tra-891-api-identity-join-guidance.md b/superpowers/plans/2026-05-30-tra-891-api-identity-join-guidance.md new file mode 100644 index 0000000..ce4d081 --- /dev/null +++ b/superpowers/plans/2026-05-30-tra-891-api-identity-join-guidance.md @@ -0,0 +1,211 @@ +# TRA-891 — API Identity Join Guidance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Correct the published advice that integrators should use the surrogate `id` as their durable foreign key; reframe `id` as an internal reconciliation anchor, add natural-key join guidance, and demote `id` on the versioning page. + +**Architecture:** Docs-only prose edits across three Docusaurus Markdown pages plus the changelog. No code, no OpenAPI/platform edits (verified: the wrong advice lives only in docs prose; `external_key` is confirmed present+required on both `AssetView` and `LocationView`; `customer_identifier`/`slug`/public users resources do not exist on the API). "Tests" for a docs change are `pnpm build`, `pnpm lint`, internal-link resolution, and a manual read-through. + +**Tech Stack:** Docusaurus 3, Markdown/MDX, Redocusaurus (fetches the live OpenAPI spec at build), pnpm. + +--- + +## File Structure + +- `docs/api/resource-identifiers.md` — fix the wrong `id`-as-FK sentence; add a "Joining your system of record" subsection. (Primary edit site.) +- `docs/api/changelog.md` — reword the TRA-885 changelog entry to drop the FK prescription. +- `docs/api/versioning.md` — add an `id`-demotion note under the stability commitment. + +All three are existing prose pages; no new files, no restructure. + +--- + +### Task 1: Remove the actively-wrong `id`-as-foreign-key sentence (launch-relevant, do first) + +**Files:** +- Modify: `docs/api/resource-identifiers.md` (the "Numeric `id` is a surrogate key" section, ~line 58) + +- [ ] **Step 1: Locate the exact current sentence** + +Run: `grep -n "Use \`id\` as your durable foreign key when you mirror TrakRF data." docs/api/resource-identifiers.md` +Expected: one match at the end of the surrogate-key paragraph. + +- [ ] **Step 2: Replace the final sentence of that paragraph** + +Find this trailing sentence (keep everything before it unchanged): + +``` +client code matches ids to their entity type as standard surrogate-key discipline. Use `id` as your durable foreign key when you mirror TrakRF data. +``` + +Replace with: + +``` +client code matches ids to their entity type as standard surrogate-key discipline. `id` is a stable internal anchor — server-assigned, opaque, and not arbitrarily rekeyed — which makes it useful as a sync/reconciliation handle when you mirror TrakRF data. It is **not** your business foreign key: key your own system of record on the natural key (`external_key`) where one exists, and reach for `id` only as the durable handle when no natural key is available. See [Joining your system of record](#joining-your-system-of-record) for the per-resource rule. +``` + +- [ ] **Step 3: Verify the wrong advice is gone** + +Run: `grep -rn "durable foreign key" docs/api/resource-identifiers.md` +Expected: no match (the phrase is removed from this file). + +- [ ] **Step 4: Commit** + +```bash +git add docs/api/resource-identifiers.md +git commit -m "docs(api): stop advising id as the durable foreign key (TRA-891)" +``` + +--- + +### Task 2: Add the "Joining your system of record" subsection + +**Files:** +- Modify: `docs/api/resource-identifiers.md` (insert between the end of the "Numeric `id` is a surrogate key" section and the "## Natural-key lookup uses `?external_key=`" heading, ~line 61) + +- [ ] **Step 1: Find the insertion point** + +Run: `grep -n "## Natural-key lookup uses" docs/api/resource-identifiers.md` +Expected: one match (`## Natural-key lookup uses \`?external_key=\``). Insert the new subsection immediately *before* this heading, after the blank line that follows the int64 paragraph (the paragraph ending "…typed-client codegen."). + +- [ ] **Step 2: Insert the subsection** + +Insert (note: `###` keeps it under the `## Path-param lookup uses \`id\`` section, beside "Numeric `id` is a surrogate key"): + +```markdown +### Joining your system of record {#joining-your-system-of-record} + +When you mirror TrakRF data into your own system, join on the **natural key**, not the surrogate `id`: + +- **Assets and locations** — join on `external_key`, your own handle (a SKU, an asset tag, an ERP code, a facility code). It is the value your warehouse software, ERP, or operator already recognizes, and it round-trips on every response. +- **General rule** — join on a stable natural key where one exists; where none does, `id` is the durable handle. Use `id` as a reconciliation anchor in that case, not as a business key you export into other systems. + +The public integration surface is assets, locations, and tags. Users and organization administration are not public joinable resources in v1 — an integration authenticates as a single organization through its API key (see [`/orgs/me`](./private-endpoints#orgs-me)), so there is no cross-org or cross-user join to maintain. +``` + +- [ ] **Step 3: Verify the anchor target exists for the Task 1 cross-link** + +Run: `grep -n "joining-your-system-of-record" docs/api/resource-identifiers.md` +Expected: two matches — the `#joining-your-system-of-record` link from Task 1 and the `{#joining-your-system-of-record}` heading anchor here. + +- [ ] **Step 4: Commit** + +```bash +git add docs/api/resource-identifiers.md +git commit -m "docs(api): add per-resource natural-key join guidance (TRA-891)" +``` + +--- + +### Task 3: Reword the TRA-885 changelog entry + +**Files:** +- Modify: `docs/api/changelog.md` (~line 18, the globally-unique-id bullet) + +- [ ] **Step 1: Locate the FK prescription in the changelog** + +Run: `grep -n "use it as your durable foreign key" docs/api/changelog.md` +Expected: one match within the "surrogate `id` is globally unique" bullet. + +- [ ] **Step 2: Replace the trailing clause** + +Find: + +``` +That cross-type collision is eliminated: ids are now minted from one shared sequence, so global uniqueness holds by construction. Treat `id` as opaque (don't parse it, order by it, or infer a count or creation time) and use it as your durable foreign key. See [ID format](./id-format) +``` + +Replace with: + +``` +That cross-type collision is eliminated: ids are now minted from one shared sequence, so global uniqueness holds by construction. Treat `id` as opaque (don't parse it, order by it, or infer a count or creation time) — it is a stable internal anchor for reconciliation, not your business foreign key; join your own systems on the natural key (`external_key`). See [ID format](./id-format) +``` + +- [ ] **Step 3: Verify no FK prescription remains** + +Run: `grep -rn "durable foreign key" docs/api/` +Expected: no matches anywhere under `docs/api/`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/api/changelog.md +git commit -m "docs(changelog): drop id-as-foreign-key advice from TRA-885 entry (TRA-891)" +``` + +--- + +### Task 4: Demote `id` on the versioning page + +**Files:** +- Modify: `docs/api/versioning.md` (insert after the "## Stability commitment (v1)" section, before "## Open vs closed enums") + +- [ ] **Step 1: Find the insertion point** + +Run: `grep -n "^## Open vs closed enums" docs/api/versioning.md` +Expected: one match. Insert the new subsection immediately before this heading (after the "Clients written against v1 will continue to work… so don't." paragraph). + +- [ ] **Step 2: Insert the demotion note** + +```markdown +### The surrogate `id` is an internal anchor, not your join key + +The `id` field is stable and won't be arbitrarily rekeyed, which makes it a usable sync / reconciliation anchor. It is **not** the integrator's business foreign key — don't key your own system of record on it. Join on the natural key (`external_key`) where one exists; see [Resource identifiers → Joining your system of record](./resource-identifiers#joining-your-system-of-record). The field-stability commitment above (names and types of returned fields don't change without a major version) is the contract that applies to `id`; TrakRF does not publish a permanence guarantee beyond it, because treating `id` as a durable external business key re-introduces the coupling the natural-key model is designed to avoid. +``` + +- [ ] **Step 3: Verify the cross-link target matches Task 2's anchor** + +Run: `grep -n "resource-identifiers#joining-your-system-of-record" docs/api/versioning.md` +Expected: one match — the slug must equal the `{#joining-your-system-of-record}` anchor created in Task 2. + +- [ ] **Step 4: Commit** + +```bash +git add docs/api/versioning.md +git commit -m "docs(api): demote surrogate id on versioning page (TRA-891)" +``` + +--- + +### Task 5: Validate the build and links, then finalize + +**Files:** none (verification only) + +- [ ] **Step 1: Lint** + +Run: `pnpm lint` +Expected: passes (no new errors). If Prettier reports formatting on the edited files, run `pnpm format` (or the repo's documented formatter) and amend the relevant commit. + +- [ ] **Step 2: Typecheck** + +Run: `pnpm typecheck` +Expected: passes. + +- [ ] **Step 3: Production build (catches broken internal links / anchors)** + +Run: `pnpm build` +Expected: build succeeds. Docusaurus fails the build on broken Markdown links, so a clean build confirms the `#joining-your-system-of-record` cross-links (Task 1, Task 4) and the `./private-endpoints#orgs-me` link resolve. + +- [ ] **Step 4: Read-through verification** + +Run: `grep -rn "durable foreign key\|as your.*foreign key\|use \`id\` as your" docs/api/` +Expected: no matches. Manually confirm: resource-identifiers.md reframe reads coherently; the new subsection states the assets/locations→`external_key` rule and the general rule; versioning.md note demotes `id` without adding a permanence guarantee. + +- [ ] **Step 5: No further commit needed** unless Step 1 required a formatting fix; the four content commits from Tasks 1–4 stand. + +--- + +## Self-Review + +**Spec coverage:** +- "Remove `id`-as-FK advice (do first)" → Task 1 ✓ +- Per-entity join guidance (assets/locations on `external_key`; general rule) → Task 2 ✓ +- Reframe `id` (stable anchor, not business FK) → Task 1 + Task 2 + Task 4 ✓ +- Versioning page: breaking-change policy (already present) + demote `id`, no absolute permanence guarantee → Task 4 ✓ +- Delta sync → not advertised; nothing to add (per spec verification) — intentionally no task ✓ +- Users/orgs join guidance → intentionally omitted (not public joinable resources); the omission is captured by Task 2's "not public joinable resources in v1" note ✓ +- Edit-site/PR shape: docs-only, no platform edit → reflected in plan (no OpenAPI tasks) ✓ + +**Placeholder scan:** No TBD/TODO; every edit shows exact before/after prose. ✓ + +**Type/anchor consistency:** The anchor slug `joining-your-system-of-record` is defined in Task 2 and referenced identically in Task 1 and Task 4. The `./private-endpoints#orgs-me` anchor matches the existing `{#orgs-me}` in private-endpoints.md. ✓ diff --git a/superpowers/specs/2026-05-30-tra-891-api-identity-join-guidance-design.md b/superpowers/specs/2026-05-30-tra-891-api-identity-join-guidance-design.md new file mode 100644 index 0000000..4d23f23 --- /dev/null +++ b/superpowers/specs/2026-05-30-tra-891-api-identity-join-guidance-design.md @@ -0,0 +1,115 @@ +# TRA-891 — Correct API integration identity guidance: join on natural keys, demote surrogate `id` + +**Date:** 2026-05-30 +**Ticket:** TRA-891 (revises TRA-885) +**Type:** Docs-only PR (verified — see Edit-site determination) + +## Problem + +TRA-885 shipped a line advising integrators to use the surrogate `id` as their durable +foreign key when mirroring TrakRF data. That contradicts the intended model: integrators +should join on natural/business keys (`external_key`), and the surrogate `id` is a stable +internal anchor for reconciliation — **not** the integrator's business foreign key. Telling +partners to key their system of record on our `id` manufactures a dependency on an opaque +internal identifier, which is exactly the coupling we want to avoid before launch. + +This spec corrects that line, reframes `id`, adds join guidance, and demotes `id` on the +versioning page. + +## Prerequisite verification (done before writing) + +The ticket requires docs to describe real behavior, not aspiration. Verified against the +live spec at `https://app.preview.trakrf.id/api/openapi.yaml` (the source Redocusaurus +builds from) and the current docs prose: + +| Claim to verify | Finding | Consequence | +| --- | --- | --- | +| `external_key` present + returned on assets | `AssetView.external_key`, `required` | Document join on `external_key` ✓ | +| `external_key` present + returned on locations | `LocationView.external_key`, `required` (ticket flagged this "unconfirmed") | **Confirmed present** — safe to document the locations join ✓ | +| `customer_identifier` field exists on the API | **0 occurrences** in the spec | The API field is `external_key`. The ticket's "(customer_identifier)" is platform/DB column terminology — do **not** introduce `customer_identifier` into public docs | +| Users are a public, joinable resource | `/api/v1/users/me` and `/users/me/current-org` are **Internal**; no public `User`/`UserView` schema; no `email` join surface | **Do not** add per-entity "join users on email/id" guidance — there is no public users resource to join | +| Organizations are a public, joinable resource with a `slug` | Only `/api/v1/orgs/me` is public (singleton, key-scoped); `OrgView` = `{id, name, scopes, api_key_id}`; **no `slug`**, no public org list/get-by-id | **Do not** add "join orgs on slug/id" guidance — `slug` does not exist on the public surface, and an integrator is always scoped to its own org | +| Wrong "durable foreign key" advice also in OpenAPI field descriptions? | **No** — only in docs-repo prose (`resource-identifiers.md`, `changelog.md`) | This is a **docs-only PR**; no paired platform edit needed | +| Delta / incremental sync advertised anywhere? | **Not advertised** (no `delta`, `updated_since`, cursor-sync capability claims) | Nothing to retract and nothing to add — do not introduce a "not-yet-supported" mention for a capability we never claimed | + +### Deviation from the ticket's literal scope (intentional) + +The ticket lists per-entity join guidance for four entity types. Verification shows only +**assets** and **locations** are public joinable resources carrying the relevant field +(`external_key`). **Users** and **organizations** are not part of the public integration +surface (users are internal-only; the public org surface is the singleton `/orgs/me` with +no `slug`). Per the ticket's own instruction — *"do not document a join key that is not +present"* / *"describe real behavior, not aspiration"* — the user/org email-vs-id and +slug-vs-id guidance is **omitted** rather than published as aspiration. The general rule +(join on a stable natural key where one exists; where none does, `id` is the durable +handle) is stated, which covers the intent without inventing a public surface. + +This is flagged here and in the PR for reviewer (ticket-author) sign-off at the merge gate. + +## Changes + +All edits are docs-repo prose. Branch: `docs/tra-891-identity-join-guidance`. + +### 1. `docs/api/resource-identifiers.md` — the launch-relevant fix (do first) + +**§ "Numeric `id` is a surrogate key" (line ~58).** Remove the final sentence +*"Use `id` as your durable foreign key when you mirror TrakRF data."* Replace with a reframe: + +- `id` is server-assigned, opaque, stable, and not arbitrarily rekeyed — usable as a + **sync / reconciliation anchor** when you mirror TrakRF data. +- It is **not** your business foreign key. Join your system of record on the natural key + (`external_key`) where one exists; reach for `id` only as the durable handle when no + natural key is available. + +Keep the existing factual properties (globally unique, opaque, minted from a non-reseeded +shared sequence) — those are correct and were just affirmed by TRA-885. Only the +prescriptive "use it as your FK" framing changes. Do **not** escalate the wording into an +absolute permanence guarantee. + +**§ intro (line ~7).** Light touch only — the existing framing already names `external_key` +as "canonical for partner-side joins / the natural key," which is correct. Ensure nothing in +the intro implies `id` is the partner-side join key. + +### 2. New subsection in `resource-identifiers.md` — per-entity join guidance + +Add a short subsection near the surrogate-key section stating the rule explicitly: + +- **Assets / locations:** join on `external_key` (your handle: SKU, asset tag, ERP code, + facility code). +- **General rule:** join on a stable natural key where one exists; where none does, `id` is + the durable handle — used as a reconciliation anchor, not exported as a business key. +- Note that the public integration surface is assets, locations, and tags; users and + organization administration are not public joinable resources in v1 (an integration is + scoped to a single organization via its API key). + +### 3. `docs/api/changelog.md` (line ~18) + +Reword the TRA-885 changelog entry to drop *"and use it as your durable foreign key"* / +*"use it as your durable foreign key."* Keep the global-uniqueness/opaque/permanent facts; +remove only the FK prescription so the changelog doesn't re-assert the corrected advice. + +### 4. `docs/api/versioning.md` — demote `id` + +Add a short note (under the stability commitment) that the surrogate `id` is a stable +internal anchor, **not** the integrator's business foreign key — integrators should key +their own systems on natural keys (`external_key`). The page already states the +additive-only / RFC 8594 breaking-change policy, so no new policy is needed. **Do not** add +an absolute `id`-permanence guarantee to the contract — that would re-sell the dependency +this ticket removes. + +## Out of scope + +- `external_key` uniqueness enforcement (a platform constraint) — separate follow-up. +- Membership / access audit log — separate follow-up. +- Any OpenAPI field-description edits (none needed; advice lives only in docs prose). +- Per-entity join guidance for users/orgs as joinable resources (not present on the public + surface — see verification). + +## Validation + +- `pnpm build` succeeds (Redocusaurus fetches the live spec; the prose edits are + Markdown-only and must not break the build). +- `pnpm typecheck` / `pnpm lint` clean. +- Internal doc links resolve (the new subsection anchor, and any cross-links to it). +- Manual read-through: the corrected pages no longer advise `id`-as-foreign-key anywhere, + and the reframe reads coherently against the surrounding text.