@happyvertical/smrt-core 0.57.29 → 0.57.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,12 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.57.29",
6
+ "packageVersion": "0.57.30",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "d2f97cf2baf0f0fbd17b7358118ca61174712a7611ae86d134ae20f046554967",
11
- "packageJson": "00f57d89385ed5215dc4c1bc58e9400f20f6d97f5a5d4a5760ebdf5000b44a9b",
10
+ "manifest": "6ca617588ae07a1d7bde21614cc2b68bdfc1a1f58d96e192aad2e4e98544b677",
11
+ "packageJson": "7614147170aae89909a0f2481e4292395aa0b38a206485236758df96eae8b619",
12
12
  "agents": "3a78a0babd2884ac22e4b376a3c9618037ab818ad4bdd762751f82fa53c21d04",
13
13
  "moduleDoc:agents/object-runtime.md": "fd2748649e820c2fe5960fa4a44c5f3e2a5afa57da89925e8b4cb3b53d67a07b",
14
14
  "moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
@@ -18,8 +18,8 @@
18
18
  "moduleDoc:agents/schema-paths.md": "aef1b6fd687d0dbe444d20bf318e7c62fb9ef063ead933d2fd8168b87fee032e",
19
19
  "moduleDoc:agents/change-feed.md": "c3d6206758dd1d2e0475177e1938fb7d7b6cfe8a6c4d3c3b56ef62bc7bc7739d",
20
20
  "moduleDoc:agents/change-signals.md": "a2dbe7077483b5a19bd5ea9589b5a08fe847377c8e05d01d57f813c6d9cbc28b",
21
- "moduleDoc:agents/generators.md": "4630a7c5a4f7f2dd6245e1040504e22d577061e5d729a5efe3bb47554bf873c3",
22
- "moduleDoc:agents/build-knowledge.md": "40584c143ccdfd3add6236187244255a3ea01c4e9b92070e580492c969090b35",
21
+ "moduleDoc:agents/generators.md": "abf359fdc6fa7f0afaf4a096d52b75982084b80d539a4ed021d3d95b47714b14",
22
+ "moduleDoc:agents/build-knowledge.md": "53965ced42c8cc30243f62b8df5747a078b6ae551fb42b4a1dc912f3f49bac39",
23
23
  "moduleDoc:agents/memory.md": "a86bdf370403d08ecad3be86e341eb322dfe6e6b7a4e8806a6f8be9bf3421da5",
24
24
  "moduleDoc:agents/system-diagnostics.md": "b7ccae18ee312bf788d5470f95fa78d3535f1789247d5372e40ca86538290772",
25
25
  "moduleDoc:agents/registry-snapshot.md": "6234aa540427396d1dda1825a4641b1072e247187d5ddf413ae46c691344ee30",
@@ -1193,12 +1193,12 @@
1193
1193
  {
1194
1194
  "path": "agents/generators.md",
1195
1195
  "module": "generators",
1196
- "content": "# smrt-core/code generators\n\nModule semantics for `src/generators/` + `src/vite-plugin/`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nThe same web virtual module exports `webMcpToolDefinitions` (#2518), a\ncanonical per-tool array selected independently of list materialization. Every\nnon-empty canonical API action set contributes tools, so get-only and\ncustom-action-only models are discoverable; custom actions declared on a\n`SmrtCollection` merge into the owning row collection. Each definition carries\ncomplete route and invalidation metadata. `collectionDefinitions` and its\nembedded descriptor copy remain unchanged for existing cache-backed consumers.\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nRoute emission iterates manifest objects in the same deterministic\nqualified-identity order (`orderedManifestObjectEntries`), so when two models\nshare one collection the last writer of each route file — and therefore the\nCRUD method set `resolveGeneratedEndpointCrudMethods` mirrors into\n`smrt-client.d.ts` — is stable across scan orders (#2754).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Four co-managed emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), the physical `@smrt/web` d.ts (`prebuild/index.ts`), and the hand-written type mirror in `@happyvertical/smrt-web` (`packages/smrt-web/src/index.ts` — dependency-free, so textual sync only).\n\n`webMcpToolDefinitions` is deliberately outside that digest: tool-only route,\nidentifier, or annotation changes cannot alter persisted row hydration.\n\nPer-field web emission (#2046): `buildWebFieldDefinitions` carries `description` (from `@field({ description })`) and sanitized `ui` hints (from `@field({ ui: { basic, group, order, locked } })`, read off the manifest `_meta.ui` bag through per-key type guards) into each emitted field definition, and `buildWebToolDescriptors` threads the same `description` into browser MCP tool schemas. `sensitive`/`transient` fields are excluded from emission entirely, so their descriptions never ship. Both keys are conditional, so hint-less schemas emit byte-identical definitions (and hashes) as before; adding a description/ui hint changes the manifest hash — deliberate over-invalidation, harmless per the #1764 contract.\n\n## MCP `get` serializers\n\nA model may add an async serializer only to its standard generated MCP `get`\ntool. Configure a module specifier and named export under `mcp.serializers.get`:\n\n```ts\n@smrt({\n mcp: {\n serializers: {\n get: {\n importPath: '@scope/package',\n exportName: 'serializeThingMcpGet',\n },\n },\n },\n})\n```\n\nThe named export receives `(item, options: PublicJsonOptions)` and returns the\nget result (or a promise for it). Bare package specifiers resolve with ESM\nconditions from the declaring model's loaded module, so the serializer's owning\npackage is an application dependency rather than a dependency of core or a\ngeneric dispatcher. This anchor is independent of `process.cwd()`. Relative\nreferences retain ordinary dynamic-import behavior. The MCP runtime imports\nand caches the named module, then calls the serializer after normal\nauthentication, tenant-scoped lookup, and read access have admitted and loaded\nthe item. A missing or non-function export makes that `get` call fail. The hook\nreplaces only the standard `get` result; generated `list`, writes, deletes, and\ncustom actions keep their existing contracts.\n\nThe serializer owns its public projection. Start with\n`item.toPublicJSON(options)`, and project each related model with that model's\n`toPublicJSON(options)` before adding it. The final transport pass cannot make\narbitrary plain aliases permission-safe: returning a DTO does not cause fields\ninside it to be filtered by model policy. Do not use `toJSON()` or spread model\ninstances into a serializer result.\n\n## Generated MCP server output language\n\n`MCPGenerator` builds every file as TypeScript, so the requested `outputPath`\nextension decides what is written (#2279). `.ts`/`.mts` targets keep the source\nverbatim for `tsx` or Node type stripping — which is why the generated source\nmust stay erasable-syntax-only (no parameter properties, enums, or namespaces).\nEvery other target (`.smrt/mcp-server/index.js` by default) is transpiled to\nJavaScript with lazily loaded `oxc-transform` before writing, because the\nprinted run script and the generated `claude-config.example.json` both invoke\nit with plain `node`. Ordinary core imports and `.ts`/`.mts` output therefore\ndo not load OXC's native bindings.\nThis keeps `typescript` dev-only in `@happyvertical/smrt-core`; generated MCP\nsource must remain erasable-syntax-only. A `.cjs`/`.cts` target is rejected\noutright: generated servers are ES modules. `src/generators/mcp-emit.ts` owns\nthose decisions — do not reintroduce a bare `writeFile` of generated source.\n\nModular output writes `config`, `tools/index`, and `handlers/index` with the\nentry point's own extension, and emits the entry's relative import specifiers\nwith that same extension, so the files it imports both exist and load with the\nsame module semantics — an `.mjs` entry gets `.mjs` siblings, not `.js` ones a\nCommonJS package would then parse as CommonJS. The entry is written at the\nrequested path rather than a hardcoded `index.js`.\nGenerated code also has to be valid in an ES module: `arguments` is not a legal\nbinding name there, however convenient it reads.\n\n## Browser-plane playbook preflight route (#2590)\n\n`GET {basePath}/_preflight?key=<playbook key>` (`src/generators/preflight-route.ts`)\nis an advisory, read-effect, idempotent report of what a caller's playbook would\nbe allowed to do — capability *selection*, never authorization. Resolution and\nverdict shaping live in `@happyvertical/smrt-playbooks`, which depends on this\npackage, so core takes the evaluator as the `APIConfig.playbookPreflight` seam and\nthe dependency stays one-way. Without a provider the route 404s.\n\n**`authMiddleware` is never invoked by preflight**, and that is enforced\nstructurally rather than by discipline: `PlaybookPreflightRouteOptions` has no\nauth member of any kind, and `rest.ts` passes the boolean `appAuthConfigured`\ninstead — so there is no handle in the module to invoke by mistake. A synthetic-\n`Request` dry run is explicitly not an option: the middleware is request-bound,\nreturns a `Response` rather than a boolean, and may consult session stores,\nrate-limit, or audit. The app-auth layer therefore reports `unknown`, which is the\nhonest answer, and a future `authPredicate` seam can fill it in without changing\nthe contract.\n\nThe static layers preflight predicts against are exported from the same module —\n`isApiActionEnabledForObject`, `isRestActionRoutable`, `isRestRoutePublic`,\n`restFieldReadPermissions`, `restMethodForApiAction`,\n`resolveRegisteredObjectName` — and `APIGenerator`'s own\n`isApiActionEnabled` / `isRoutePublic` now delegate to them, so the route and the\nprediction of the route cannot drift. Exposure and existence are separate\nquestions: `include`/`exclude` gate a route, they do not conjure one, so\n`isRestActionRoutable` additionally requires a custom action to be declared in\n`api.routes` — the only map `dispatchCustomCollectionAction` iterates. A custom\naction is predicted against the verb its own route config declares, so a\n`public: 'read'` opt-out neither silently covers a `POST` action nor falsely\ndenies a declared `GET` one. Every unresolvable key returns the provider's single uniform\n\"unavailable\" body with an unconditional 200: unknown and unauthorized keys are\nindistinguishable at the HTTP layer too.\n\n## Emitted agent surface (#2591)\n\nGenerated model tools have always been build-time artifacts — virtual module,\nmanifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed\nonly once something mounted, so \"what can an agent do in this app\" had no answer\nshort of enumerating every route. This closes that.\n\nThe same OXC scan that builds the manifest also runs the scanner's\nagent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it\nin `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to\n`buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration\ndiscovery is NOT bound to the plugin's `include` glob — an app that scans\n`src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`\nsidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth\nholding onto:\n\n- **It never touches `manifest.json`.** The runtime manifest stays\n runtime-focused; the agent-addressable surface is an agent/developer contract,\n so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`\n only, under `agentSurface: { intents, playbooks, diagnostics }`.\n- **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a\n native parser binary and `smrt-core`'s main entry is browser-reachable, so\n core's sync knowledge builder must not import it. The Vite plugin already\n imports the scanner lazily on the Node side and is the only caller that writes\n this artifact.\n\nThe field is **omitted entirely** when a package declares nothing, which is what\nmakes it additive in practice rather than only on paper: every existing\npackage's checked-in artifact stays byte-identical.\n\nEach declaring module gets a `sourceHashes` entry under the\n`agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so\nEDITING an intent sidecar marks the artifact stale exactly like editing\n`AGENTS.md` does (`stale-domain-knowledge`).\n\nHashes alone cannot see an **added** declaration, though: a brand-new sidecar\nhas no recorded hash to mismatch, the runtime manifest never carries intents,\nand `AGENTS.md` is untouched — so every other signal stays green while the\nartifact omits a real operation. `dev:knowledge-check` therefore also re-derives\nthe declaration SET from source and compares it to the artifact by identity,\nreporting either direction as `stale-agent-surface`. The scan is bounded like\nthe numeric-precision lint: `src` only, behind the scanner's token pre-filter.\n\nThat re-derivation must model what the EMITTER sees, not merely what is on\ndisk, or it reports drift no rebuild can clear. Which files count is decided by\nthe scanner's exported `isAgentSurfaceSourcePath` — the same predicate the\nemitter itself uses, never a list copied into the checker — and the per-file\nresults run through `mergeAgentSurfaces` before comparing, because the merge is\nwhere a duplicate identity and a derived tool-name collision are resolved and\nthe artifact is the merged result.\nDiagnostics are compared alongside identities: a sidecar containing only a\ncomputed declaration adds no identity and has no prior hash, so without that,\n\"a diagnostic, never silence\" would quietly become \"a diagnostic, until the\nartifact goes stale\". The walk covers `<pkg>/src` while the emitter globs the\nwhole project root, so an emitted entry from outside `src` is not reported as\nmissing — this check did not look there, and claiming otherwise would be an\nerror nothing could clear.\n\nBoth `stale-*` codes are warnings by default and errors under `--strict`, which\nis what CI runs. Alongside them: `agent-surface-missing-identity`,\n`agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are\nerrors, and `agent-surface-not-static` and `agent-surface-tool-name-collision`\nare warnings. A cross-file duplicate arrives as a *diagnostic* rather than two\nentries — the scanner's merge already dropped the loser — so that diagnostic\nmaps to the duplicate error rather than the not-static warning; otherwise the\nerror would be unreachable for the case it exists to catch.\n\nA `tool-name-collision` diagnostic (#2725) is derived from the MANIFEST, not\nfrom the declaration: the plugin compares each emitted intent against\n`buildWebMcpToolDefinitions` for the same build, which is already filtered by\nthe exposure policy, and appends the result to the surface. So it is excluded\nfrom the drift comparison above — that pass re-derives declarations from source\nalone and has no manifest, so counting one as \"declared\" would report every\ncollision as drift no rebuild could clear. The check is advisory and drops\nnothing: `defineIntent` accepts the id, so the intent is emitted, and the\ndocument-global tool-name lock (#2613) rejects whichever registers second — the\nbuild-time notice is the earlier, cheaper form of the same answer. A build\ncannot see the provider's WebMCP `namespace` or `effects` policy, either of\nwhich separates the pair outright, so the message states that precondition\nrather than asserting the collision.\n\n`smrt doctor` prints the whole surface — model tools, intents, playbooks — from\nthese artifacts alone, with no application running.\n\n## Collection exposure\n\nA collection class is registered as a resource of its own, so every transport\ngenerates operations for it (`<name>collection_create` MCP tools,\n`<name>collection:create` CLI commands, routes for its public methods). It may\nnever publish more than its model. `registry/collection-exposure.ts` holds the\nrule; the manifest generator applies it (`mergeInheritedFields`) and\n`ObjectRegistry.getConfig()` applies it again at runtime, so a manifest built\nbefore the rule is held to it too. For each of `api`/`mcp`/`cli` the collection\nleaves unset, it inherits the model's: `false` stays `false`; an `include`\nlist keeps only its CRUD verbs (the other names are the model's instance\nmethods, so the collection's own methods stay closed); an `exclude` list is\ncopied; an open model leaves the collection open. A `sensitive: true` model's\ncollection gets read-only MCP unless it configures `mcp` itself. The CLI treats\na collection's `include` as the complete allowlist, as MCP and REST do.\n\nA collection opens its own methods by listing them in its own `@smrt()`. A\ncollection decorator's config is merged over its model's at runtime, so repeat\nthe model's CRUD verbs (and storage keys) there. The build fails with\n`CONFIG_COLLECTION_EXPOSURE` when a collection's own surface opens an MCP or\nCLI CRUD write its model closes. Gates: `registry/__tests__/collection-exposure.test.ts`,\n`packages/cli/src/__tests__/collection-exposure.test.ts`, and the cross-package\n`packages/bundle-gate/src/__tests__/collection-exposure.spec.ts`.\n\n## Explicit collection action resources\n\nA typed `SmrtCollection<Model>` may declare `@smrt({ collection: 'operations',\napi: { include: ['submit'] }, cli: { include: ['submit'] } })` to give its\ncustom actions a distinct route namespace. The explicit `collection` controls\nroutes; the collection still inherits its model's storage/schema. Without an\nexplicit namespace, the existing model collection name is inherited. When both\nthe model and its action host expose HTTP CLI commands in that inherited\nnamespace, discovery combines disjoint commands under the model resource shell,\nwhile evaluating each command with its original class policy. Explicit duplicate\nnamespaces and overlapping routes/commands remain configuration errors.\n\nHTTP CLI discovery includes only named custom methods in the collection's\n`cli.include` that its API exposes. Boolean/default CLI config, wildcard-only\nincludes and `mirror: 'api'` alone do not opt a collection into discovery;\ncollections never advertise CRUD. Both static and instance collection methods\nuse collection-scoped URLs without an item id. Existing unsupported-signature\nwarnings and the handler's per-command policy still apply.\n\nThe collection must declare or inherit its real `_itemClass` and be registered\nas that model's collection constructor. A facade may extend an undecorated\ndomain collection to retain its instance behavior; include that collection\nancestry in the consumer manifest. Generated startup retains its existing\nside-effect imports and registers discovery metadata and exact live constructor\nbindings only for named API/HTTP\nCLI action hosts and their collection ancestors. Ordinary collections stay\nside-effect-only. Runtime schema ownership also recognizes real\n`SmrtCollection` prototype ancestry when a domain parent is not registered;\nclass names and `_itemClass` alone do not exempt a model from table ownership. Generated routes resolve the model's registry\nidentity and invoke the registered collection, not an arbitrary model instance.\nA distinct route namespace does not change authorization: collection-hosted\nwrites retain the model collection's permission slug (#2977). Discovery is\ncapability filtering, not row authorization; the route and domain action remain\nauthoritative for authentication, permissions and tenant ownership.\n\n## Subclass exposure\n\nA model that extends another registered model (`Person extends Profile`, an\nSTI contract subtype, a CTI subclass) is generated as a resource of its own,\nbut every operation it inherits is its parent's. `registry/subclass-exposure.ts`\nholds the rule; the manifest generator applies it (`mergeInheritedFields`,\nbefore the collection pass, so a subclass's collection sees it) and\n`ObjectRegistry.getConfig()` applies it again at runtime. For each of\n`api`/`mcp`/`cli` the subclass leaves unset it inherits the parent's effective\nvalue as is, and `sensitive: true` is inherited and cannot be dropped. An\nabstract parent's synthesized write exclusions are not inherited: its authored\nsurface is, so its concrete subclasses can lift them.\n\nA subclass's own `@smrt()` may narrow its parent's surface. The build fails\nwith `CONFIG_SUBCLASS_EXPOSURE` when it opens a CRUD verb or a parent method\n(on REST, MCP or CLI, read the way each generator reads `include`/`exclude`)\nthat the parent closes, unless the subclass lists that operation in\n`widenParentExposure: { api: ['create'] }`; a listed operation that widens\nnothing also fails. Methods only the subclass declares are not checked. Gates:\n`registry/__tests__/subclass-exposure.test.ts`,\n`packages/cli/src/__tests__/subclass-exposure.test.ts`, and the cross-package\n`packages/bundle-gate/src/__tests__/subclass-exposure.spec.ts`.\n\n## Custom-action contract\n\n`resolveCustomActionMetadata()` is the common discovery and invocation contract\nfor generated REST routes and API clients, MCP, CLI, WebMCP, and simple\nREST-resource discovery. Receiver scope comes from the executable method, never a\nconfiguration-only `api.routes[name].scope` override: instance model methods\nare item-scoped and require `id`; static model methods and recognized\n`SmrtCollection` methods are collection-scoped and do not accept `id`. Route\nconfiguration may still choose its path and HTTP verb, but it cannot turn an\ninstance call into `ClassRef.action` or vice versa.\n\nWhen scanner method metadata exists, discovery projects each named parameter\nand its JSON-schema type, and invokers pass the values positionally in declared\norder. The legacy single `options` bag remains compatible when metadata is\nabsent (or the declared method takes `options`). Do not infer this from runtime\nfunction arity. An omitted typed `options` parameter remains `undefined`, so a\nmethod's JavaScript default initializer continues to apply; an explicit `null`\nremains `null`. Flat tool and CLI inputs reserve `id` for receiver parsing. If\nan action declares an `id` parameter, its flat MCP/WebMCP field is `actionId`\n(and CLI uses `--action-id`); REST keeps its independent path/body\nnamespaces. Typed CLI actions may use standard flag names such as `limit`,\n`offset`, `where`, and `format` without those values being stripped as CRUD\nflags.\n\nMCP static dispatch resolves the exact qualified registry entry's model\nconstructor first, then its registered `collectionConstructor`. Manifest-backed\ncollection resources can retain a stub model constructor after generated\n`registerCollection()` binds the live class; invoke the static method with the\nselected constructor as `this`. Receiver resolution does not add tool exposure\nor permit an item-scoped method to run without its ID.\n\nCustom actions may return an explicit, domain-neutral failure object with\n`ok: false`, `code`, and `message` plus optional `status`, `details`,\n`retryable`, and `correlationId`. `normalizeCustomActionFailure()` redacts it;\ngenerated REST returns `{ error: failure }` with the non-2xx status, while MCP\nreturns `isError: true` and `_meta['io.happyvertical/smrt']`. Opaque successful\nobjects (including `{ code, message }`) remain untouched; thrown exceptions are\nnot reclassified as domain failures.\n\nCustom route metadata also classifies browser-tool effects. Set `effect` to\n`read`, `write`, or `destructive`, with truthful `idempotent` and `openWorld`\nflags. CRUD classification is fixed: list/get are read, create/update are write,\nand delete is destructive. An undeclared custom action deliberately defaults to\ndestructive, non-idempotent, and open-world so a browser capability policy never\nfails open.\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n\n## Generated dev-plane route (#2782)\n\n`src/vite-plugin/dev-plane-route.ts` emits `<routesDir>/_dev/[...tool]/+server.ts`\nonly when `svelteKit.devPlaneRoute.enabled === true` and\n`@happyvertical/smrt-dev-mcp/dev-plane` resolves from the consumer (same\nresolvability guard as `_resources`). The route is dev-only (`$app/environment`\n`dev`, else 404), refuses to start without `SMRT_DEV_MCP_TOKEN` (503), and\ndelegates everything else to `createDevPlane`, passing `getSmrtConfig(...).db`\nfrom the embedded access prelude (below) so the plane shares the app's\nconnection.\nCore never imports smrt-dev-mcp; only the generated file does, at consumer\nruntime. The route carries the auto-generated header so the sweep owns it.\n\nGenerated SvelteKit write routes (#2977) call `requireRoutePermission(locals, action)` after `requireRouteAuth`: CRUD writes need `<collection>.create|update|delete` and mutating custom actions `<collection>.<method>` in the session permission snapshot on `locals`, else 403 (fail-closed; `api.public: true` stays open, `tenantContext.superAdminBypass` skips). See `packages/users/agents/permissions.md`.\n\n## MCP protocol identifiers (#3219)\n\nMCP discovery preserves existing names matching `[A-Za-z0-9_.-]{1,64}`.\nPreviously invalid names use their first 31 sanitized ASCII characters, `_`,\nand the first 32 hex characters of SHA-256 over the JSON tuple\n`[original class name, lowercased action name, original raw tool name]`.\nThis makes long or punctuation-bearing identifiers deterministic without\ncollapsing common prefixes. Discovery reserves compliant names first and fails\nclosed on any duplicate raw name or final alias collision instead of advertising\nambiguous dispatch (including class names differing only by case across packages).\nConsumers must use the advertised name rather than constructing one.\n\nThe generator retains a separate class/action binding for each descriptor and\nemits that mapping for generated runtimes. Dispatch, task eligibility, tenant\nscoping, ordering, and STI metadata use this binding; protocol aliases are not\nexecution identities. The advertised catalog still gates execution. The direct\nruntime-template API accepts `toolTargets`; omitting it retains the legacy\n`object_action` convention for existing direct callers. Supply explicit targets\nfor canonicalized names or class names containing underscores. This contract\ndoes not change CLI, REST, or WebMCP identifiers.\n\n`MCPGenerator.getToolIdentity(tool)` exposes the generator-validated original\nclass, action, and raw generated name to in-process catalog composers. App MCP\nuses it for class allow-lists, tenant checks, and compatible workflow guards;\nthose consumers must not reverse protocol aliases. The alias remains the sole\nwire tool name and is still the key for public patterns and generic tool policy.\n\n`MCPConfig.classNames` scopes generation (simple names, case-insensitive, or\nqualified `@scope/pkg:Class`); omitted, every registered class is a candidate.\nA simple name matching more than one registered class throws (scope it by\nqualified name). Each tool keeps its class's registry key, and configuration,\nfields, methods, dispatch, tasks and collections resolve through that key —\nnever through the first same-named class. Generated servers (single-file and\nmodular) carry it as `registryKey` in their tool targets and task actions and\nuse it for every emitted collection, policy, class and STI-base lookup;\n`objectName` stays the wire/display name.\nA host with an allow-list passes it so the duplicate-name check runs over the\npublished set only: an unrelated collision among classes it never lists must\nnot fail its catalog or calls (#3490). App MCP always scopes its generators.\n\n## Generated route collection access (#3416)\n\nThere is no generated route-access module. Every generated SvelteKit route\nthat needs collections (CRUD, custom actions, `_changes`, `_events`,\n`sync/apply`, `_dev`) embeds the prelude from\n`generateCollectionAccessImports()` (`sveltekit-config-import.ts`): it imports\nthe generated `smrt-register` module, then the app's config module\n(`$lib/server/smrt` by default) as `* as smrtApplication`, and destructures\n`getCollection`/`getSmrtConfig` from `createGeneratedCollectionAccess()`\n(`src/generated-collection-access.ts`). Generation fails before writing\nanything when `configFileName` resolves to the registration module\n(`smrt-register` with any script extension or case).\nThat helper resolves per call, never caching: a legacy `getCollection` /\n`getSmrtConfig` export wins (deprecated, warned once per module, for one\nrelease), else the exported app-runtime `runtime.getCollection()` /\n`classOptions()`, else it fails with an error naming the module. `_resources`\nand static collection-scoped actions only need registration, so they import\n`smrt-register` and then the config module. When the config module is missing\nand `@happyvertical/smrt-app-runtime/sveltekit` resolves from the consumer, the\ngenerator writes a runtime-shaped default instead of the legacy\n`getCollection` file.\n\n## Enum-constrained fields (#3598)\n\nThe scanner emits `enum: [...]` on a manifest field typed as a TS enum or\nstring/number literal union. Registry-sourced fields mirror it into\n`_meta.enum` (`createFieldFromManifest` / `mergeManifestField`); read it with\n`readFieldEnum()` (top level first, then `_meta`), never directly. Generated\ntool schemas (`fieldTypeToJsonSchema`, so MCP and WebMCP descriptors), OpenAPI\n(`swagger.ts`) and the legacy `generateSchemaProperties` emit JSON Schema\n`enum` for `text`/`integer`/`decimal` fields only; a nullable field appends\n`null` to the list so `type: [.., 'null']` still validates. The client web\ncollection `fields` map does not carry `enum` yet (it feeds the shape digest and\nthree hand-mirrored d.ts files); tool inputs read it from the manifest field.\n\n## Custom-action permission aliases (#3878)\n\n`@smrt({ operationPermissions: { run: { action: 'read' } } })` lets a custom\nmethod reuse an existing CRUD grant in its own collection. Generated SvelteKit\nPOST routes and smrt-users operation guards resolve the same mapping; the\ncatalog emits the target grant rather than a second method-named permission.\nCRUD itself cannot be aliased. Malformed declarations fail closed. Optional\n`databaseActions: ['SELECT', 'UPDATE']` adds explicit PostgreSQL policy bindings\nfor the aliased grant (for example a status-only archive guarded by `create`\nthat must load its item before updating it). This is server-authored authority\nmetadata: grant only the actions the method needs, review it with the method's\nmodel-level validation, and regenerate RLS policies when adopting a changed\nbinding.\n\nCustom actions retain the `{ action, result }` response envelope.\n"
1196
+ "content": "# smrt-core/code generators\n\nModule semantics for `src/generators/` + `src/vite-plugin/`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nThe same web virtual module exports `webMcpToolDefinitions` (#2518), a\ncanonical per-tool array selected independently of list materialization. Every\nnon-empty canonical API action set contributes tools, so get-only and\ncustom-action-only models are discoverable; custom actions declared on a\n`SmrtCollection` merge into the owning row collection. Each definition carries\ncomplete route and invalidation metadata. `collectionDefinitions` and its\nembedded descriptor copy remain unchanged for existing cache-backed consumers.\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nRoute emission iterates manifest objects in the same deterministic\nqualified-identity order (`orderedManifestObjectEntries`), so when two models\nshare one collection the last writer of each route file — and therefore the\nCRUD method set `resolveGeneratedEndpointCrudMethods` mirrors into\n`smrt-client.d.ts` — is stable across scan orders (#2754).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Four co-managed emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), the physical `@smrt/web` d.ts (`prebuild/index.ts`), and the hand-written type mirror in `@happyvertical/smrt-web` (`packages/smrt-web/src/index.ts` — dependency-free, so textual sync only).\n\n`webMcpToolDefinitions` is deliberately outside that digest: tool-only route,\nidentifier, or annotation changes cannot alter persisted row hydration.\n\nPer-field web emission (#2046): `buildWebFieldDefinitions` carries `description` (from `@field({ description })`) and sanitized `ui` hints (from `@field({ ui: { basic, group, order, locked } })`, read off the manifest `_meta.ui` bag through per-key type guards) into each emitted field definition, and `buildWebToolDescriptors` threads the same `description` into browser MCP tool schemas. `sensitive`/`transient` fields are excluded from emission entirely, so their descriptions never ship. Both keys are conditional, so hint-less schemas emit byte-identical definitions (and hashes) as before; adding a description/ui hint changes the manifest hash — deliberate over-invalidation, harmless per the #1764 contract.\n\n## MCP `get` serializers\n\nA model may add an async serializer only to its standard generated MCP `get`\ntool. Configure a module specifier and named export under `mcp.serializers.get`:\n\n```ts\n@smrt({\n mcp: {\n serializers: {\n get: {\n importPath: '@scope/package',\n exportName: 'serializeThingMcpGet',\n },\n },\n },\n})\n```\n\nThe named export receives `(item, options: PublicJsonOptions)` and returns the\nget result (or a promise for it). Bare package specifiers resolve with ESM\nconditions from the declaring model's loaded module, so the serializer's owning\npackage is an application dependency rather than a dependency of core or a\ngeneric dispatcher. This anchor is independent of `process.cwd()`. Relative\nreferences retain ordinary dynamic-import behavior. The MCP runtime imports\nand caches the named module, then calls the serializer after normal\nauthentication, tenant-scoped lookup, and read access have admitted and loaded\nthe item. A missing or non-function export makes that `get` call fail. The hook\nreplaces only the standard `get` result; generated `list`, writes, deletes, and\ncustom actions keep their existing contracts.\n\nThe serializer owns its public projection. Start with\n`item.toPublicJSON(options)`, and project each related model with that model's\n`toPublicJSON(options)` before adding it. The final transport pass cannot make\narbitrary plain aliases permission-safe: returning a DTO does not cause fields\ninside it to be filtered by model policy. Do not use `toJSON()` or spread model\ninstances into a serializer result.\n\n## Generated MCP server output language\n\n`MCPGenerator` builds every file as TypeScript, so the requested `outputPath`\nextension decides what is written (#2279). `.ts`/`.mts` targets keep the source\nverbatim for `tsx` or Node type stripping — which is why the generated source\nmust stay erasable-syntax-only (no parameter properties, enums, or namespaces).\nEvery other target (`.smrt/mcp-server/index.js` by default) is transpiled to\nJavaScript with lazily loaded `oxc-transform` before writing, because the\nprinted run script and the generated `claude-config.example.json` both invoke\nit with plain `node`. Ordinary core imports and `.ts`/`.mts` output therefore\ndo not load OXC's native bindings.\nThis keeps `typescript` dev-only in `@happyvertical/smrt-core`; generated MCP\nsource must remain erasable-syntax-only. A `.cjs`/`.cts` target is rejected\noutright: generated servers are ES modules. `src/generators/mcp-emit.ts` owns\nthose decisions — do not reintroduce a bare `writeFile` of generated source.\n\nModular output writes `config`, `tools/index`, and `handlers/index` with the\nentry point's own extension, and emits the entry's relative import specifiers\nwith that same extension, so the files it imports both exist and load with the\nsame module semantics — an `.mjs` entry gets `.mjs` siblings, not `.js` ones a\nCommonJS package would then parse as CommonJS. The entry is written at the\nrequested path rather than a hardcoded `index.js`.\nGenerated code also has to be valid in an ES module: `arguments` is not a legal\nbinding name there, however convenient it reads.\n\n## Browser-plane playbook preflight route (#2590)\n\n`GET {basePath}/_preflight?key=<playbook key>` (`src/generators/preflight-route.ts`)\nis an advisory, read-effect, idempotent report of what a caller's playbook would\nbe allowed to do — capability *selection*, never authorization. Resolution and\nverdict shaping live in `@happyvertical/smrt-playbooks`, which depends on this\npackage, so core takes the evaluator as the `APIConfig.playbookPreflight` seam and\nthe dependency stays one-way. Without a provider the route 404s.\n\n**`authMiddleware` is never invoked by preflight**, and that is enforced\nstructurally rather than by discipline: `PlaybookPreflightRouteOptions` has no\nauth member of any kind, and `rest.ts` passes the boolean `appAuthConfigured`\ninstead — so there is no handle in the module to invoke by mistake. A synthetic-\n`Request` dry run is explicitly not an option: the middleware is request-bound,\nreturns a `Response` rather than a boolean, and may consult session stores,\nrate-limit, or audit. The app-auth layer therefore reports `unknown`, which is the\nhonest answer, and a future `authPredicate` seam can fill it in without changing\nthe contract.\n\nThe static layers preflight predicts against are exported from the same module —\n`isApiActionEnabledForObject`, `isRestActionRoutable`, `isRestRoutePublic`,\n`restFieldReadPermissions`, `restMethodForApiAction`,\n`resolveRegisteredObjectName` — and `APIGenerator`'s own\n`isApiActionEnabled` / `isRoutePublic` now delegate to them, so the route and the\nprediction of the route cannot drift. Exposure and existence are separate\nquestions: `include`/`exclude` gate a route, they do not conjure one, so\n`isRestActionRoutable` additionally requires a custom action to be declared in\n`api.routes` — the only map `dispatchCustomCollectionAction` iterates. A custom\naction is predicted against the verb its own route config declares, so a\n`public: 'read'` opt-out neither silently covers a `POST` action nor falsely\ndenies a declared `GET` one. Every unresolvable key returns the provider's single uniform\n\"unavailable\" body with an unconditional 200: unknown and unauthorized keys are\nindistinguishable at the HTTP layer too.\n\n## Emitted agent surface (#2591)\n\nGenerated model tools have always been build-time artifacts — virtual module,\nmanifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed\nonly once something mounted, so \"what can an agent do in this app\" had no answer\nshort of enumerating every route. This closes that.\n\nThe same OXC scan that builds the manifest also runs the scanner's\nagent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it\nin `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to\n`buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration\ndiscovery is NOT bound to the plugin's `include` glob — an app that scans\n`src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`\nsidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth\nholding onto:\n\n- **It never touches `manifest.json`.** The runtime manifest stays\n runtime-focused; the agent-addressable surface is an agent/developer contract,\n so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`\n only, under `agentSurface: { intents, playbooks, diagnostics }`.\n- **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a\n native parser binary and `smrt-core`'s main entry is browser-reachable, so\n core's sync knowledge builder must not import it. The Vite plugin already\n imports the scanner lazily on the Node side and is the only caller that writes\n this artifact.\n\nThe field is **omitted entirely** when a package declares nothing, which is what\nmakes it additive in practice rather than only on paper: every existing\npackage's checked-in artifact stays byte-identical.\n\nEach declaring module gets a `sourceHashes` entry under the\n`agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so\nEDITING an intent sidecar marks the artifact stale exactly like editing\n`AGENTS.md` does (`stale-domain-knowledge`).\n\nHashes alone cannot see an **added** declaration, though: a brand-new sidecar\nhas no recorded hash to mismatch, the runtime manifest never carries intents,\nand `AGENTS.md` is untouched — so every other signal stays green while the\nartifact omits a real operation. `dev:knowledge-check` therefore also re-derives\nthe declaration SET from source and compares it to the artifact by identity,\nreporting either direction as `stale-agent-surface`. The scan is bounded like\nthe numeric-precision lint: `src` only, behind the scanner's token pre-filter.\n\nThat re-derivation must model what the EMITTER sees, not merely what is on\ndisk, or it reports drift no rebuild can clear. Which files count is decided by\nthe scanner's exported `isAgentSurfaceSourcePath` — the same predicate the\nemitter itself uses, never a list copied into the checker — and the per-file\nresults run through `mergeAgentSurfaces` before comparing, because the merge is\nwhere a duplicate identity and a derived tool-name collision are resolved and\nthe artifact is the merged result.\nDiagnostics are compared alongside identities: a sidecar containing only a\ncomputed declaration adds no identity and has no prior hash, so without that,\n\"a diagnostic, never silence\" would quietly become \"a diagnostic, until the\nartifact goes stale\". The walk covers `<pkg>/src` while the emitter globs the\nwhole project root, so an emitted entry from outside `src` is not reported as\nmissing — this check did not look there, and claiming otherwise would be an\nerror nothing could clear.\n\nBoth `stale-*` codes are warnings by default and errors under `--strict`, which\nis what CI runs. Alongside them: `agent-surface-missing-identity`,\n`agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are\nerrors, and `agent-surface-not-static` and `agent-surface-tool-name-collision`\nare warnings. A cross-file duplicate arrives as a *diagnostic* rather than two\nentries — the scanner's merge already dropped the loser — so that diagnostic\nmaps to the duplicate error rather than the not-static warning; otherwise the\nerror would be unreachable for the case it exists to catch.\n\nA `tool-name-collision` diagnostic (#2725) is derived from the MANIFEST, not\nfrom the declaration: the plugin compares each emitted intent against\n`buildWebMcpToolDefinitions` for the same build, which is already filtered by\nthe exposure policy, and appends the result to the surface. So it is excluded\nfrom the drift comparison above — that pass re-derives declarations from source\nalone and has no manifest, so counting one as \"declared\" would report every\ncollision as drift no rebuild could clear. The check is advisory and drops\nnothing: `defineIntent` accepts the id, so the intent is emitted, and the\ndocument-global tool-name lock (#2613) rejects whichever registers second — the\nbuild-time notice is the earlier, cheaper form of the same answer. A build\ncannot see the provider's WebMCP `namespace` or `effects` policy, either of\nwhich separates the pair outright, so the message states that precondition\nrather than asserting the collision.\n\n`smrt doctor` prints the whole surface — model tools, intents, playbooks — from\nthese artifacts alone, with no application running.\n\n## Collection exposure\n\nA collection class is registered as a resource of its own, so every transport\ngenerates operations for it (`<name>collection_create` MCP tools,\n`<name>collection:create` CLI commands, routes for its public methods). It may\nnever publish more than its model. `registry/collection-exposure.ts` holds the\nrule; the manifest generator applies it (`mergeInheritedFields`) and\n`ObjectRegistry.getConfig()` applies it again at runtime, so a manifest built\nbefore the rule is held to it too. For each of `api`/`mcp`/`cli` the collection\nleaves unset, it inherits the model's: `false` stays `false`; an `include`\nlist keeps only its CRUD verbs (the other names are the model's instance\nmethods, so the collection's own methods stay closed); an `exclude` list is\ncopied; an open model leaves the collection open. A `sensitive: true` model's\ncollection gets read-only MCP unless it configures `mcp` itself. The CLI treats\na collection's `include` as the complete allowlist, as MCP and REST do.\n\nA collection opens its own methods by listing them in its own `@smrt()`. A\ncollection decorator's config is merged over its model's at runtime, so repeat\nthe model's CRUD verbs (and storage keys) there. The build fails with\n`CONFIG_COLLECTION_EXPOSURE` when a collection's own surface opens an MCP or\nCLI CRUD write its model closes. Gates: `registry/__tests__/collection-exposure.test.ts`,\n`packages/cli/src/__tests__/collection-exposure.test.ts`, and the cross-package\n`packages/bundle-gate/src/__tests__/collection-exposure.spec.ts`.\n\n## Explicit collection action resources\n\nA typed `SmrtCollection<Model>` may declare `@smrt({ collection: 'operations',\napi: { include: ['submit'] }, cli: { include: ['submit'] } })` to give its\ncustom actions a distinct route namespace. The explicit `collection` controls\nroutes; the collection still inherits its model's storage/schema. Without an\nexplicit namespace, the existing model collection name is inherited. When both\nthe model and its action host expose HTTP CLI commands in that inherited\nnamespace, discovery combines disjoint commands under the model resource shell,\nwhile evaluating each command with its original class policy. Explicit duplicate\nnamespaces and overlapping routes/commands remain configuration errors.\n\nHTTP CLI discovery includes only named custom methods in the collection's\n`cli.include` that its API exposes. Boolean/default CLI config, wildcard-only\nincludes and `mirror: 'api'` alone do not opt a collection into discovery;\ncollections never advertise CRUD. Both static and instance collection methods\nuse collection-scoped URLs without an item id. Existing unsupported-signature\nwarnings and the handler's per-command policy still apply.\n\nThe collection must declare or inherit its real `_itemClass` and be registered\nas that model's collection constructor. A facade may extend an undecorated\ndomain collection to retain its instance behavior; include that collection\nancestry in the consumer manifest. Generated startup retains its existing\nside-effect imports and registers discovery metadata and exact live constructor\nbindings only for named API/HTTP\nCLI action hosts and their collection ancestors. Ordinary collections stay\nside-effect-only. Runtime schema ownership also recognizes real\n`SmrtCollection` prototype ancestry when a domain parent is not registered;\nclass names and `_itemClass` alone do not exempt a model from table ownership. Generated routes resolve the model's registry\nidentity and invoke the registered collection, not an arbitrary model instance.\nA distinct route namespace does not change authorization: collection-hosted\nwrites retain the model collection's permission slug (#2977). Discovery is\ncapability filtering, not row authorization; the route and domain action remain\nauthoritative for authentication, permissions and tenant ownership.\n\n## Subclass exposure\n\nA model that extends another registered model (`Person extends Profile`, an\nSTI contract subtype, a CTI subclass) is generated as a resource of its own,\nbut every operation it inherits is its parent's. `registry/subclass-exposure.ts`\nholds the rule; the manifest generator applies it (`mergeInheritedFields`,\nbefore the collection pass, so a subclass's collection sees it) and\n`ObjectRegistry.getConfig()` applies it again at runtime. For each of\n`api`/`mcp`/`cli` the subclass leaves unset it inherits the parent's effective\nvalue as is, and `sensitive: true` is inherited and cannot be dropped. An\nabstract parent's synthesized write exclusions are not inherited: its authored\nsurface is, so its concrete subclasses can lift them.\n\nA subclass's own `@smrt()` may narrow its parent's surface. The build fails\nwith `CONFIG_SUBCLASS_EXPOSURE` when it opens a CRUD verb or a parent method\n(on REST, MCP or CLI, read the way each generator reads `include`/`exclude`)\nthat the parent closes, unless the subclass lists that operation in\n`widenParentExposure: { api: ['create'] }`; a listed operation that widens\nnothing also fails. Methods only the subclass declares are not checked. Gates:\n`registry/__tests__/subclass-exposure.test.ts`,\n`packages/cli/src/__tests__/subclass-exposure.test.ts`, and the cross-package\n`packages/bundle-gate/src/__tests__/subclass-exposure.spec.ts`.\n\n## Custom-action contract\n\n`resolveCustomActionMetadata()` is the common discovery and invocation contract\nfor generated REST routes and API clients, MCP, CLI, WebMCP, and simple\nREST-resource discovery. Receiver scope comes from the executable method, never a\nconfiguration-only `api.routes[name].scope` override: instance model methods\nare item-scoped and require `id`; static model methods and recognized\n`SmrtCollection` methods are collection-scoped and do not accept `id`. Route\nconfiguration may still choose its path and HTTP verb, but it cannot turn an\ninstance call into `ClassRef.action` or vice versa.\n\nWhen scanner method metadata exists, discovery projects each named parameter\nand its JSON-schema type, and invokers pass the values positionally in declared\norder. The legacy single `options` bag remains compatible when metadata is\nabsent (or the declared method takes `options`). Do not infer this from runtime\nfunction arity. An omitted typed `options` parameter remains `undefined`, so a\nmethod's JavaScript default initializer continues to apply; an explicit `null`\nremains `null`. Flat tool and CLI inputs reserve `id` for receiver parsing. If\nan action declares an `id` parameter, its flat MCP/WebMCP field is `actionId`\n(and CLI uses `--action-id`); REST keeps its independent path/body\nnamespaces. Typed CLI actions may use standard flag names such as `limit`,\n`offset`, `where`, and `format` without those values being stripped as CRUD\nflags.\n\nMCP static dispatch resolves the exact qualified registry entry's model\nconstructor first, then its registered `collectionConstructor`. Manifest-backed\ncollection resources can retain a stub model constructor after generated\n`registerCollection()` binds the live class; invoke the static method with the\nselected constructor as `this`. Receiver resolution does not add tool exposure\nor permit an item-scoped method to run without its ID.\n\nCustom actions may return an explicit, domain-neutral failure object with\n`ok: false`, `code`, and `message` plus optional `status`, `details`,\n`retryable`, and `correlationId`. `normalizeCustomActionFailure()` redacts it;\ngenerated REST returns `{ error: failure }` with the non-2xx status, while MCP\nreturns `isError: true` and `_meta['io.happyvertical/smrt']`. Opaque successful\nobjects (including `{ code, message }`) remain untouched; thrown exceptions are\nnot reclassified as domain failures.\n\nCustom route metadata also classifies browser-tool effects. Set `effect` to\n`read`, `write`, or `destructive`, with truthful `idempotent` and `openWorld`\nflags. CRUD classification is fixed: list/get are read, create/update are write,\nand delete is destructive. An undeclared custom action deliberately defaults to\ndestructive, non-idempotent, and open-world so a browser capability policy never\nfails open.\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n\n## Generated dev-plane route (#2782)\n\n`src/vite-plugin/dev-plane-route.ts` emits `<routesDir>/_dev/[...tool]/+server.ts`\nonly when `svelteKit.devPlaneRoute.enabled === true` and\n`@happyvertical/smrt-dev-mcp/dev-plane` resolves from the consumer (same\nresolvability guard as `_resources`). The route is dev-only (`$app/environment`\n`dev`, else 404), refuses to start without `SMRT_DEV_MCP_TOKEN` (503), and\ndelegates everything else to `createDevPlane`, passing `getSmrtConfig(...).db`\nfrom the embedded access prelude (below) so the plane shares the app's\nconnection.\nCore never imports smrt-dev-mcp; only the generated file does, at consumer\nruntime. The route carries the auto-generated header so the sweep owns it.\n\nGenerated SvelteKit write routes (#2977) call `requireRoutePermission(locals, action)` after `requireRouteAuth`: CRUD writes need `<collection>.create|update|delete` and mutating custom actions `<collection>.<method>` in the session permission snapshot on `locals`, else 403 (fail-closed; `api.public: true` stays open, `tenantContext.superAdminBypass` skips). See `packages/users/agents/permissions.md`.\n\n## MCP protocol identifiers (#3219)\n\nMCP discovery preserves existing names matching `[A-Za-z0-9_.-]{1,64}`.\nPreviously invalid names use their first 31 sanitized ASCII characters, `_`,\nand the first 32 hex characters of SHA-256 over the JSON tuple\n`[original class name, lowercased action name, original raw tool name]`.\nThis makes long or punctuation-bearing identifiers deterministic without\ncollapsing common prefixes. Discovery reserves compliant names first and fails\nclosed on any duplicate raw name or final alias collision instead of advertising\nambiguous dispatch (including class names differing only by case across packages).\nConsumers must use the advertised name rather than constructing one.\n\nThe generator retains a separate class/action binding for each descriptor and\nemits that mapping for generated runtimes. Dispatch, task eligibility, tenant\nscoping, ordering, and STI metadata use this binding; protocol aliases are not\nexecution identities. The advertised catalog still gates execution. The direct\nruntime-template API accepts `toolTargets`; omitting it retains the legacy\n`object_action` convention for existing direct callers. Supply explicit targets\nfor canonicalized names or class names containing underscores. This contract\ndoes not change CLI, REST, or WebMCP identifiers.\n\n`MCPGenerator.getToolIdentity(tool)` exposes the generator-validated original\nclass, action, and raw generated name to in-process catalog composers. App MCP\nuses it for class allow-lists, tenant checks, and compatible workflow guards;\nthose consumers must not reverse protocol aliases. The alias remains the sole\nwire tool name and is still the key for public patterns and generic tool policy.\n\n`MCPConfig.classNames` scopes generation (simple names, case-insensitive, or\nqualified `@scope/pkg:Class`); omitted, every registered class is a candidate.\nA simple name matching more than one registered class throws (scope it by\nqualified name). Each tool keeps its class's registry key, and configuration,\nfields, methods, dispatch, tasks and collections resolve through that key —\nnever through the first same-named class. Generated servers (single-file and\nmodular) carry it as `registryKey` in their tool targets and task actions and\nuse it for every emitted collection, policy, class and STI-base lookup;\n`objectName` stays the wire/display name.\nA host with an allow-list passes it so the duplicate-name check runs over the\npublished set only: an unrelated collision among classes it never lists must\nnot fail its catalog or calls (#3490). App MCP always scopes its generators.\n\n## Generated route collection access (#3416)\n\nThere is no generated route-access module. Every generated SvelteKit route\nthat needs collections (CRUD, custom actions, `_changes`, `_events`,\n`sync/apply`, `_dev`) embeds the prelude from\n`generateCollectionAccessImports()` (`sveltekit-config-import.ts`): it imports\nthe generated `smrt-register` module, then the app's config module\n(`$lib/server/smrt` by default) as `* as smrtApplication`, and destructures\n`getCollection`/`getSmrtConfig` from `createGeneratedCollectionAccess()`\n(`src/generated-collection-access.ts`). Generation fails before writing\nanything when `configFileName` resolves to the registration module\n(`smrt-register` with any script extension or case).\nThat helper resolves per call, never caching: a legacy `getCollection` /\n`getSmrtConfig` export wins (deprecated, warned once per module, for one\nrelease), else the exported app-runtime `runtime.getCollection()` /\n`classOptions()`, else it fails with an error naming the module. `_resources`\nand static collection-scoped actions only need registration, so they import\n`smrt-register` and then the config module. When the config module is missing\nand `@happyvertical/smrt-app-runtime/sveltekit` resolves from the consumer, the\ngenerator writes a runtime-shaped default instead of the legacy\n`getCollection` file.\n\n## Enum-constrained fields (#3598)\n\nThe scanner emits `enum: [...]` on a manifest field typed as a TS enum or\nstring/number literal union. Registry-sourced fields mirror it into\n`_meta.enum` (`createFieldFromManifest` / `mergeManifestField`); read it with\n`readFieldEnum()` (top level first, then `_meta`), never directly. Generated\ntool schemas (`fieldTypeToJsonSchema`, so MCP and WebMCP descriptors), OpenAPI\n(`swagger.ts`) and the legacy `generateSchemaProperties` emit JSON Schema\n`enum` for `text`/`integer`/`decimal` fields only; a nullable field appends\n`null` to the list so `type: [.., 'null']` still validates. The client web\ncollection `fields` map does not carry `enum` yet (it feeds the shape digest and\nthree hand-mirrored d.ts files); tool inputs read it from the manifest field.\n\n## Custom-action permission aliases (#3878)\n\n`@smrt({ operationPermissions: { run: { action: 'read' } } })` lets a custom\nmethod reuse an existing CRUD grant in its own collection. Generated SvelteKit\nPOST routes and smrt-users operation guards resolve the same mapping; the\ncatalog emits the target grant rather than a second method-named permission.\nCRUD itself cannot be aliased. Malformed declarations fail closed. Optional\n`databaseActions: ['SELECT', 'UPDATE']` adds explicit PostgreSQL policy bindings\nfor the aliased grant (for example a status-only archive guarded by `create`\nthat must load its item before updating it). This is server-authored authority\nmetadata: grant only the actions the method needs, review it with the method's\nmodel-level validation, and regenerate RLS policies when adopting a changed\nbinding.\n\nCustom actions retain the `{ action, result }` response envelope.\n\n## Collection-hosted REST factory identity (#4320)\n\nGenerated collection custom-action handlers resolve their executable collection\nusing the declaring resource's exact qualified registration key. Keep that\nfactory key separate from the item model's qualified type/policy metadata:\nitem collection slugs still govern permissions, and ordinary model routes keep\ntheir own factory lookup. A resource in another package must neither rebuild\nan item identity under its package nor steal the item's existing package when\nits collection decorator merges configuration. Registration and generated\nfactory selection share one identity resolver; never install global aliases to\nrepair an inferred or bundled class name.\n\nEvery collection hosting a generated REST action receives its exact constructor\nbinding even with `cli: false` or omitted CLI configuration. This runtime-only\nbinding does not add discovery metadata: named HTTP CLI resources keep their\nexisting explicit-include eligibility. Constructor-only qualified receivers keep\ntheir own collection-cache identity while resolving backing item metadata, so\nordinary model factories cannot reuse a different resource receiver. Neither\nCLI nor MCP discovery gains a resource or default CRUD surface from this binding.\n\nWhen consumer registration owns external packages (`smrtDependencies`), its\n`.smrt/register.js` binds external REST action receivers using the resolved\ncanonical/provider-context export. The SvelteKit registration module continues\nto delegate external imports there; external bindings also add no discovery\nmetadata and do not depend on CLI inclusion.\n"
1197
1197
  },
1198
1198
  {
1199
1199
  "path": "agents/build-knowledge.md",
1200
1200
  "module": "build-knowledge",
1201
- "content": "# Decorators, build integration, and knowledge\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes; see [schema-paths.md](schema-paths.md)), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\nAn explicit `@smrt({ collection: 'domain.records' })` names the route/permission\nnamespace independently of `tableName`, including standalone non-STI models.\nScanner output preserves it in both `collection` and `decoratorConfig.collection`.\nGenerated registration reads the merged declaration, so older manifests with a\nderived top-level collection still agree with native decorators. Explicit runtime\nregistration options win; absent declarations retain manifest/STI/default naming.\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n`ui.widget` (#3599) adds a presentation widget hint:\n`'textarea' | 'currency' | 'email' | 'url' | 'phone'`. It rides the same\n`_meta.ui` channel and is also emitted (sanitized) into web-collection\ndefinitions. The manifest generator validates it against the field type and\nfails the build otherwise: every widget needs a `text` field (`currency` is a\ncurrency-CODE picker for an ISO 4217 string such as `currency = 'USD'`, not a\nmoney amount; amounts are integer minor units and take no widget); an unknown\nvalue or a non-text field is an error. `ui.widget` is a hint only — it never changes\nthe column type or validation.\n\n`ui.format` (#3718) says how a value is presented: `money` (an `integer` of\nminor units — the amount hint record pages format and total), `date` (a\nbusiness date on a `datetime`, shown without a time), `datetime`, `percent`\n(a rate on a `decimal`/`integer`). Same channel, same emission, same build-time\ncheck against the field type. `@foreignKey(Target, { role: 'items' })` (or on\n`@crossPackageRef`) marks the declaring model as the target's line items; the\nscanner keeps it at `_meta.role`, the build rejects it on a non-reference or\nan unknown value, and the generated web relationship edge carries\n`role: 'items'` (edges without a role stay byte-stable). Both are hints: no\nschema, persistence or security effect.\n\n## Presentation metadata (#3599)\n\nPure helpers live in `src/ui-metadata.ts`, published browser-safe as\n`@happyvertical/smrt-core/ui-metadata` (also re-exported from the package\nroot). `ManifestGenerator.applyUiMetadata()` runs in `applyGenerationPasses`\nafter inheritance, so every producer (Vite plugin, `ManifestBuilder`,\n`generateManifest`) validates the same way.\n\n- **Display label.** `@smrt({ display: { label: '<field>' } })` names the own\n field generic pickers and assistants use to label a record. Own fields only —\n no paths into other packages; a model whose label lives elsewhere ships a\n selector. Undeclared, it defaults to the first of `name`, `title`, `label`,\n `code` that is a usable own field. Build fails when the declared field is\n missing, `sensitive`, `transient`, or a relationship/json field. The resolved\n field is emitted as `displayLabelField` on the manifest object and in the\n knowledge artifact, and generated MCP/WebMCP `list`/`get`/`update`/`delete`\n descriptions end with ``Records are identified by their `<field>` field.``\n (`describeAction` in `generators/tool-schema.ts`; nothing is added when the\n model has no label field).\n- **Selector slots.** `ModuleUISlot.selects: '@scope/pkg:Model'`\n (`@happyvertical/smrt-types`) marks a package component as THE selector for a\n model. The scanner reads such slots statically from `src/**/ui.ts`\n (`parseUiSelectorsFile`, independent of the class glob) and the manifest\n carries them as `uiSelectors` keyed by slot id. `selects` must be a string\n literal; a duplicate slot id, two selectors for one model, or an own-package\n target that does not exist fails the build. Hosts call\n `findSelectorFor(manifests, qualifiedName)`; generic forms use the selector\n for any `@foreignKey`/`@crossPackageRef` field targeting that model and fall\n back to a generic picker labelled by `displayLabelField`. `display` is read\n per class: an STI subclass that declares none uses the default lookup rather\n than its base's declaration, and a CTI child cannot name a parent's field.\n\n## Worker registration artifact (#3117)\n\nWith `svelteKit.enabled`, a production `vite build` compiles the generated\n`smrt-register.ts` into `.smrt/runtime/register.js` after the server build\n(`src/vite-plugin/worker-registration.ts`, called once from `closeBundle`).\nadapter-node re-bundles the server from its own entries, so the web bundle is\nnever importable by a separate Node process; this artifact is how a deployed\ntask/schedule worker registers the app's local objects (and, through the\n`.smrt/register.js` it imports, consumed packages' objects) with the same\nqualified identities and isolated manifests as the web server. It is a nested\nSSR build with no consumer plugins: only aliases pointing into the project\nsources (e.g. `$lib`) apply, and bare imports stay external so the worker\nshares the installed `@happyvertical/smrt-core` registry. The sole exception is\n`@oxc-project/runtime` and its helper subpaths: Vite injects them when lowering\ndecorators, but applications do not declare that compiler support dependency,\nso the artifact bundles them. SvelteKit\nvirtual modules (`$env/*`, `$app/*`) are not available to a worker; an object\nimporting one fails at worker startup. A failure fails the build.\n\n**Register imports stay Node-loadable (#4219).** `.smrt/register.js`\n(`generateRegistrationFile` in `src/consumer-plugin/index.ts`) is evaluated by\nplain Node — `smrt db:migrate`, every CLI, and the worker artifact above — as\nwell as by Vite SSR, so it may only import modules Node can load. Each object\nis imported from its manifest `importPath` (the scanner's export-subpath match,\ne.g. `@happyvertical/smrt-products/models`). Each configured provider in\n`smrtDependencies` is also imported once so it is evaluated in Node's installed\npackage context (#4187): the package root, unless the package declares that\nroot as a Svelte entry (`isComponentBearingRoot`: a `svelte` field, or a `.`\nexport with a `svelte` condition or a `.svelte`/`.css` target — e.g.\n`@happyvertical/smrt-products`, whose root re-exports its components and so\nreaches `@happyvertical/smrt-ui`'s `.svelte` files). Such a root is never\nimported and no subpath is substituted for it (`resolveProviderContextImport`\nreturns `undefined`): its already-imported object entries establish the\ncontext, a provider contributing only nested objects needs none because the\ntransitive loader uses only `import.meta.resolve(provider)`, and a substitute\n`./models` whose JS reached `.svelte` would re-create the failure. A package that publishes Svelte\nonly under subpaths keeps its root import; the broader `isComponentBearingPackage`\ntest (any Svelte export) only selects `ssr.noExternal` packages. A `svelte`\ncondition counts even with a plain `.js` target — that is what svelte-package\nand Vite library builds emit for a root that re-exports components, and every\nSvelte root in this workspace (`smrt-products`, `smrt-ui`, `smrt-svelte`,\n`smrt-planner`) has that shape and fails under plain Node; a Node-safe root\nthat declares `svelte` anyway only loses an optional context import.\nTransitive-only object modules are loaded through `importSmrtDependency()`\nfrom their provider's context, which needs no root import.\n\nNot covered by contract: a package whose root is a Svelte entry AND whose\n`@smrt()` objects are exported only from that root. The object loop must then\nimport the root (it is the only module exporting the constructors) — statically\nwhen the app resolves the package, or through `importSmrtDependency()` when it\nis reached only through its provider — so Vite SSR works but plain Node can\nload the register only if that root happens to be Node-safe. Generation keeps\nthe import and warns once per package (`declares a Svelte root entry`), asking\nto verify the root under plain Node or publish the objects from `./models` or\n`./objects`. The declaration is a heuristic: a Node-safe root that declares\n`svelte` only by convention gets the same warning (a false positive, harmless —\nthe import is kept) and, when its objects come from subpaths, loses only its\noptional provider-context import. Regression: the `#4219` tests in\n`index.test.ts` (unit cases for `isComponentBearingRoot` /\n`resolveProviderContextImport`, a child-Node load of the generated register,\nthe nested-only provider that gets no import, and the root-only and transitive\nwarnings).\n\n**Gate: `pnpm check:node-roots`** (root script; run after `pnpm build`). It\nruns `scripts/survey-node-root-load.mjs --strict --allow=<packages>`, which\nspawns `node --input-type=module -e \"await import('<name>')\"` from every\nbuilt workspace package whose manifest declares `@smrt()` objects and fails on\nany root plain Node cannot evaluate that is not allow-listed. The allow-list\nis explicit and lives in the root `package.json` script: currently only\n`@happyvertical/smrt-products`, whose root re-exports its Svelte components by\ndocumented contract. A new entry needs the same justification; a model root\nthat merely starts importing `@happyvertical/smrt-ui` must be fixed, not\nallowed. `--strict` also fails when nothing was surveyed (run before\n`pnpm build`) and when an allow-listed package was not surveyed or now loads,\nso the list cannot go stale. `--all` surveys every published package,\nincluding those without objects, as a report.\n\nCI runs the gate in both Knowledge Freshness jobs of\n`.github/workflows/test-suite.yml`. In full mode (the merge queue, and every PR\nwhile `vars.CI_MERGE_QUEUE_ENABLED` is not `true`) it always runs. In affected\nmode it runs only when the `knowledge` paths filter matches, which covers\n`package.json` files and docs but not `packages/*/src/**`; a same-repo PR that\nmakes a model root import smrt-ui only through source is therefore first\ncaught in the merge queue, which still blocks the merge. The filter is not\nwidened for this, because that would run the knowledge job on every PR. Run\n`pnpm check:node-roots` locally when changing a model package's root imports.\n\n## Lightweight discovery\n\nFilesystem-only discovery, module-doc parsing, and graph freshness are owned by\n`@happyvertical/smrt-scanner/knowledge`. Core's knowledge exports remain compatible\nreexports. Runtime manifest projection and custom-action semantics stay in core.\n\n`src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates\ninstalled scope directories and reads canonical AGENTS/legacy CLAUDE docs without\nloading package code, artifacts, or scanning objects. CLI snapshots and MCP share\nthese primitives. Callers retain selection policy: snapshots resolve links and\nfall back to `packages/*` only when no installed SMRT package loads; MCP preserves\nnode_modules paths, deduplicates realpaths, excludes authored workspace links,\nand then enriches the selected packages. Workspace-root/glob discovery remains\nowned by each consumer.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe CLI's schema-command gate reads that same merged file: a pure-consumer\nproject (0 local objects) qualifies on the consumed packages its manifest\nrecords, so a clobbered `objects` map is what made `smrt db:migrate` report\n`missing_local_manifest` (#2925).\n\n`.smrt/manifest.json` has two writers and neither owns all of it: `smrtPlugin()`\ncontributes the project's scanned objects and `smrtConsumer()` the consumed\npackages' entries. Both writes are merge-preserving, keyed on each entry's\nrecorded `packageName` — a plain overwrite in either direction silently drops\nthe other's objects, and the last pass of a multi-environment build is not\nreliably the aggregating one (#1760, #2925). `dist/manifest.json` stays\nlocal-only: a published package must not carry its dependencies' objects.\nCore's canonical generator excludes temporary `__test-*` source roots as well\nas test files and `__tests__`; prepack verifies fresh and reused manifests\ncontain no test-artifact marker or fixture source provenance (#4190).\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nTest generation uses that same effective configuration and publishes knowledge\nmatching the manifest it generated. Disabling knowledge removes a previous local\nknowledge artifact, so consumers cannot mistake it for current enabled output.\n`new ManifestBuilder(projectRoot)` keeps discovery, package identity, and output\nat that explicit root, including installed providers and external base classes;\nomitting the argument retains the current-working-directory\nbehavior. Both manifest writers retain declared package dependencies when merging\ntheir local and consumed-object projections.\n\nTest manifests carry optional `artifactPurpose: 'test'`; dependency discovery\nskips those artifacts, including `.smrt` fallbacks. Unmarked runtime manifests,\nempty published providers, and source-only development providers remain eligible.\n`ManifestBuilder` accepts explicit `outputMode: 'dev' | 'build'`; omitted mode\nretains legacy filename inference. Vitest selects dev mode explicitly, so tests\nnever replace a production `dist/manifest.json`. Relative output directories are\nresolved against the builder's project root.\n\nWith `svelteKit.enabled`, `smrtPlugin()`'s transform prepends\n`import './smrt-register.js';` to the configured config module\n(`<configPath>/<configFileName>`, `src/vite-plugin/sveltekit-register-injection.ts`)\nonce the register module exists, on the same first line so line numbers hold.\nApps therefore never hand-write the guarded registration import (#3416); a\nconfig module that still has it imports the same module twice, harmlessly.\n\nTracked `src/lib/server/smrt-register.ts` belongs to SvelteKit config/type\ngeneration. Generic library builds consume it but do not cache or restore it\nas an output; otherwise a concurrent config producer's bytes can be replayed\nover a newer registration after dependency eligibility changes.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n\n## Vite Plugin\n\n`src/vite-preset/` (`@happyvertical/smrt-core/vite`, `smrt()`) composes the Oxc\ndecorator block, `smrtConsumer`, then `smrtPlugin` with the template path\nconventions. Its package list comes only from the `smrt({ packages })` option, or\n`smrt.config` `consumer.packages` (smrt-config schema); it never sniffs\n`package.json`, and a missing or malformed list throws. Keep its output\nbyte-identical to the two-plugin form (`src/vite-preset/index.test.ts`). It\nalso dedupes `@sveltejs/kit` (`smrt:shared-runtime`): SvelteKit matches\n`redirect()`/`fail()` by class, and a linked/workspace SMRT SvelteKit entry\nwould otherwise import its own copy and turn redirects into 500s. The preset\nalso keeps the thin `@happyvertical/smrt-app-runtime/sveltekit` entry inside\nthe SSR bundle: adapter-node bundles Kit itself, and an external runtime\nwould load a separate class through Node even at the same installed version.\nVite matches `ssr.noExternal` by package name, so a pre-resolver keeps the\nexact runtime root external and `ssr.external` keeps `./sveltekit-runtime`\nexternal. That implementation subpath is an internal composition boundary;\napps keep importing `./sveltekit`. This avoids bundling native SQL dependencies.\nThis split applies only to builds; development keeps normal SSR externalization\nand Kit dedupe because Vite's dev module runner ignores resolver external flags.\nOther runtime dependencies retain their normal externalization. Do not explicitly\nexternalize app-runtime in an app config. The production regression in\n`packages/app-runtime/src/sveltekit-production.test.ts` exercises the final\nadapter-node HTTP handler using an installed copy of the published dist entry.\n\n**Package models over REST (#3749).** `consumer.packages` registers a package's\nmodels and gives `@smrt/web` (the consumer's `collectionDefinitions`, one per\nlist-capable model, endpoint `/<collection>`) their definitions, but a package\nmodel gets generated REST routes only when it is on the consumer's hosting\nallowlist (`smrtConsumer({ svelteKit: { objects } })`, #2850: an HTTP exposure\nboundary, each model's own `api` config still applies, so `api: false` and\nempty-include models stay closed, tenancy comes with the generated route). The\npreset fills that allowlist: with `smrt.cookbook.json` present it is the models\nof the cookbook's recipes plus `features`, minus models the cookbook `exposure`\nturns off for `api` or a recipe's `options[model].exposure.api === false`\n(`vite-preset/cookbook-exposure.ts`; recipe `{ exclude }` narrowing is not\napplied by the build yet). `smrt({ expose })` replaces the derived list. No\ncookbook and no `expose` keeps `svelteKit: true` (nothing hosted). The producer's\n`@happyvertical/smrt-virt-web` still covers only the app's own models; read\npackage models from `@smrt/web`. `smrt({ objectsDir })` is passed to the hosting\ncontribution too: the producer and consumer share one `routesDir`, and the\ngenerator refuses it when their settings differ.\n\n**STI siblings (#3749).** Package STI subclasses share their base's table, and\nthe manifest gives them the base's `collection` unless they declare one\n(`consumer-plugin/hosted-sti-collections.ts`). Two hosted siblings would both\nclaim `/<base collection>`, so each hosted subclass that inherits its\ncollection gets its class-derived one (`Order` -> `orders`,\n`defaultCollectionName` in the scanner), in every manifest view the consumer\nderives: routes, `@smrt/web`, the generated client and registration. This is the\nconvention the framework already has for an independent route collection\n(`@smrt({ collection })`), applied by default for hosted subclasses; an explicit\ncollection wins. The route therefore reads through the subclass's own\ncollection (rows scoped to its `_meta_type`) and the definition for that model\nadvertises that route (`objectRef` = the model, `endpoint` = `/orders`). A hosted\nbase keeps the shared name and the base-collection semantics (every row of the\ntable, any subtype). A subclass left off the allowlist keeps the shared\ncollection and folds into the base's definition, as before. A collection class\nthat inherits another's item type (`MaterialCollection extends\nProductCollection`) resolves to the same item and collection, so only one of\nthem is hosted (the class named for the item, else the first). Subclasses that\ndeclare no `api` config get the omitted-config default (full CRUD), not the\nbase's `include`; narrow them on the subclass if the base is meant to restrict\nthem.\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Clean consumer type checks (TypeScript 6)\n\nThe consumer config hook writes `.smrt/register.js` alongside generated types\nafter route preflight succeeds. Generated SvelteKit registration imports that\nmodule, so it must exist during `svelte-kit sync`, before Vite's `buildStart`.\nBoth hosted routes and the legacy `svelteKit: true` mode follow this order.\nThe clean-consumer regression fixture ships real provider declarations and\nchecks the generated imports with `svelte-check`.\nCore's `generate:test` task depends on its own `build`: both publish the local\n`.smrt` manifest/knowledge pair, so Turbo must serialize production before test\ngeneration instead of caching an interleaved pair.\n\n## Model descriptions\n\nEach manifest object and knowledge `objects[]` entry may carry a user-facing\n`description` (for the planner Features tab and similar). The scanner\n(`extractClassJsDocDescription` in `packages/scanner/src/oxc-parser.ts`) reads it\nfrom the parsed source, in priority order: explicit `@smrt({ description })`,\nelse the first paragraph of the class JSDoc (the JSDoc may sit above `export`\nand decorators), minus a leading `ClassName - ` / `ClassName: `, tags\n(`{@link}`, backticks, HTML) and later paragraphs, capped at 200 chars on a\nsentence boundary. `@internal` classes and classes without docs emit no key. It\nis not inherited from a parent class. Generated MCP tool descriptions are\nunchanged (they stay generic).\n"
1201
+ "content": "# Decorators, build integration, and knowledge\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes; see [schema-paths.md](schema-paths.md)), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\nAn explicit `@smrt({ collection: 'domain.records' })` names the route/permission\nnamespace independently of `tableName`, including standalone non-STI models.\nScanner output preserves it in both `collection` and `decoratorConfig.collection`.\nGenerated registration reads the merged declaration, so older manifests with a\nderived top-level collection still agree with native decorators. Explicit runtime\nregistration options win; absent declarations retain manifest/STI/default naming.\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n`ui.widget` (#3599) adds a presentation widget hint:\n`'textarea' | 'currency' | 'email' | 'url' | 'phone'`. It rides the same\n`_meta.ui` channel and is also emitted (sanitized) into web-collection\ndefinitions. The manifest generator validates it against the field type and\nfails the build otherwise: every widget needs a `text` field (`currency` is a\ncurrency-CODE picker for an ISO 4217 string such as `currency = 'USD'`, not a\nmoney amount; amounts are integer minor units and take no widget); an unknown\nvalue or a non-text field is an error. `ui.widget` is a hint only — it never changes\nthe column type or validation.\n\n`ui.format` (#3718) says how a value is presented: `money` (an `integer` of\nminor units — the amount hint record pages format and total), `date` (a\nbusiness date on a `datetime`, shown without a time), `datetime`, `percent`\n(a rate on a `decimal`/`integer`). Same channel, same emission, same build-time\ncheck against the field type. `@foreignKey(Target, { role: 'items' })` (or on\n`@crossPackageRef`) marks the declaring model as the target's line items; the\nscanner keeps it at `_meta.role`, the build rejects it on a non-reference or\nan unknown value, and the generated web relationship edge carries\n`role: 'items'` (edges without a role stay byte-stable). Both are hints: no\nschema, persistence or security effect.\n\n## Presentation metadata (#3599)\n\nPure helpers live in `src/ui-metadata.ts`, published browser-safe as\n`@happyvertical/smrt-core/ui-metadata` (also re-exported from the package\nroot). `ManifestGenerator.applyUiMetadata()` runs in `applyGenerationPasses`\nafter inheritance, so every producer (Vite plugin, `ManifestBuilder`,\n`generateManifest`) validates the same way.\n\n- **Display label.** `@smrt({ display: { label: '<field>' } })` names the own\n field generic pickers and assistants use to label a record. Own fields only —\n no paths into other packages; a model whose label lives elsewhere ships a\n selector. Undeclared, it defaults to the first of `name`, `title`, `label`,\n `code` that is a usable own field. Build fails when the declared field is\n missing, `sensitive`, `transient`, or a relationship/json field. The resolved\n field is emitted as `displayLabelField` on the manifest object and in the\n knowledge artifact, and generated MCP/WebMCP `list`/`get`/`update`/`delete`\n descriptions end with ``Records are identified by their `<field>` field.``\n (`describeAction` in `generators/tool-schema.ts`; nothing is added when the\n model has no label field).\n- **Selector slots.** `ModuleUISlot.selects: '@scope/pkg:Model'`\n (`@happyvertical/smrt-types`) marks a package component as THE selector for a\n model. The scanner reads such slots statically from `src/**/ui.ts`\n (`parseUiSelectorsFile`, independent of the class glob) and the manifest\n carries them as `uiSelectors` keyed by slot id. `selects` must be a string\n literal; a duplicate slot id, two selectors for one model, or an own-package\n target that does not exist fails the build. Hosts call\n `findSelectorFor(manifests, qualifiedName)`; generic forms use the selector\n for any `@foreignKey`/`@crossPackageRef` field targeting that model and fall\n back to a generic picker labelled by `displayLabelField`. `display` is read\n per class: an STI subclass that declares none uses the default lookup rather\n than its base's declaration, and a CTI child cannot name a parent's field.\n\n## Worker registration artifact (#3117)\n\nWith `svelteKit.enabled`, a production `vite build` compiles the generated\n`smrt-register.ts` into `.smrt/runtime/register.js` after the server build\n(`src/vite-plugin/worker-registration.ts`, called once from `closeBundle`).\nadapter-node re-bundles the server from its own entries, so the web bundle is\nnever importable by a separate Node process; this artifact is how a deployed\ntask/schedule worker registers the app's local objects (and, through the\n`.smrt/register.js` it imports, consumed packages' objects) with the same\nqualified identities and isolated manifests as the web server. It is a nested\nSSR build with no consumer plugins: only aliases pointing into the project\nsources (e.g. `$lib`) apply, and bare imports stay external so the worker\nshares the installed `@happyvertical/smrt-core` registry. The sole exception is\n`@oxc-project/runtime` and its helper subpaths: Vite injects them when lowering\ndecorators, but applications do not declare that compiler support dependency,\nso the artifact bundles them. SvelteKit\nvirtual modules (`$env/*`, `$app/*`) are not available to a worker; an object\nimporting one fails at worker startup. A failure fails the build.\n\n**Register imports stay Node-loadable (#4219).** `.smrt/register.js`\n(`generateRegistrationFile` in `src/consumer-plugin/index.ts`) is evaluated by\nplain Node — `smrt db:migrate`, every CLI, and the worker artifact above — as\nwell as by Vite SSR, so it may only import modules Node can load. Each object\nis imported from its manifest `importPath` (the scanner's export-subpath match,\ne.g. `@happyvertical/smrt-products/models`). Each configured provider in\n`smrtDependencies` is also imported once so it is evaluated in Node's installed\npackage context (#4187): the package root, unless the package declares that\nroot as a Svelte entry (`isComponentBearingRoot`: a `svelte` field, or a `.`\nexport with a `svelte` condition or a `.svelte`/`.css` target — e.g.\n`@happyvertical/smrt-products`, whose root re-exports its components and so\nreaches `@happyvertical/smrt-ui`'s `.svelte` files). Such a root is never\nimported and no subpath is substituted for it (`resolveProviderContextImport`\nreturns `undefined`): its already-imported object entries establish the\ncontext, a provider contributing only nested objects needs none because the\ntransitive loader uses only `import.meta.resolve(provider)`, and a substitute\n`./models` whose JS reached `.svelte` would re-create the failure. A package that publishes Svelte\nonly under subpaths keeps its root import; the broader `isComponentBearingPackage`\ntest (any Svelte export) only selects `ssr.noExternal` packages. A `svelte`\ncondition counts even with a plain `.js` target — that is what svelte-package\nand Vite library builds emit for a root that re-exports components, and every\nSvelte root in this workspace (`smrt-products`, `smrt-ui`, `smrt-svelte`,\n`smrt-planner`) has that shape and fails under plain Node; a Node-safe root\nthat declares `svelte` anyway only loses an optional context import.\nTransitive-only object modules are loaded through `importSmrtDependency()`\nfrom their provider's context, which needs no root import.\n\nSelective SSR compilation compares a JavaScript barrel's `svelte` export target\nagainst the package instance visible from its actual importer. pnpm may install\nthe same version in multiple peer contexts; the app-root package directory is\nnot the identity of a transitive import. Read that importer's installed export\nmap and root together, preserving exact target matching. Plain server entries\nremain external; do not fix peer-context resolution by deduplicating app\npackages or bundling every server export (#4318).\n\nNot covered by contract: a package whose root is a Svelte entry AND whose\n`@smrt()` objects are exported only from that root. The object loop must then\nimport the root (it is the only module exporting the constructors) — statically\nwhen the app resolves the package, or through `importSmrtDependency()` when it\nis reached only through its provider — so Vite SSR works but plain Node can\nload the register only if that root happens to be Node-safe. Generation keeps\nthe import and warns once per package (`declares a Svelte root entry`), asking\nto verify the root under plain Node or publish the objects from `./models` or\n`./objects`. The declaration is a heuristic: a Node-safe root that declares\n`svelte` only by convention gets the same warning (a false positive, harmless —\nthe import is kept) and, when its objects come from subpaths, loses only its\noptional provider-context import. Regression: the `#4219` tests in\n`index.test.ts` (unit cases for `isComponentBearingRoot` /\n`resolveProviderContextImport`, a child-Node load of the generated register,\nthe nested-only provider that gets no import, and the root-only and transitive\nwarnings).\n\n**Gate: `pnpm check:node-roots`** (root script; run after `pnpm build`). It\nruns `scripts/survey-node-root-load.mjs --strict --allow=<packages>`, which\nspawns `node --input-type=module -e \"await import('<name>')\"` from every\nbuilt workspace package whose manifest declares `@smrt()` objects and fails on\nany root plain Node cannot evaluate that is not allow-listed. The allow-list\nis explicit and lives in the root `package.json` script: currently only\n`@happyvertical/smrt-products`, whose root re-exports its Svelte components by\ndocumented contract. A new entry needs the same justification; a model root\nthat merely starts importing `@happyvertical/smrt-ui` must be fixed, not\nallowed. `--strict` also fails when nothing was surveyed (run before\n`pnpm build`) and when an allow-listed package was not surveyed or now loads,\nso the list cannot go stale. `--all` surveys every published package,\nincluding those without objects, as a report.\n\nCI runs the gate in both Knowledge Freshness jobs of\n`.github/workflows/test-suite.yml`. In full mode (the merge queue, and every PR\nwhile `vars.CI_MERGE_QUEUE_ENABLED` is not `true`) it always runs. In affected\nmode it runs only when the `knowledge` paths filter matches, which covers\n`package.json` files and docs but not `packages/*/src/**`; a same-repo PR that\nmakes a model root import smrt-ui only through source is therefore first\ncaught in the merge queue, which still blocks the merge. The filter is not\nwidened for this, because that would run the knowledge job on every PR. Run\n`pnpm check:node-roots` locally when changing a model package's root imports.\n\n## Lightweight discovery\n\nFilesystem-only discovery, module-doc parsing, and graph freshness are owned by\n`@happyvertical/smrt-scanner/knowledge`. Core's knowledge exports remain compatible\nreexports. Runtime manifest projection and custom-action semantics stay in core.\n\n`src/knowledge-discovery.ts`, exported through `smrt-core/knowledge`, enumerates\ninstalled scope directories and reads canonical AGENTS/legacy CLAUDE docs without\nloading package code, artifacts, or scanning objects. CLI snapshots and MCP share\nthese primitives. Callers retain selection policy: snapshots resolve links and\nfall back to `packages/*` only when no installed SMRT package loads; MCP preserves\nnode_modules paths, deduplicates realpaths, excludes authored workspace links,\nand then enriches the selected packages. Workspace-root/glob discovery remains\nowned by each consumer.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe CLI's schema-command gate reads that same merged file: a pure-consumer\nproject (0 local objects) qualifies on the consumed packages its manifest\nrecords, so a clobbered `objects` map is what made `smrt db:migrate` report\n`missing_local_manifest` (#2925).\n\n`.smrt/manifest.json` has two writers and neither owns all of it: `smrtPlugin()`\ncontributes the project's scanned objects and `smrtConsumer()` the consumed\npackages' entries. Both writes are merge-preserving, keyed on each entry's\nrecorded `packageName` — a plain overwrite in either direction silently drops\nthe other's objects, and the last pass of a multi-environment build is not\nreliably the aggregating one (#1760, #2925). `dist/manifest.json` stays\nlocal-only: a published package must not carry its dependencies' objects.\nCore's canonical generator excludes temporary `__test-*` source roots as well\nas test files and `__tests__`; prepack verifies fresh and reused manifests\ncontain no test-artifact marker or fixture source provenance (#4190).\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nTest generation uses that same effective configuration and publishes knowledge\nmatching the manifest it generated. Disabling knowledge removes a previous local\nknowledge artifact, so consumers cannot mistake it for current enabled output.\n`new ManifestBuilder(projectRoot)` keeps discovery, package identity, and output\nat that explicit root, including installed providers and external base classes;\nomitting the argument retains the current-working-directory\nbehavior. Both manifest writers retain declared package dependencies when merging\ntheir local and consumed-object projections.\n\nTest manifests carry optional `artifactPurpose: 'test'`; dependency discovery\nskips those artifacts, including `.smrt` fallbacks. Unmarked runtime manifests,\nempty published providers, and source-only development providers remain eligible.\n`ManifestBuilder` accepts explicit `outputMode: 'dev' | 'build'`; omitted mode\nretains legacy filename inference. Vitest selects dev mode explicitly, so tests\nnever replace a production `dist/manifest.json`. Relative output directories are\nresolved against the builder's project root.\n\nWith `svelteKit.enabled`, `smrtPlugin()`'s transform prepends\n`import './smrt-register.js';` to the configured config module\n(`<configPath>/<configFileName>`, `src/vite-plugin/sveltekit-register-injection.ts`)\nonce the register module exists, on the same first line so line numbers hold.\nApps therefore never hand-write the guarded registration import (#3416); a\nconfig module that still has it imports the same module twice, harmlessly.\n\nTracked `src/lib/server/smrt-register.ts` belongs to SvelteKit config/type\ngeneration. Generic library builds consume it but do not cache or restore it\nas an output; otherwise a concurrent config producer's bytes can be replayed\nover a newer registration after dependency eligibility changes.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n\n## Vite Plugin\n\n`src/vite-preset/` (`@happyvertical/smrt-core/vite`, `smrt()`) composes the Oxc\ndecorator block, `smrtConsumer`, then `smrtPlugin` with the template path\nconventions. Its package list comes only from the `smrt({ packages })` option, or\n`smrt.config` `consumer.packages` (smrt-config schema); it never sniffs\n`package.json`, and a missing or malformed list throws. Keep its output\nbyte-identical to the two-plugin form (`src/vite-preset/index.test.ts`). It\nalso dedupes `@sveltejs/kit` (`smrt:shared-runtime`): SvelteKit matches\n`redirect()`/`fail()` by class, and a linked/workspace SMRT SvelteKit entry\nwould otherwise import its own copy and turn redirects into 500s. The preset\nalso keeps the thin `@happyvertical/smrt-app-runtime/sveltekit` entry inside\nthe SSR bundle: adapter-node bundles Kit itself, and an external runtime\nwould load a separate class through Node even at the same installed version.\nVite matches `ssr.noExternal` by package name, so a pre-resolver keeps the\nexact runtime root external and `ssr.external` keeps `./sveltekit-runtime`\nexternal. That implementation subpath is an internal composition boundary;\napps keep importing `./sveltekit`. This avoids bundling native SQL dependencies.\nThis split applies only to builds; development keeps normal SSR externalization\nand Kit dedupe because Vite's dev module runner ignores resolver external flags.\nOther runtime dependencies retain their normal externalization. Do not explicitly\nexternalize app-runtime in an app config. The production regression in\n`packages/app-runtime/src/sveltekit-production.test.ts` exercises the final\nadapter-node HTTP handler using an installed copy of the published dist entry.\n\n**Package models over REST (#3749).** `consumer.packages` registers a package's\nmodels and gives `@smrt/web` (the consumer's `collectionDefinitions`, one per\nlist-capable model, endpoint `/<collection>`) their definitions, but a package\nmodel gets generated REST routes only when it is on the consumer's hosting\nallowlist (`smrtConsumer({ svelteKit: { objects } })`, #2850: an HTTP exposure\nboundary, each model's own `api` config still applies, so `api: false` and\nempty-include models stay closed, tenancy comes with the generated route). The\npreset fills that allowlist: with `smrt.cookbook.json` present it is the models\nof the cookbook's recipes plus `features`, minus models the cookbook `exposure`\nturns off for `api` or a recipe's `options[model].exposure.api === false`\n(`vite-preset/cookbook-exposure.ts`; recipe `{ exclude }` narrowing is not\napplied by the build yet). `smrt({ expose })` replaces the derived list. No\ncookbook and no `expose` keeps `svelteKit: true` (nothing hosted). The producer's\n`@happyvertical/smrt-virt-web` still covers only the app's own models; read\npackage models from `@smrt/web`. `smrt({ objectsDir })` is passed to the hosting\ncontribution too: the producer and consumer share one `routesDir`, and the\ngenerator refuses it when their settings differ.\n\n**STI siblings (#3749).** Package STI subclasses share their base's table, and\nthe manifest gives them the base's `collection` unless they declare one\n(`consumer-plugin/hosted-sti-collections.ts`). Two hosted siblings would both\nclaim `/<base collection>`, so each hosted subclass that inherits its\ncollection gets its class-derived one (`Order` -> `orders`,\n`defaultCollectionName` in the scanner), in every manifest view the consumer\nderives: routes, `@smrt/web`, the generated client and registration. This is the\nconvention the framework already has for an independent route collection\n(`@smrt({ collection })`), applied by default for hosted subclasses; an explicit\ncollection wins. The route therefore reads through the subclass's own\ncollection (rows scoped to its `_meta_type`) and the definition for that model\nadvertises that route (`objectRef` = the model, `endpoint` = `/orders`). A hosted\nbase keeps the shared name and the base-collection semantics (every row of the\ntable, any subtype). A subclass left off the allowlist keeps the shared\ncollection and folds into the base's definition, as before. A collection class\nthat inherits another's item type (`MaterialCollection extends\nProductCollection`) resolves to the same item and collection, so only one of\nthem is hosted (the class named for the item, else the first). Subclasses that\ndeclare no `api` config get the omitted-config default (full CRUD), not the\nbase's `include`; narrow them on the subclass if the base is meant to restrict\nthem.\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Clean consumer type checks (TypeScript 6)\n\nThe consumer config hook writes `.smrt/register.js` alongside generated types\nafter route preflight succeeds. Generated SvelteKit registration imports that\nmodule, so it must exist during `svelte-kit sync`, before Vite's `buildStart`.\nBoth hosted routes and the legacy `svelteKit: true` mode follow this order.\nThe clean-consumer regression fixture ships real provider declarations and\nchecks the generated imports with `svelte-check`.\nCore's `generate:test` task depends on its own `build`: both publish the local\n`.smrt` manifest/knowledge pair, so Turbo must serialize production before test\ngeneration instead of caching an interleaved pair.\n\n## Model descriptions\n\nEach manifest object and knowledge `objects[]` entry may carry a user-facing\n`description` (for the planner Features tab and similar). The scanner\n(`extractClassJsDocDescription` in `packages/scanner/src/oxc-parser.ts`) reads it\nfrom the parsed source, in priority order: explicit `@smrt({ description })`,\nelse the first paragraph of the class JSDoc (the JSDoc may sit above `export`\nand decorators), minus a leading `ClassName - ` / `ClassName: `, tags\n(`{@link}`, backticks, HTML) and later paragraphs, capped at 200 chars on a\nsentence boundary. `@internal` classes and classes without docs emit no key. It\nis not inherited from a parent class. Generated MCP tool descriptions are\nunchanged (they stay generic).\n"
1202
1202
  },
1203
1203
  {
1204
1204
  "path": "agents/memory.md",
@@ -1 +1 @@
1
- {"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"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"}
@@ -52,6 +52,11 @@ function getRegistrationPackageName(manifest, objectDef, isLocal) {
52
52
  if (isLocal) return objectDef.packageName ?? manifest.packageName;
53
53
  return objectDef.packageName;
54
54
  }
55
+ /** Use the same exact identity for collection registration and route factories. */
56
+ function collectionResourceRegistryKey(projectRoot, className, objectDef, manifest) {
57
+ const packageName = getRegistrationPackageName(manifest, objectDef, isLocalObject(projectRoot, objectDef));
58
+ return objectDef.qualifiedName || (className.includes(":") ? className : packageName ? `${packageName}:${extractSimpleClassName(className)}` : className);
59
+ }
55
60
  function toSingleQuotedStringLiteral(value) {
56
61
  return `'${JSON.stringify(value).slice(1, -1).replaceAll("'", "\\'")}'`;
57
62
  }
@@ -1022,7 +1027,7 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
1022
1027
  objects: [],
1023
1028
  hasCollectionImport: false
1024
1029
  };
1025
- if (isCollectionManifestClass(manifest, objectDef) && !isCollectionActionResource(manifest, objectDef)) packageEntry.hasCollectionImport = true;
1030
+ if (isCollectionManifestClass(manifest, objectDef) && !hasGeneratedCollectionActions(manifest, objectDef)) packageEntry.hasCollectionImport = true;
1026
1031
  else packageEntry.objects.push({
1027
1032
  simpleName: objectDef.exportName || extractSimpleClassName(className),
1028
1033
  bindingName: registrationBindings.get(className) || extractSimpleClassName(className)
@@ -1038,7 +1043,7 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
1038
1043
  const normalized = relative(configDir, objectDef.filePath).replace(/\\/g, "/").replace(/\.(ts|js|tsx|jsx)$/, "");
1039
1044
  importPath = normalized.startsWith(".") ? normalized : `./${normalized}`;
1040
1045
  } else importPath = getSvelteKitImportPath(projectRoot, void 0, options.objectsDir, simpleClassName);
1041
- if (isCollectionManifestClass(manifest, objectDef) && !isCollectionActionResource(manifest, objectDef)) {
1046
+ if (isCollectionManifestClass(manifest, objectDef) && !hasGeneratedCollectionActions(manifest, objectDef)) {
1042
1047
  localSideEffectImports.add(importPath);
1043
1048
  continue;
1044
1049
  }
@@ -1142,9 +1147,8 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
1142
1147
  `});`
1143
1148
  ].join("\n");
1144
1149
  }).filter((registration) => registration !== null).join("\n");
1145
- const collectionRegistrations = Object.entries(manifest.objects).filter(([, objectDef]) => isCollectionActionResource(manifest, objectDef) && (!consumerOwnsExternalRegistration || isLocalObject(projectRoot, objectDef))).map(([className, objectDef]) => {
1146
- const packageName = getRegistrationPackageName(manifest, objectDef, isLocalObject(projectRoot, objectDef));
1147
- const qualifiedName = objectDef.qualifiedName || (packageName ? `${packageName}:${extractSimpleClassName(className)}` : className);
1150
+ const collectionRegistrations = Object.entries(manifest.objects).filter(([, objectDef]) => hasGeneratedCollectionActions(manifest, objectDef) && (!consumerOwnsExternalRegistration || isLocalObject(projectRoot, objectDef))).map(([className, objectDef]) => {
1151
+ const qualifiedName = collectionResourceRegistryKey(projectRoot, className, objectDef, manifest);
1148
1152
  const bindingName = registrationBindings.get(className) || extractSimpleClassName(className);
1149
1153
  return `ObjectRegistry.registerCollection(${JSON.stringify(qualifiedName)}, ${bindingName});`;
1150
1154
  }).join("\n");
@@ -1169,13 +1173,18 @@ ${registrations}
1169
1173
  ${collectionMetadata.size > 0 ? `
1170
1174
  // Metadata only: collection constructors were installed by their decorators.
1171
1175
  ObjectRegistry.registerPackageManifest(JSON.parse(${collectionMetadataLiteral}));
1172
- // Preserve exact runtime identity alongside discovery metadata, without initialization.
1176
+ ` : ""}
1177
+ // Bind every generated REST action receiver, independently of discovery eligibility.
1173
1178
  ${collectionRegistrations}
1174
- ` : ""}`;
1179
+ `;
1175
1180
  if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
1176
1181
  writeFileSync(registrationFilePath, registrationContent, "utf-8");
1177
1182
  console.log(`[smrt] Generated registration file: ${registrationFilePath}`);
1178
1183
  }
1184
+ /** REST action receivers need a live binding even when CLI discovery is closed. */
1185
+ function hasGeneratedCollectionActions(manifest, objectDef) {
1186
+ return isCollectionManifestClass(manifest, objectDef) && resolveApiActionSet(objectDef, manifest).size > 0;
1187
+ }
1179
1188
  /** Named HTTP CLI collection actions need metadata and a live receiver binding. */
1180
1189
  function isCollectionActionResource(manifest, objectDef) {
1181
1190
  if (!isCollectionManifestClass(manifest, objectDef)) return false;
@@ -1189,7 +1198,7 @@ function buildRegistrationBindings(manifest) {
1189
1198
  const keysBySimpleName = /* @__PURE__ */ new Map();
1190
1199
  const reservedBindings = /* @__PURE__ */ new Set();
1191
1200
  for (const [manifestKey, objectDef] of Object.entries(manifest.objects)) {
1192
- if (isCollectionManifestClass(manifest, objectDef) && !isCollectionActionResource(manifest, objectDef)) continue;
1201
+ if (isCollectionManifestClass(manifest, objectDef) && !hasGeneratedCollectionActions(manifest, objectDef)) continue;
1193
1202
  const simpleName = extractSimpleClassName(manifestKey);
1194
1203
  reservedBindings.add(simpleName);
1195
1204
  const keys = keysBySimpleName.get(simpleName) ?? [];
@@ -2080,7 +2089,7 @@ function generateActionRouteTemplate(projectRoot, routeDir, routeSpecs, objectDe
2080
2089
  const tenantScoped = isTenantScoped(objectDef) || routeSpecs.some((spec) => !!spec.lookupObjectDef && isTenantScoped(spec.lookupObjectDef));
2081
2090
  const principalContext = usesPrincipalContext(objectDef) || routeSpecs.some((spec) => !!spec.lookupObjectDef && usesPrincipalContext(spec.lookupObjectDef));
2082
2091
  const needsTenantContext = tenantScoped || principalContext;
2083
- const handlers = routeSpecs.map((spec) => generateActionRouteHandler(spec.lookupClassName, spec.actionName, spec.actionDef, spec.routeConfig, spec.hostType, tenantScoped, needsTenantContext, lookupModelType.typeName, hostModelType.typeName, resolveOperationPermissionAlias(objectDef.decoratorConfig, spec.actionName)?.action)).join("\n");
2092
+ const handlers = routeSpecs.map((spec) => generateActionRouteHandler(spec.lookupClassName, spec.actionName, spec.actionDef, spec.routeConfig, spec.hostType, tenantScoped, needsTenantContext, lookupModelType.typeName, hostModelType.typeName, resolveOperationPermissionAlias(objectDef.decoratorConfig, spec.actionName)?.action, spec.hostType === "collection" ? collectionResourceRegistryKey(projectRoot, spec.hostClassName, objectDef, semanticManifest) : void 0)).join("\n");
2084
2093
  return `${AUTO_GENERATED_ROUTE_HEADER}
2085
2094
  // DO NOT EDIT - changes will be overwritten
2086
2095
 
@@ -2089,7 +2098,7 @@ ${generateAuthGuardHelper(objectDef, semanticManifest, hostType === "collection"
2089
2098
  ${generateTypedRouteErrorHelper()}
2090
2099
  ${handlers}`;
2091
2100
  }
2092
- function generateActionRouteHandler(lookupClassName, actionName, actionDef, routeConfig, hostType, tenantScoped, needsTenantContext, lookupTypeName, hostTypeName, permissionAction) {
2101
+ function generateActionRouteHandler(lookupClassName, actionName, actionDef, routeConfig, hostType, tenantScoped, needsTenantContext, lookupTypeName, hostTypeName, permissionAction, collectionRegistryKey = lookupClassName) {
2093
2102
  const successResponse = ` return json({
2094
2103
  action: '${actionName}',
2095
2104
  result: toPublicResult(result, publicJsonOptions),
@@ -2113,9 +2122,9 @@ function generateActionRouteHandler(lookupClassName, actionName, actionDef, rout
2113
2122
  export const ${handlerName}: RequestHandler = async (${collectionHandlerArgs}) => {
2114
2123
  ${authGuardLine}
2115
2124
  const publicJsonOptions = getPublicJsonOptions(locals);
2116
- ${generateCollectionLoad(lookupClassName, { typeName: lookupTypeName })}
2125
+ ${generateCollectionLoad(collectionRegistryKey, { typeName: lookupTypeName })}
2117
2126
  const typedCollection = collection as unknown as ${hostTypeName};
2118
- ${generateCollectionNotRegisteredError(lookupClassName)}
2127
+ ${generateCollectionNotRegisteredError(collectionRegistryKey)}
2119
2128
  ${staticTargetLoad}
2120
2129
 
2121
2130
  ${optionsLoad}${scopedOptions.source} let result: unknown;