@happyvertical/smrt-core 0.57.13 → 0.57.15
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.
- package/agents/generators.md +35 -0
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/registry/collection-resolution.d.ts.map +1 -1
- package/dist/registry/collection-resolution.js +6 -0
- package/dist/registry/collection-resolution.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +2 -1
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/smrt-knowledge.json +5 -5
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +44 -5
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/package.json +4 -4
package/dist/smrt-knowledge.json
CHANGED
|
@@ -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.
|
|
6
|
+
"packageVersion": "0.57.15",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
10
|
+
"manifest": "19f3e8c890cee71e38319d2042af535a4398db25869e4534918c6a59fb05573d",
|
|
11
|
+
"packageJson": "708f403d38434c8729f97d8f7b7a5cb9fe8dcd49184799c79a8b7c3dd34240cc",
|
|
12
12
|
"agents": "3a78a0babd2884ac22e4b376a3c9618037ab818ad4bdd762751f82fa53c21d04",
|
|
13
13
|
"moduleDoc:agents/object-runtime.md": "fd2748649e820c2fe5960fa4a44c5f3e2a5afa57da89925e8b4cb3b53d67a07b",
|
|
14
14
|
"moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
|
|
@@ -18,7 +18,7 @@
|
|
|
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": "
|
|
21
|
+
"moduleDoc:agents/generators.md": "6eecf5c639bac7c88d7b72456f16d84dd9b69bebac76464e9329b4775c54906f",
|
|
22
22
|
"moduleDoc:agents/build-knowledge.md": "ebf431321e0014f24df4e8b8b6f3cfc4e38626e40d5e1e37cebe802766d97964",
|
|
23
23
|
"moduleDoc:agents/memory.md": "a86bdf370403d08ecad3be86e341eb322dfe6e6b7a4e8806a6f8be9bf3421da5",
|
|
24
24
|
"moduleDoc:agents/system-diagnostics.md": "b7ccae18ee312bf788d5470f95fa78d3535f1789247d5372e40ca86538290772",
|
|
@@ -1192,7 +1192,7 @@
|
|
|
1192
1192
|
{
|
|
1193
1193
|
"path": "agents/generators.md",
|
|
1194
1194
|
"module": "generators",
|
|
1195
|
-
"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## 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\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"
|
|
1195
|
+
"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\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
1196
|
},
|
|
1197
1197
|
{
|
|
1198
1198
|
"path": "agents/build-knowledge.md",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAEvE,OAAO,EACL,KAAK,iBAAiB,EAYtB,KAAK,kBAAkB,EACxB,MAAM,gCAAgC,CAAC;AAOxC,OAAO,KAAK,EAEV,aAAa,EAEd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EACV,gBAAgB,EAChB,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,qBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"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"}
|
|
@@ -11,7 +11,7 @@ import { generateEventsRoute } from "./events-route.js";
|
|
|
11
11
|
import { generateResourcesRoute, reservesResourcesRoute } from "./resources-route.js";
|
|
12
12
|
import { canonicalSvelteKitPath } from "./sveltekit-path.js";
|
|
13
13
|
import { collectSyncApplyTargets, generateSyncApplyRoute } from "./sync-apply-route.js";
|
|
14
|
-
import { computeWebManifestHash, isCollectionManifestClass, orderedManifestObjectEntries, resolveCollectionItemObject, resolveCollectionItemTypeName } from "./web-collections.js";
|
|
14
|
+
import { computeWebManifestHash, findManifestObjectByName, isCollectionManifestClass, orderedManifestObjectEntries, resolveCollectionItemObject, resolveCollectionItemTypeName } from "./web-collections.js";
|
|
15
15
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { pathToFileURL } from "node:url";
|
|
17
17
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -1016,7 +1016,7 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
|
|
|
1016
1016
|
objects: [],
|
|
1017
1017
|
hasCollectionImport: false
|
|
1018
1018
|
};
|
|
1019
|
-
if (isCollectionManifestClass(manifest, objectDef)) packageEntry.hasCollectionImport = true;
|
|
1019
|
+
if (isCollectionManifestClass(manifest, objectDef) && !isCollectionActionResource(manifest, objectDef)) packageEntry.hasCollectionImport = true;
|
|
1020
1020
|
else packageEntry.objects.push({
|
|
1021
1021
|
simpleName: objectDef.exportName || extractSimpleClassName(className),
|
|
1022
1022
|
bindingName: registrationBindings.get(className) || extractSimpleClassName(className)
|
|
@@ -1032,7 +1032,7 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
|
|
|
1032
1032
|
const normalized = relative(configDir, objectDef.filePath).replace(/\\/g, "/").replace(/\.(ts|js|tsx|jsx)$/, "");
|
|
1033
1033
|
importPath = normalized.startsWith(".") ? normalized : `./${normalized}`;
|
|
1034
1034
|
} else importPath = getSvelteKitImportPath(projectRoot, void 0, options.objectsDir, simpleClassName);
|
|
1035
|
-
if (isCollectionManifestClass(manifest, objectDef)) {
|
|
1035
|
+
if (isCollectionManifestClass(manifest, objectDef) && !isCollectionActionResource(manifest, objectDef)) {
|
|
1036
1036
|
localSideEffectImports.add(importPath);
|
|
1037
1037
|
continue;
|
|
1038
1038
|
}
|
|
@@ -1082,6 +1082,25 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
|
|
|
1082
1082
|
filePath: relative(root, filePath).split(sep).join("/")
|
|
1083
1083
|
};
|
|
1084
1084
|
}
|
|
1085
|
+
const collectionMetadata = /* @__PURE__ */ new Map();
|
|
1086
|
+
const manifestKeys = new Map(Object.entries(manifest.objects).map(([key, value]) => [value, key]));
|
|
1087
|
+
for (const objectDef of Object.values(manifest.objects)) {
|
|
1088
|
+
if (!isCollectionManifestClass(manifest, objectDef)) continue;
|
|
1089
|
+
if (!isCollectionActionResource(manifest, objectDef)) continue;
|
|
1090
|
+
let current = objectDef;
|
|
1091
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1092
|
+
while (current && !seen.has(current) && isCollectionManifestClass(manifest, current)) {
|
|
1093
|
+
seen.add(current);
|
|
1094
|
+
const key = manifestKeys.get(current);
|
|
1095
|
+
if (key) collectionMetadata.set(key, portableObjectManifest(projectRoot, current));
|
|
1096
|
+
const parent = current.extendsQualified || current.extends;
|
|
1097
|
+
current = parent ? findManifestObjectByName(manifest, parent, current) : void 0;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const collectionMetadataLiteral = JSON.stringify(JSON.stringify({
|
|
1101
|
+
...manifest,
|
|
1102
|
+
objects: Object.fromEntries(collectionMetadata)
|
|
1103
|
+
}));
|
|
1085
1104
|
const registrationManifests = Object.fromEntries(Object.entries(manifest.objects).flatMap(([className, objectDef]) => {
|
|
1086
1105
|
if (isCollectionManifestClass(manifest, objectDef)) return [];
|
|
1087
1106
|
const packageName = getRegistrationPackageName(manifest, objectDef, isLocalObject(projectRoot, objectDef));
|
|
@@ -1112,6 +1131,12 @@ async function generateRegistrationFile(projectRoot, manifest, options) {
|
|
|
1112
1131
|
`});`
|
|
1113
1132
|
].join("\n");
|
|
1114
1133
|
}).filter((registration) => registration !== null).join("\n");
|
|
1134
|
+
const collectionRegistrations = Object.entries(manifest.objects).filter(([, objectDef]) => isCollectionActionResource(manifest, objectDef)).map(([className, objectDef]) => {
|
|
1135
|
+
const packageName = getRegistrationPackageName(manifest, objectDef, isLocalObject(projectRoot, objectDef));
|
|
1136
|
+
const qualifiedName = objectDef.qualifiedName || (packageName ? `${packageName}:${extractSimpleClassName(className)}` : className);
|
|
1137
|
+
const bindingName = registrationBindings.get(className) || extractSimpleClassName(className);
|
|
1138
|
+
return `ObjectRegistry.registerCollection(${JSON.stringify(qualifiedName)}, ${bindingName});`;
|
|
1139
|
+
}).join("\n");
|
|
1115
1140
|
const registrationContent = `/**
|
|
1116
1141
|
* Auto-generated SMRT object registration
|
|
1117
1142
|
* DO NOT EDIT - changes will be overwritten
|
|
@@ -1130,16 +1155,30 @@ const smrtRegistrationManifests = JSON.parse(${registrationManifestLiteral});
|
|
|
1130
1155
|
|
|
1131
1156
|
// Re-register imported objects with explicit package names for bundled runtimes
|
|
1132
1157
|
${registrations}
|
|
1133
|
-
|
|
1158
|
+
${collectionMetadata.size > 0 ? `
|
|
1159
|
+
// Metadata only: collection constructors were installed by their decorators.
|
|
1160
|
+
ObjectRegistry.registerPackageManifest(JSON.parse(${collectionMetadataLiteral}));
|
|
1161
|
+
// Preserve exact runtime identity alongside discovery metadata, without initialization.
|
|
1162
|
+
${collectionRegistrations}
|
|
1163
|
+
` : ""}`;
|
|
1134
1164
|
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
|
1135
1165
|
writeFileSync(registrationFilePath, registrationContent, "utf-8");
|
|
1136
1166
|
console.log(`[smrt] Generated registration file: ${registrationFilePath}`);
|
|
1137
1167
|
}
|
|
1168
|
+
/** Named HTTP CLI collection actions need metadata and a live receiver binding. */
|
|
1169
|
+
function isCollectionActionResource(manifest, objectDef) {
|
|
1170
|
+
if (!isCollectionManifestClass(manifest, objectDef)) return false;
|
|
1171
|
+
const cli = objectDef.decoratorConfig?.cli;
|
|
1172
|
+
if (!cli || typeof cli !== "object" || !Array.isArray(cli.include) || cli.http === false || cli.exclude !== void 0 && !Array.isArray(cli.exclude)) return false;
|
|
1173
|
+
const apiActions = resolveApiActionSet(objectDef, manifest);
|
|
1174
|
+
const cliActions = resolveCliActionSet(objectDef);
|
|
1175
|
+
return cli.include.some((name) => name !== "*" && apiActions.has(name) && cliActions.has(name));
|
|
1176
|
+
}
|
|
1138
1177
|
function buildRegistrationBindings(manifest) {
|
|
1139
1178
|
const keysBySimpleName = /* @__PURE__ */ new Map();
|
|
1140
1179
|
const reservedBindings = /* @__PURE__ */ new Set();
|
|
1141
1180
|
for (const [manifestKey, objectDef] of Object.entries(manifest.objects)) {
|
|
1142
|
-
if (isCollectionManifestClass(manifest, objectDef)) continue;
|
|
1181
|
+
if (isCollectionManifestClass(manifest, objectDef) && !isCollectionActionResource(manifest, objectDef)) continue;
|
|
1143
1182
|
const simpleName = extractSimpleClassName(manifestKey);
|
|
1144
1183
|
reservedBindings.add(simpleName);
|
|
1145
1184
|
const keys = keysBySimpleName.get(simpleName) ?? [];
|