diff --git a/plugin-sdk/CLAUDE.md b/plugin-sdk/CLAUDE.md index f8c288e26..a5bf48b14 100644 --- a/plugin-sdk/CLAUDE.md +++ b/plugin-sdk/CLAUDE.md @@ -30,7 +30,7 @@ Because the package ships standalone it cannot import server code, so several fi | SDK copy | Source of truth | Parity test | |---|---|---| -| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog, `KNOWN_ADDONS`, the output contract `PLUGIN_ENTITY_FIELDS` / `PLUGIN_METHOD_RESULT`) | `server/src/nest/plugins/protocol/envelope.ts`, `protocol/output-contract.ts` and `server/src/addons.ts` via `server/scripts/gen-plugin-facts.ts` — **machine-written, never hand-edit** | server `check:plugin-facts` CI gate; the entity interfaces in `src/index.ts` are type-checked against `PLUGIN_ENTITY_FIELDS` | +| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog, `KNOWN_ADDONS`, the output contract `PLUGIN_ENTITY_FIELDS` / `PLUGIN_ENTITY_NESTED` / `PLUGIN_METHOD_RESULT`) | `server/src/nest/plugins/protocol/envelope.ts`, `protocol/output-contract.ts` and `server/src/addons.ts` via `server/scripts/gen-plugin-facts.ts` (**machine-written, never hand-edit**) | server `check:plugin-facts` CI gate; the entity interfaces in `src/index.ts` are type-checked against `PLUGIN_ENTITY_FIELDS` | | `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` | | `src/manifest.ts` (validation rules incl. the `trek` version range) | `server/src/nest/plugins/install/` | kept in sync by hand | | `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; `TREK_PLUGINS_REPO` points at a checkout) | diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index d0160430c..f5a715ef3 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -132,25 +132,39 @@ ungranted call throws `PERMISSION_DENIED`. Every ctx method falls into one of three kinds, listed per method in the exported `PLUGIN_METHOD_RESULT`: -- **An entity** (`trip`, `place`, `day`, `reservation`, `packingItem`, `tripFile`, - `budgetItem`, `assignment`, `user`; a list where the entry ends in `[]`). Each row - carries exactly the fields in `PLUGIN_ENTITY_FIELDS[entity]` and no others: the - table's published columns in snake_case, then what TREK adds on top (for a trip - `day_count`, `place_count`, `is_owner`, `owner_username` and `shared_count`; for a - place `category`, `tags` and `ratings`; for a reservation `endpoints`, `travelers` - and `day_positions`; for a day its `assignments` and `notes_items`). A field is only - present when the method's read produces it, so `trips.getById` has no `day_count` - while `trips.listMine` does. Credentials are never part of a row (a trip's - calendar `feed_token`, any account data beyond a user's id, username, display name - and avatar). A column TREK adds later is not delivered until TREK publishes it in - this list, so a row never changes shape behind your back. The `Trip`, `Place`, ... - interfaces name the commonly used fields and are checked against these lists when - the SDK is built; `Place.day_id` is deprecated and never delivered, since a place's - days are the `assignments` of `ctx.trips.getDays()`. -- **`readModel`**: the app's own read model for that data (tags, to-dos, collab - notes, journal entries, bags, collections, accommodations and the like), passed on - as TREK's REST route returns it. These are typed `unknown`; treat a field you did - not check for as optional. +- **An entity** (a list where the entry ends in `[]`). The trip data: `trip`, + `place`, `day`, `reservation`, `accommodation`, `packingItem`, `packingBag`, + `tripFile`, `fileLink`, `budgetItem`, `assignment`, `dayNote`, `todo` and `user`; + the collab board: `collabNote`, `collabPoll` and `collabMessage`; the acting + user's own data: `tag`, `category`, `journey`, `journalEntry`, `bucketItem` and + `collection`. Each row carries exactly the fields in `PLUGIN_ENTITY_FIELDS[entity]` + and no others: the table's published columns in snake_case, then what TREK adds on + top (for a trip `day_count`, `place_count`, `is_owner`, `owner_username` and + `shared_count`; for a place `category`, `tags` and `ratings`; for a reservation + `endpoints`, `travelers` and `day_positions`; for a day its `assignments` and + `notes_items`). A field is only present when the method's read produces it, so + `trips.getById` has no `day_count` while `trips.listMine` does. Credentials are + never part of a row (a trip's calendar `feed_token`, any account data beyond a + user's id, username, display name and avatar). A column TREK adds later is not + delivered until TREK publishes it in this list, so a row never changes shape behind + your back. + + The same holds for the rows inside a row. `PLUGIN_ENTITY_NESTED` names, per entity, + the fields that hold rows of another entity (a day's `notes_items` are `dayNote` + rows, a reservation's `endpoints` are `reservationEndpoint` rows), and those carry + that entity's fields and no others, however deep they sit. Every other added field + (counts, `{ user_id, username }` lists, id lists, a place's `category`) is a value + TREK builds field by field. Three results are envelopes around rows rather than a + row: `collections.listMine` (`collectionListing`), `collections.get` + (`collectionDetail`) and `vacay.mine` (`vacayPlanData`). + + The `Trip`, `Place`, ... interfaces name the commonly used fields and are checked + against these lists when the SDK is built; `Place.day_id` is deprecated and never + delivered, since a place's days are the `assignments` of `ctx.trips.getDays()`. +- **`readModel`**: a small result TREK builds field by field from named columns, so + no stored row passes through it whole: a bag's members, the Atlas `visited` codes, + what a Vacay toggle did, a collection's saved place or copy summary, and a journal + photo. These are typed `unknown`; treat a field you did not check for as optional. - **`host`**: a value the host builds itself (`{ deleted }`, `{ sent }`, a model's answer, an access token) or data you own (`ctx.db`, `ctx.meta`, another plugin's answer), returned as it is. @@ -375,7 +389,7 @@ npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign # or just answ - `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`). - `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest. -- `PLUGIN_ENTITY_FIELDS`, `PLUGIN_METHOD_RESULT`: what each ctx method returns and which fields each entity row carries (see [What a ctx call returns](#what-a-ctx-call-returns)). +- `PLUGIN_ENTITY_FIELDS`, `PLUGIN_ENTITY_NESTED`, `PLUGIN_METHOD_RESULT`: what each ctx method returns, which fields each entity row carries and which of them hold rows of another entity (see [What a ctx call returns](#what-a-ctx-call-returns)). - `validateManifest(json)` — the manifest rules the server loader uses. - `settingDefaults(manifest, scope)` — the `default`s of one settings scope, keyed by field: what the host resolves for an unset field, and what `dev` seeds `ctx.config` / `ctx.settings` with. Spread it under your own fixtures when calling `createMockHost` to mirror the host. `SETTING_FIELD_KEYS` is the attribute list the host stores. - `createMockHost(opts)` (from `trek-plugin-sdk/testing`). diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index c58507d9e..d54cb8a76 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -622,8 +622,291 @@ export const PLUGIN_ENTITY_FIELDS = { 'display_name', 'avatar', ], + dayNote: [ + 'id', + 'day_id', + 'trip_id', + 'text', + 'time', + 'icon', + 'sort_order', + 'created_at', + 'color', + ], + reservationEndpoint: [ + 'id', + 'reservation_id', + 'role', + 'sequence', + 'name', + 'code', + 'lat', + 'lng', + 'timezone', + 'local_time', + 'local_date', + 'created_at', + ], + tag: [ + 'id', + 'user_id', + 'name', + 'color', + 'created_at', + ], + todo: [ + 'id', + 'trip_id', + 'name', + 'checked', + 'category', + 'sort_order', + 'due_date', + 'description', + 'assigned_user_id', + 'priority', + 'created_at', + 'reminded_at', + ], + accommodation: [ + 'id', + 'trip_id', + 'place_id', + 'start_day_id', + 'end_day_id', + 'check_in', + 'check_in_end', + 'check_out', + 'confirmation', + 'notes', + 'created_at', + 'place_name', + 'place_address', + 'place_image', + 'place_lat', + 'place_lng', + 'reservation_title', + ], + packingBag: [ + 'id', + 'trip_id', + 'name', + 'color', + 'weight_limit_grams', + 'sort_order', + 'created_at', + 'user_id', + 'assigned_username', + 'members', + 'total_weight_grams', + ], + fileLink: [ + 'id', + 'file_id', + 'reservation_id', + 'assignment_id', + 'place_id', + 'created_at', + 'budget_item_id', + ], + collabNote: [ + 'id', + 'trip_id', + 'user_id', + 'category', + 'title', + 'content', + 'color', + 'pinned', + 'created_at', + 'updated_at', + 'website', + 'username', + 'avatar', + 'avatar_url', + 'attachments', + ], + collabPoll: [ + 'id', + 'trip_id', + 'user_id', + 'question', + 'options', + 'multiple', + 'closed', + 'deadline', + 'created_at', + 'username', + 'avatar', + 'avatar_url', + 'is_closed', + 'multiple_choice', + ], + collabMessage: [ + 'id', + 'trip_id', + 'user_id', + 'text', + 'reply_to', + 'created_at', + 'deleted', + 'username', + 'avatar', + 'reply_text', + 'reply_username', + 'user_avatar', + 'avatar_url', + 'reactions', + 'attachments', + ], + journey: [ + 'id', + 'user_id', + 'title', + 'subtitle', + 'cover_gradient', + 'status', + 'created_at', + 'updated_at', + 'cover_image', + 'show_trip_tracks', + 'show_verdict', + 'show_mood', + 'show_weather', + 'status_override', + 'photo_location', + 'entry_count', + 'photo_count', + 'place_count', + 'trip_date_min', + 'trip_date_max', + ], + journalEntry: [ + 'id', + 'journey_id', + 'source_trip_id', + 'source_place_id', + 'author_id', + 'type', + 'title', + 'story', + 'entry_date', + 'entry_time', + 'location_name', + 'location_lat', + 'location_lng', + 'mood', + 'weather', + 'tags', + 'visibility', + 'sort_order', + 'created_at', + 'updated_at', + 'pros_cons', + 'stats_excluded', + 'dismissed', + 'country_code', + 'source_assignment_id', + 'is_draft', + 'photos', + 'source_trip_name', + ], + category: [ + 'id', + 'name', + 'color', + 'icon', + 'user_id', + 'created_at', + ], + bucketItem: [ + 'id', + 'user_id', + 'name', + 'lat', + 'lng', + 'country_code', + 'notes', + 'created_at', + 'target_date', + 'visited_at', + 'visited_source', + 'region_code', + ], + collection: [ + 'id', + 'owner_id', + 'name', + 'description', + 'color', + 'icon', + 'cover_image', + 'links', + 'sort_order', + 'created_at', + 'updated_at', + 'place_count', + 'members', + 'is_owner', + 'labels', + ], + collectionListing: [ + 'collections', + 'incomingInvites', + ], + collectionDetail: [ + 'collection', + 'places', + ], + vacayPlan: [ + 'id', + 'owner_id', + 'block_weekends', + 'holidays_enabled', + 'holidays_region', + 'school_holidays_enabled', + 'company_holidays_enabled', + 'carry_over_enabled', + 'created_at', + 'weekend_days', + 'week_start', + 'holiday_calendars', + ], + vacayHolidayCalendar: [ + 'id', + 'plan_id', + 'type', + 'region', + 'label', + 'color', + 'sort_order', + ], + vacayPlanData: [ + 'plan', + 'users', + 'pendingInvites', + 'incomingInvites', + 'isOwner', + 'isFused', + ], } as const; +/** + * The fields of an entity that hold rows of another entity (one row or a list), + * and which one. Those rows carry that entity's PLUGIN_ENTITY_FIELDS and no others, + * however deep they sit, so a column TREK adds to a child table is held back the + * same way. A field not listed here holds a value TREK builds field by field. + */ +export const PLUGIN_ENTITY_NESTED: Readonly>>> = { + place: { tags: 'tag' }, + day: { assignments: 'assignment', notes_items: 'dayNote' }, + reservation: { endpoints: 'reservationEndpoint' }, + collectionListing: { collections: 'collection' }, + collectionDetail: { collection: 'collection' }, + vacayPlan: { holiday_calendars: 'vacayHolidayCalendar' }, + vacayPlanData: { plan: 'vacayPlan' }, +}; + /** * What each ctx method returns: an entity from PLUGIN_ENTITY_FIELDS (`trip`, or * `trip[]` for a list), `host` for a value the host builds itself or data the @@ -639,54 +922,54 @@ export const PLUGIN_METHOD_RESULT: Readonly> = { 'trips.getPlaces': 'place[]', 'trips.getReservations': 'reservation[]', 'trips.getDays': 'day[]', - 'trips.getAccommodations': 'readModel', + 'trips.getAccommodations': 'accommodation[]', 'trips.listMine': 'trip[]', 'reservations.listMine': 'reservation[]', 'reservations.create': 'reservation', 'reservations.update': 'reservation', 'reservations.delete': 'host', - 'accommodations.create': 'readModel', - 'accommodations.update': 'readModel', + 'accommodations.create': 'accommodation', + 'accommodations.update': 'accommodation', 'accommodations.delete': 'host', 'packing.list': 'packingItem[]', 'packing.create': 'packingItem', 'packing.update': 'packingItem', 'packing.delete': 'host', - 'packing.listBags': 'readModel', - 'packing.createBag': 'readModel', - 'packing.updateBag': 'readModel', + 'packing.listBags': 'packingBag[]', + 'packing.createBag': 'packingBag', + 'packing.updateBag': 'packingBag', 'packing.deleteBag': 'host', 'packing.setBagMembers': 'readModel', 'files.list': 'tripFile[]', 'files.getContent': 'host', 'files.create': 'tripFile', - 'files.createLink': 'readModel', + 'files.createLink': 'fileLink[]', 'files.update': 'tripFile', 'files.softDelete': 'host', - 'collab.listNotes': 'readModel', - 'collab.listPolls': 'readModel', - 'collab.listMessages': 'readModel', - 'collab.createNote': 'readModel', - 'collab.createPoll': 'readModel', - 'collab.votePoll': 'readModel', - 'collab.createMessage': 'readModel', + 'collab.listNotes': 'collabNote[]', + 'collab.listPolls': 'collabPoll[]', + 'collab.listMessages': 'collabMessage[]', + 'collab.createNote': 'collabNote', + 'collab.createPoll': 'collabPoll', + 'collab.votePoll': 'collabPoll', + 'collab.createMessage': 'collabMessage', 'trips.addMember': 'host', 'trips.removeMember': 'host', 'trips.create': 'trip', - 'journal.listMine': 'readModel', - 'journal.getEntries': 'readModel', + 'journal.listMine': 'journey[]', + 'journal.getEntries': 'journalEntry[]', 'atlas.visited': 'readModel', - 'atlas.bucketList': 'readModel', + 'atlas.bucketList': 'bucketItem[]', 'rates.get': 'host', - 'vacay.mine': 'readModel', - 'daynotes.list': 'readModel', - 'daynotes.create': 'readModel', - 'daynotes.update': 'readModel', + 'vacay.mine': 'vacayPlanData', + 'daynotes.list': 'dayNote[]', + 'daynotes.create': 'dayNote', + 'daynotes.update': 'dayNote', 'daynotes.delete': 'host', - 'collections.listMine': 'readModel', - 'collections.get': 'readModel', - 'collections.create': 'readModel', - 'collections.update': 'readModel', + 'collections.listMine': 'collectionListing', + 'collections.get': 'collectionDetail', + 'collections.create': 'collection', + 'collections.update': 'collection', 'collections.savePlace': 'readModel', 'collections.copyToTrip': 'readModel', 'collections.deletePlace': 'host', @@ -694,26 +977,26 @@ export const PLUGIN_METHOD_RESULT: Readonly> = { 'atlas.unmarkCountry': 'host', 'atlas.markRegion': 'host', 'atlas.unmarkRegion': 'host', - 'atlas.createBucketItem': 'readModel', + 'atlas.createBucketItem': 'bucketItem', 'atlas.deleteBucketItem': 'host', 'vacay.toggleEntry': 'readModel', 'vacay.toggleCompanyHoliday': 'readModel', - 'journal.createEntry': 'readModel', + 'journal.createEntry': 'journalEntry', 'journal.addEntryPhoto': 'readModel', - 'journal.updateEntry': 'readModel', + 'journal.updateEntry': 'journalEntry', 'journal.deleteEntry': 'host', - 'journal.createJourney': 'readModel', + 'journal.createJourney': 'journey', 'journal.deleteJourney': 'host', 'weather.get': 'host', - 'categories.list': 'readModel', - 'tags.list': 'readModel', - 'tags.create': 'readModel', - 'tags.update': 'readModel', + 'categories.list': 'category[]', + 'tags.list': 'tag[]', + 'tags.create': 'tag', + 'tags.update': 'tag', 'tags.delete': 'host', 'trips.members': 'user[]', - 'todos.list': 'readModel', - 'todos.create': 'readModel', - 'todos.update': 'readModel', + 'todos.list': 'todo[]', + 'todos.create': 'todo', + 'todos.update': 'todo', 'todos.delete': 'host', 'costs.getByTrip': 'budgetItem[]', 'costs.listMine': 'budgetItem[]', diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 1ca2191b5..f98a6a701 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -1007,8 +1007,9 @@ export { export { EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS, } from './generated/host-facts.js'; -// What ctx results carry: the published fields of each entity, and what each method returns. -export { PLUGIN_ENTITY_FIELDS, PLUGIN_METHOD_RESULT } from './generated/host-facts.js'; +// What ctx results carry: the published fields of each entity, the fields holding rows +// of another entity, and what each method returns. +export { PLUGIN_ENTITY_FIELDS, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_RESULT } from './generated/host-facts.js'; // The lucide icons a `capabilities.poiCategories` entry may use, and the per-plugin cap. export { POI_CATEGORY_ICONS, POI_CATEGORY_MAX } from './generated/host-facts.js'; diff --git a/plugin-sdk/test/output-contract.test.ts b/plugin-sdk/test/output-contract.test.ts index 0f16007b7..c92f2169c 100644 --- a/plugin-sdk/test/output-contract.test.ts +++ b/plugin-sdk/test/output-contract.test.ts @@ -5,7 +5,7 @@ * runtime side: what the exports hold and that they cover the method vocabulary. */ import { describe, it, expect } from 'vitest'; -import { PLUGIN_ENTITY_FIELDS, PLUGIN_METHOD_RESULT } from '../src/index.js'; +import { PLUGIN_ENTITY_FIELDS, PLUGIN_ENTITY_NESTED, PLUGIN_METHOD_RESULT } from '../src/index.js'; import { KNOWN_ADDONS } from '../src/manifest.js'; import { KNOWN_METHODS } from '../src/generated/host-facts.js'; @@ -26,6 +26,20 @@ describe('PLUGIN_ENTITY_FIELDS', () => { }); }); +describe('PLUGIN_ENTITY_NESTED', () => { + it('points the fields that hold rows at entities with a field list', () => { + const fields: Readonly> = PLUGIN_ENTITY_FIELDS; + for (const [entity, nested] of Object.entries(PLUGIN_ENTITY_NESTED)) { + for (const [field, child] of Object.entries(nested)) { + expect(fields[entity]).toContain(field); + expect(fields[child]).toBeDefined(); + } + } + expect(PLUGIN_ENTITY_NESTED.day).toEqual({ assignments: 'assignment', notes_items: 'dayNote' }); + expect(PLUGIN_ENTITY_NESTED.reservation).toEqual({ endpoints: 'reservationEndpoint' }); + }); +}); + describe('PLUGIN_METHOD_RESULT', () => { it('describes every wire method plus the three unconditional ones', () => { for (const method of KNOWN_METHODS) expect(PLUGIN_METHOD_RESULT[method]).toBeDefined(); @@ -41,6 +55,8 @@ describe('PLUGIN_METHOD_RESULT', () => { expect(PLUGIN_METHOD_RESULT['trips.getById']).toBe('trip'); expect(PLUGIN_METHOD_RESULT['trips.getPlaces']).toBe('place[]'); expect(PLUGIN_METHOD_RESULT['db.query']).toBe('host'); + expect(PLUGIN_METHOD_RESULT['tags.list']).toBe('tag[]'); + expect(PLUGIN_METHOD_RESULT['atlas.visited']).toBe('readModel'); }); }); diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 13e231800..1e6316650 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -48,7 +48,7 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`c - **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; `src/websocket.ts` is a re-export stub for old call sites. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change. - **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change. - **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator**: it bypasses that gate. Every tick first takes the job's row in `scheduler_leases` (`SchedulerLeasesRepository.acquire` in one `UnitOfWork` transaction, where a conditional UPDATE decides the race), renews it while it runs and keeps it for a short settle window afterwards, so two processes on one database never run the same tick; a job needs nothing of its own for this. Claims on individual rows (the reminder jobs' `claimReminder`) stay where they are: the lease decides who runs a tick, the claim what a tick may send. -- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity read (`trips.getById`, `costs.listMine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, so a new column on `trips`, `places`, `reservations` and the other whole-row tables fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` is generated from it). A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap and a time budget checked between rows. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together. +- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest**; it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/`: run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity result (`trips.getById`, `todos.list`, `vacay.mine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, together with every child row it carries whose field is listed in `PLUGIN_ENTITY_NESTED` (a day's `notes_items`, a reservation's `endpoints`). A new column on any whole-row table behind those entities, child tables included, fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` and `PLUGIN_ENTITY_NESTED` are generated from it); a method left as `readModel` must return a value built field by field from named columns. A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap and a time budget checked between rows. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together. - **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. Where the data lives (`data/`, `uploads/`, backups, logs, key files, the DB file) is resolved in one place, `src/app-config/data-paths.ts`; a provider that reads one injects `DataPathsService`, plain helper modules shared with code outside the container call `resolveDataPaths()` (the list is in `src/app-config/README.md`), and nothing anchors a data path on its own `__dirname` or on `process.cwd()`. - **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only holds the connection and applies pragmas. It does not build a schema, and importing it opens nothing (the first reader does). The connection's lifecycle belongs to the `DatabaseLifecycle` provider (`nest/database/database-lifecycle.service.ts`): `buildApp()` opens it through `open()` (which binds the ORM to later swaps and runs the schema bootstrap below), the shutdown closes it through `close()`, and a restore or the demo reset swaps the file only through the database backup port, `DATABASE_BACKUP` (`nest/database/database-backup.interface.ts`; the SQLite implementation is `nest/backup/sqlite-database-backup.ts`: `VACUUM INTO` snapshot, close, atomic file swap, `reopen()`). The port works on `DatabaseLifecycle.file`, the one resolved path (`TREK_DB_FILE` or `data/travel.db`), and a snapshot that fails fails the backup rather than archiving the live file. `MaintenanceRepository` is provided only by `MaintenanceModule`'s factory and injected everywhere else; no consumer constructs it. A provider that has to follow every reopen (the demo reset job saves the first demo baseline after a restore's re-bootstrap seeded the example trips) registers with `DatabaseLifecycle.onReopened()`, which runs after the reopen's schema bootstrap; a listener contains its own failures, since one that throws fails the reopen. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests**: the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. An install the retired positional runner migrated (a `schema_version` row, no `mikro_orm_migrations` rows) is baselined by `db/legacy-baseline.ts`: the migrations whose `Legacy migration step N` docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's `createTables()` did on every boot; anything it cannot map refuses the boot. Before a boot applies pending migrations to a file database that holds anything, `db/pre-migrate-snapshot.ts` writes a `VACUUM INTO` copy next to it (`pre-migrate--