@happyvertical/smrt-core 0.57.46 → 0.57.48
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.md +11 -3
- package/agents/browser-entry.md +16 -0
- package/agents/build-knowledge.md +58 -14
- package/agents/change-feed.md +20 -0
- package/agents/generators.md +69 -3
- package/agents/schema-paths.md +82 -0
- package/dist/_virtual/_rolldown/runtime.js +22 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -2
- package/dist/cascade.d.ts +46 -0
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +96 -7
- package/dist/cascade.js.map +1 -1
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +7 -4
- package/dist/change-feed.js.map +1 -1
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +3 -3
- package/dist/class.js.map +1 -1
- package/dist/consumer-plugin/index.d.ts +13 -0
- package/dist/consumer-plugin/index.d.ts.map +1 -1
- package/dist/consumer-plugin/index.js +69 -13
- package/dist/consumer-plugin/index.js.map +1 -1
- package/dist/consumer-plugin.js +2 -2
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +3 -3
- package/dist/database.js.map +1 -1
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +1 -4
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/dispatch/index.d.ts +1 -1
- package/dist/dispatch/index.d.ts.map +1 -1
- package/dist/dispatch/types.d.ts +22 -3
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +2 -2
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/custom-action.d.ts +11 -0
- package/dist/generators/custom-action.d.ts.map +1 -1
- package/dist/generators/custom-action.js +23 -4
- package/dist/generators/custom-action.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/lazy-config.d.ts +120 -3
- package/dist/lazy-config.d.ts.map +1 -1
- package/dist/lazy-config.js +81 -10
- package/dist/lazy-config.js.map +1 -1
- package/dist/manifest/generator.d.ts +8 -2
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +6 -5
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/index.d.ts +1 -0
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/index.js +2 -1
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/local-manifest-reader.d.ts +13 -2
- package/dist/manifest/local-manifest-reader.d.ts.map +1 -1
- package/dist/manifest/local-manifest-reader.js +38 -6
- package/dist/manifest/local-manifest-reader.js.map +1 -1
- package/dist/manifest/manifest-loader.d.ts +9 -8
- package/dist/manifest/manifest-loader.d.ts.map +1 -1
- package/dist/manifest/manifest-loader.js +67 -27
- package/dist/manifest/manifest-loader.js.map +1 -1
- package/dist/manifest/object-declarations.d.ts +116 -0
- package/dist/manifest/object-declarations.d.ts.map +1 -0
- package/dist/manifest/object-declarations.js +161 -0
- package/dist/manifest/object-declarations.js.map +1 -0
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +5 -3
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.d.ts.map +1 -1
- package/dist/manifest/store.js +18 -2
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest/test-artifacts.d.ts +57 -0
- package/dist/manifest/test-artifacts.d.ts.map +1 -0
- package/dist/manifest/test-artifacts.js +111 -0
- package/dist/manifest/test-artifacts.js.map +1 -0
- package/dist/manifest.js +2 -1
- package/dist/manifest.json +5 -3
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +11 -3
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/index.d.ts +2 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +3 -1
- package/dist/migrations/qualified-name-aliases.d.ts.map +1 -1
- package/dist/migrations/qualified-name-aliases.js +1 -1
- package/dist/migrations/qualified-name-aliases.js.map +1 -1
- package/dist/migrations.js +3 -1
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +2 -2
- package/dist/object.js.map +1 -1
- package/dist/postgres-permissions.d.ts.map +1 -1
- package/dist/postgres-permissions.js +4 -3
- package/dist/postgres-permissions.js.map +1 -1
- package/dist/postgres-timeouts.d.ts +30 -3
- package/dist/postgres-timeouts.d.ts.map +1 -1
- package/dist/postgres-timeouts.js +39 -2
- package/dist/postgres-timeouts.js.map +1 -1
- package/dist/prebuild/index.d.ts.map +1 -1
- package/dist/prebuild/index.js +14 -11
- package/dist/prebuild/index.js.map +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +24 -2
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/collection-exposure.d.ts +27 -0
- package/dist/registry/collection-exposure.d.ts.map +1 -1
- package/dist/registry/collection-exposure.js +74 -1
- package/dist/registry/collection-exposure.js.map +1 -1
- package/dist/registry/collection-resolution.d.ts.map +1 -1
- package/dist/registry/collection-resolution.js +1 -0
- package/dist/registry/collection-resolution.js.map +1 -1
- package/dist/registry/framework-base-classes.d.ts.map +1 -1
- package/dist/registry/framework-base-classes.js +5 -0
- package/dist/registry/framework-base-classes.js.map +1 -1
- package/dist/registry/schema-builder.d.ts +8 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +17 -4
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +12 -0
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +2 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +30 -10
- package/dist/registry.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +10 -8
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +29 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/generator.d.ts +32 -0
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +59 -0
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +4 -2
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/scripts/browser-unbundled-dev.mjs +90 -0
- package/dist/scripts/generate-test-manifest.js +9 -49
- package/dist/smrt-knowledge.json +16 -16
- package/dist/system/compatibility.d.ts.map +1 -1
- package/dist/system/compatibility.js +12 -4
- package/dist/system/compatibility.js.map +1 -1
- package/dist/system/schema.d.ts +70 -8
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +231 -24
- package/dist/system/schema.js.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +14 -2
- package/dist/testing/database.js.map +1 -1
- package/dist/tools/tool-generator.d.ts.map +1 -1
- package/dist/tools/tool-generator.js +2 -2
- package/dist/tools/tool-generator.js.map +1 -1
- package/dist/utils/import-workspace-module.d.ts.map +1 -1
- package/dist/utils/import-workspace-module.js +23 -0
- package/dist/utils/import-workspace-module.js.map +1 -1
- package/dist/utils/naming.js +5 -3
- package/dist/utils/naming.js.map +1 -1
- package/dist/vendor/pluralize/pluralize.js +421 -0
- package/dist/vendor/pluralize/pluralize.js.map +1 -0
- package/dist/vite-plugin/api-client-entries.d.ts +5 -1
- package/dist/vite-plugin/api-client-entries.d.ts.map +1 -1
- package/dist/vite-plugin/api-client-entries.js +11 -6
- package/dist/vite-plugin/api-client-entries.js.map +1 -1
- package/dist/vite-plugin/consumer-import.d.ts +28 -0
- package/dist/vite-plugin/consumer-import.d.ts.map +1 -0
- package/dist/vite-plugin/consumer-import.js +42 -0
- package/dist/vite-plugin/consumer-import.js.map +1 -0
- package/dist/vite-plugin/dev-plane-route.d.ts +6 -1
- package/dist/vite-plugin/dev-plane-route.d.ts.map +1 -1
- package/dist/vite-plugin/dev-plane-route.js +8 -9
- package/dist/vite-plugin/dev-plane-route.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +12 -9
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/resources-route.d.ts.map +1 -1
- package/dist/vite-plugin/resources-route.js +3 -8
- package/dist/vite-plugin/resources-route.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +7 -11
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/sync-apply-route.d.ts +16 -0
- package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
- package/dist/vite-plugin/sync-apply-route.js +48 -6
- package/dist/vite-plugin/sync-apply-route.js.map +1 -1
- package/package.json +12 -12
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,24 +3,24 @@
|
|
|
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.48",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
12
|
-
"agents": "
|
|
10
|
+
"manifest": "66c456aa82191541f1194dd24fab0b386385a3c623917a6b02db43a4b1bafdbb",
|
|
11
|
+
"packageJson": "22cd036d8e162d96391099743c5791051b08a7a6926c060334947b7e6af3040e",
|
|
12
|
+
"agents": "796e85343e7361fcb0874e904e709190189ffae4ca644d90d59b1bd2d1384b40",
|
|
13
13
|
"moduleDoc:agents/object-runtime.md": "fd2748649e820c2fe5960fa4a44c5f3e2a5afa57da89925e8b4cb3b53d67a07b",
|
|
14
14
|
"moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
|
|
15
15
|
"moduleDoc:agents/collection-reads.md": "39acea7faf3505d38bfb999e414f4c4c6f964ec7c3d19cc52993302c29223029",
|
|
16
16
|
"moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
|
|
17
17
|
"moduleDoc:agents/data-query.md": "726ef89560d4143d03bfc26943a993ecaa0b42b47e64e861d9de08ee17f1981b",
|
|
18
|
-
"moduleDoc:agents/schema-paths.md": "
|
|
18
|
+
"moduleDoc:agents/schema-paths.md": "e45d09450fe1ea680f0810d53aa27044650f3e74e473e813e8d6c0184795345b",
|
|
19
19
|
"moduleDoc:agents/deployment-phases.md": "e1036073f31d54db95de95e498c74cddce5e47f367d56ccc00aad69f4e5a28e1",
|
|
20
|
-
"moduleDoc:agents/change-feed.md": "
|
|
20
|
+
"moduleDoc:agents/change-feed.md": "bdffe7934683dba6c71108ea3c22a2528ef2ab06971119a60696d9cc2979875c",
|
|
21
21
|
"moduleDoc:agents/change-signals.md": "a2dbe7077483b5a19bd5ea9589b5a08fe847377c8e05d01d57f813c6d9cbc28b",
|
|
22
|
-
"moduleDoc:agents/generators.md": "
|
|
23
|
-
"moduleDoc:agents/build-knowledge.md": "
|
|
22
|
+
"moduleDoc:agents/generators.md": "e1de88e8486534e0d70ddd578be13a73d4296a5c337ab2db74cd46b565092667",
|
|
23
|
+
"moduleDoc:agents/build-knowledge.md": "999cd57a28a521f2931d67dfb76eb35386aec6ad21a9642ccf6ba3f80e061961",
|
|
24
24
|
"moduleDoc:agents/memory.md": "a86bdf370403d08ecad3be86e341eb322dfe6e6b7a4e8806a6f8be9bf3421da5",
|
|
25
25
|
"moduleDoc:agents/system-diagnostics.md": "b7ccae18ee312bf788d5470f95fa78d3535f1789247d5372e40ca86538290772",
|
|
26
26
|
"moduleDoc:agents/registry-snapshot.md": "6234aa540427396d1dda1825a4641b1072e247187d5ddf413ae46c691344ee30",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"moduleDoc:agents/recipes.md": "528dfe91b1e54d210bfbfa4d7de3420e0e897d55954c678089413851357e4275",
|
|
30
30
|
"moduleDoc:agents/cookbook.md": "20c256f4f6219f9b2add6365ef930e0bb3eda37e7866ddbc4bdd466830f76e4f",
|
|
31
31
|
"moduleDoc:agents/cookbook-engine.md": "f8e6869021b6cc214fe34da20b73a83509abc10bab71299ea9668b47a1c4307d",
|
|
32
|
-
"moduleDoc:agents/browser-entry.md": "
|
|
32
|
+
"moduleDoc:agents/browser-entry.md": "853fe495cdb9f28647954151a7e4a9e0844cc0fdeb2552391014581e5a0ad9be",
|
|
33
33
|
"moduleDoc:agents/test-types.md": "37c009ae2bd21001d83bba6cfab2047465fe32d5443de12661e24ba7d4e2d613"
|
|
34
34
|
},
|
|
35
35
|
"exports": [
|
|
@@ -86,7 +86,6 @@
|
|
|
86
86
|
"import-meta-resolve": "4.2.0",
|
|
87
87
|
"minimatch": "10.2.6",
|
|
88
88
|
"oxc-transform": "0.150.0",
|
|
89
|
-
"pluralize": "^8.0.0",
|
|
90
89
|
"tsx": "^4.23.13",
|
|
91
90
|
"yaml": "^2.9.1",
|
|
92
91
|
"@faker-js/faker": "^10.6.0",
|
|
@@ -97,6 +96,7 @@
|
|
|
97
96
|
"@types/pluralize": "^0.0.33",
|
|
98
97
|
"@xenova/transformers": "^2.17.2",
|
|
99
98
|
"ajv": "8.20.0",
|
|
99
|
+
"pluralize": "^8.0.0",
|
|
100
100
|
"reflect-metadata": "0.2.2",
|
|
101
101
|
"svelte": "^5.57.1",
|
|
102
102
|
"svelte-check": "^4.7.6",
|
|
@@ -1148,7 +1148,7 @@
|
|
|
1148
1148
|
"polymorphicAssociations": 1,
|
|
1149
1149
|
"uuidColumns": 3
|
|
1150
1150
|
},
|
|
1151
|
-
"agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts`, `src/unchanged-indexed.ts`, `src/native-duckdb.ts` | Guarded writes, PostgreSQL revision precision, unchanged indexed columns on referenced DuckDB rows (#3737) | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/migrations/deployment-phase-tracker.ts` | Permanent checksummed PostgreSQL deployment steps, atomic markers and explicit external-effect reconciliation | [agents/deployment-phases.md](agents/deployment-phases.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |\n| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |\n| `src/recipe.ts`, `../scanner/src/recipes.ts` | `SmrtRecipe`: declared user-facing units (models, nav, `requires`, curation `options`) collected into manifest and knowledge `recipes` (#3590) | [agents/recipes.md](agents/recipes.md) |\n| `src/cookbook/` | Cookbook v1 document: browser-safe validate/migrate, JSON Schema (`./cookbook`, `./cookbook/v1.schema.json`; #3748) | [agents/cookbook.md](agents/cookbook.md) |\n| `src/cookbook/engine/` | Headless cookbook command engine: typed commands, idempotent retries, atomic batches, receipts, tool schemas (`./cookbook/engine`; #3753) | [agents/cookbook-engine.md](agents/cookbook-engine.md) |\n| `src/browser.ts`, `src/host.ts`, `src/host.browser.ts` | Browser entry that builds and evaluates without Node-only modules: host seam, `package.json#browser` swaps, `pnpm test:browser` (#2838) | [agents/browser-entry.md](agents/browser-entry.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Any output that names a database URL (CLI banners, logs, errors) renders it\n with `redactDatabaseUrl` / `redactDatabaseUrlsInText`\n (`src/utils/database-url.ts`, #3527): never the password or query string,\n and never the raw string when it cannot be parsed.\n- `SmrtError` lives in import-free `src/smrt-error.ts`, published as\n `./smrt-error` (#4100) for browser-safe code that must not load the root\n entry; `errors.ts` re-exports that one class. Keep it import-free.\n- Per-class registry work on the hydration path (manifest reconciliation in\n `ensureManifestLoaded()`, the simple-name index behind `findClass()`) is\n memoized per registry generation (`src/registry/generation.ts`, #3047). Any\n new writer of registered classes, their field/method metadata, or loaded\n manifests must go through the tracked maps or call\n `bumpRegistryGeneration()`; `issue-3047-hydration-registry-work.test.ts`\n guards that hydration does not repeat this work per row.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\n- Owning `CollectionInterceptor.writeGuard` predicates must reach final writes,\n including natural-key adoption and deletes; zero matched rows fail closed.\n See [object runtime](agents/object-runtime.md).\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses a transaction-scoped advisory lock, taken only when the\n PostgreSQL schema version is not yet stamped: inside a caller's transaction it\n is held until that transaction ends (#2868). Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames,\n and drops a frame's Vite `?v=` query (#3490). Library builds stamp each\n decorated class with `static __smrtPackage__` (own property, read at\n decoration as its explicit package) and publish `stampsConstructors`; an\n unstamped class never takes a stamping package's entry by name and table,\n in either order (#3490, `issue-3490-*.test.ts`, `package-stamp.test.ts`).\n Qualified names are `@scope/pkg:Class`; the scope is a deliberate\n discriminator, so manifest generation (`generateManifest`,\n `applyGenerationPasses`, hence `pnpm build`) throws\n `CONFIG_UNSCOPED_PACKAGE_NAME` for an unscoped `package.json` name (#3408).\n Instance and collection registry lookups pass the qualified name. A parent\n imported from another scoped package is emitted as `extendsQualified`\n (scanner traces named, aliased, and namespace imports; `extends` stays the\n simple name), kept only when that package's manifest declares it\n (`verifyImportedParents`); decorator registration takes the parent from the\n real constructor's registration, which a stale simple manifest parent never\n overwrites (#4145, `issue-4145-*.test.ts`). The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.spec.ts`.\n- A collection class never publishes more than its model: unset surfaces\n inherit the model's, and the build rejects a collection that opens a CRUD\n write its model closes. See `agents/generators.md` (Collection exposure).\n- A model subclass (STI child or CTI subclass) never publishes more than its\n parent model: unset surfaces and `sensitive` inherit the parent's, and the\n build rejects a subclass that widens them unless it names each operation in\n `widenParentExposure`. See `agents/generators.md` (Subclass exposure).\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- `src/browser.ts` is the `browser`-condition entry of the package root: the\n Node entry (`index.ts`) minus HTTP servers, manifest/knowledge discovery, run-once,\n the Vite plugin, and the test helper. A new export added to `index.ts` must also\n go to `browser.ts` unless it is Node-only; `src/__tests__/browser-entry-parity.test.ts`\n fails otherwise (allowlist: `NODE_ONLY_GROUPS` in that test, each with a reason; the browser\n entry may not publish a name the Node entry lacks), and also\n fails when a workspace package imports a root value the browser entry lacks.\n- `exports['.'].browser` (`dist/browser.js`) is the browser surface. Model\n package roots are bundled for browsers by the bundle-gate browser fixture\n (#3621): anything a model root imports from core must be exported by the\n browser entry and must not reach `node:` built-ins, `pg`, `cosmiconfig`, or\n `jiti`. Keep Node-only code behind a subpath or a lazy import; when a fix\n makes a package pass, delete its entry in\n `packages/bundle-gate/src/browser-gate/expected-failures.ts`.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\n`typecheck` checks production source first, then the test project in\n`tsconfig.test.json`. Test coverage and the shared relaxed config\ncontract are documented in [agents/test-types.md](agents/test-types.md).\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
|
|
1151
|
+
"agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts`, `src/unchanged-indexed.ts`, `src/native-duckdb.ts` | Guarded writes, PostgreSQL revision precision, unchanged indexed columns on referenced DuckDB rows (#3737) | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/migrations/deployment-phase-tracker.ts` | Permanent checksummed PostgreSQL deployment steps, atomic markers and explicit external-effect reconciliation | [agents/deployment-phases.md](agents/deployment-phases.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |\n| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.md) |\n| `src/recipe.ts`, `../scanner/src/recipes.ts` | `SmrtRecipe`: declared user-facing units (models, nav, `requires`, curation `options`) collected into manifest and knowledge `recipes` (#3590) | [agents/recipes.md](agents/recipes.md) |\n| `src/cookbook/` | Cookbook v1 document: browser-safe validate/migrate, JSON Schema (`./cookbook`, `./cookbook/v1.schema.json`; #3748) | [agents/cookbook.md](agents/cookbook.md) |\n| `src/cookbook/engine/` | Headless cookbook command engine: typed commands, idempotent retries, atomic batches, receipts, tool schemas (`./cookbook/engine`; #3753) | [agents/cookbook-engine.md](agents/cookbook-engine.md) |\n| `src/browser.ts`, `src/host.ts`, `src/host.browser.ts` | Browser entry that builds and evaluates without Node-only modules: host seam, `package.json#browser` swaps, `pnpm test:browser` (#2838) | [agents/browser-entry.md](agents/browser-entry.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Any output that names a database URL (CLI banners, logs, errors) renders it\n with `redactDatabaseUrl` / `redactDatabaseUrlsInText`\n (`src/utils/database-url.ts`, #3527): never the password or query string,\n and never the raw string when it cannot be parsed.\n- `SmrtError` lives in import-free `src/smrt-error.ts`, published as\n `./smrt-error` (#4100) for browser-safe code that must not load the root\n entry; `errors.ts` re-exports that one class. Keep it import-free.\n- Per-class registry work on the hydration path (manifest reconciliation in\n `ensureManifestLoaded()`, the simple-name index behind `findClass()`) is\n memoized per registry generation (`src/registry/generation.ts`, #3047). Any\n new writer of registered classes, their field/method metadata, or loaded\n manifests must go through the tracked maps or call\n `bumpRegistryGeneration()`; `issue-3047-hydration-registry-work.test.ts`\n guards that hydration does not repeat this work per row.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\n- Owning `CollectionInterceptor.writeGuard` predicates must reach final writes,\n including natural-key adoption and deletes; zero matched rows fail closed.\n See [object runtime](agents/object-runtime.md).\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses a transaction-scoped advisory lock, taken only when the\n PostgreSQL schema version is not yet stamped: inside a caller's transaction it\n is held until that transaction ends (#2868). Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames,\n and drops a frame's Vite `?v=` query (#3490). Library builds stamp each\n decorated class with `static __smrtPackage__` (own property, read at\n decoration as its explicit package) and publish `stampsConstructors`; an\n unstamped class never takes a stamping package's entry by name and table,\n in either order (#3490, `issue-3490-*.test.ts`, `package-stamp.test.ts`).\n Qualified names are `@scope/pkg:Class`; the scope is a deliberate\n discriminator, so manifest generation (`generateManifest`,\n `applyGenerationPasses`, hence `pnpm build`) throws\n `CONFIG_UNSCOPED_PACKAGE_NAME` for an unscoped `package.json` name (#3408).\n Instance and collection registry lookups pass the qualified name. A parent\n imported from another scoped package is emitted as `extendsQualified`\n (scanner traces named, aliased, and namespace imports; `extends` stays the\n simple name), kept only when that package's manifest declares it\n (`verifyImportedParents`); decorator registration takes the parent from the\n real constructor's registration, which a stale simple manifest parent never\n overwrites (#4145, `issue-4145-*.test.ts`). The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. The scanner\n stamps each collection entry with `collectionOf` (its model) and the\n registry's collection classifier trusts it first, so a collection stays a\n non-owner even when runtime cannot walk its parent or holds another core\n copy's `SmrtCollection` (#3956, `issue-3956-*.test.ts`). Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.spec.ts`.\n- A collection class never publishes more than its model: unset surfaces\n inherit the model's, and the build rejects a collection that opens a CRUD\n write its model closes. Inherited surfaces (`inheritedExposure`) keep\n following the model at runtime and collection CRUD is capped to the model's,\n so closing a model closes its collection's tools (#3468). See\n `agents/generators.md` (Collection exposure).\n- A model subclass (STI child or CTI subclass) never publishes more than its\n parent model: unset surfaces and `sensitive` inherit the parent's, and the\n build rejects a subclass that widens them unless it names each operation in\n `widenParentExposure`. See `agents/generators.md` (Subclass exposure).\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Custom-action schemas and decoders read\n `declaredParameterType()`, not `parameter.type` (#4327). Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- `src/browser.ts` is the `browser`-condition entry of the package root: the\n Node entry (`index.ts`) minus HTTP servers, manifest/knowledge discovery, run-once,\n the Vite plugin, and the test helper. A new export added to `index.ts` must also\n go to `browser.ts` unless it is Node-only; `src/__tests__/browser-entry-parity.test.ts`\n fails otherwise (allowlist: `NODE_ONLY_GROUPS` in that test, each with a reason; the browser\n entry may not publish a name the Node entry lacks), and also\n fails when a workspace package imports a root value the browser entry lacks.\n- `exports['.'].browser` (`dist/browser.js`) is the browser surface. Model\n package roots are bundled for browsers by the bundle-gate browser fixture\n (#3621): anything a model root imports from core must be exported by the\n browser entry and must not reach `node:` built-ins, `pg`, `cosmiconfig`, or\n `jiti`. Keep Node-only code behind a subpath or a lazy import; when a fix\n makes a package pass, delete its entry in\n `packages/bundle-gate/src/browser-gate/expected-failures.ts`.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\n`typecheck` checks production source first, then the test project in\n`tsconfig.test.json`. Test coverage and the shared relaxed config\ncontract are documented in [agents/test-types.md](agents/test-types.md).\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
|
|
1152
1152
|
"moduleDocs": [
|
|
1153
1153
|
{
|
|
1154
1154
|
"path": "agents/object-runtime.md",
|
|
@@ -1178,7 +1178,7 @@
|
|
|
1178
1178
|
{
|
|
1179
1179
|
"path": "agents/schema-paths.md",
|
|
1180
1180
|
"module": "schema-paths",
|
|
1181
|
-
"content": "# smrt-core/schema paths\n\nModule semantics for `src/schema/` — which `SchemaGenerator` entry point reaches\na real database, what each one emits, and the rules that keep them in step.\nPackage orientation, the cross-module invariants, and the traps that apply\nbefore editing anything live in [../AGENTS.md](../AGENTS.md) — read that first;\nit links the relevant runtime and generation contracts.\n\n## Four entry points, two of which ship\n\n`src/schema/generator.ts` exposes four index-emitting entry points. Their columns and indexes must agree for the same class.\n\n| Entry point | Selected by | Status |\n|---|---|---|\n| `generateSTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |\n| `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |\n\nProduction DDL takes the manifest route:\n\n```\n@smrt() class ─▶ scanner ─▶ manifest.json ─▶ generate{STI,CTI}SchemaFromManifest\n ─▶ registered `schema` ─▶ ObjectRegistry.getAllSchemasAsDefinitions()\n ├─▶ smrt db:migrate | db:diff | db:status\n │ (the CLI drives SchemaComparer + MigrationTracker directly)\n └─▶ migrateSmrtSchemas() / getPendingSchemaStatements()\n (src/migrations/orchestrate.ts — exported for programmatic\n use; no in-repo caller outside its own tests)\n```\n\nSince #2359 the two families share one set of index helpers and\n`src/schema/schema-path-parity.test.ts` runs the same fixture manifest through\nthe manifest paths, through `ObjectRegistry.registerFromManifest()` + the\nregistry paths, and through `getAllSchemasAsDefinitions()`, asserting identical\ncolumn and index sets. Extend that fixture with every generator change; a\ndivergence is a bug in the generator, not an exception to add to the test.\n\n### Index rules (#2359)\n\n- **Reference columns are always indexed.** `ensureReferenceColumnIndexes()`\n runs last on every path and gives each `@foreignKey`, `@crossPackageRef` and\n tenant column `<table>_<column>_idx` unless an UNQUALIFIED index (no `WHERE`,\n no JSON path) already leads with it — the `conflictColumns` unique index or an\n `indexed: true` opt-in, or the column's own inline UNIQUE. A partial\n `WHERE _meta_type = …` index does not count: base-class polymorphic queries\n carry no discriminator predicate. `indexed: true` on a reference column is\n redundant. Roll the index wave out to production with\n `smrt db:migrate --postgres-safe` (concurrent-index mode, #2362): a plain\n atomic batch takes SHARE/ACCESS EXCLUSIVE locks for ~230 index builds. STI FK indexes are plain, one per\n column, not per-class partial.\n- **No index on the primary key.** `<table>_id_idx` is gone from every path,\n and `conflictColumns` equal to the PK column set emit no conflict index\n (`ON CONFLICT (id)` binds to the PK constraint). `SchemaComparer` drops the\n legacy non-unique single-column PK index from existing databases without\n `--drop-indexes` when the live table reports that column as its sole primary\n key (never a UNIQUE one — on PostgreSQL that may back a custom-named PRIMARY\n KEY constraint, and `DROP INDEX` on it would fail the atomic batch).\n- **Slug loading keeps its index.** Custom `conflictColumns` replace the\n `(slug, context)` unique index; `loadFromSlug()`/`getId()`/`getSavedId()`\n still filter on slug/context, so a plain `<table>_slug_context_idx` is kept\n (additive; routing those lookups through the conflict key would change which\n row a slug resolves to). The tenant-led default key below counts as serving\n it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant\n predicate (#2365) and is served by the prefix, so no second index.\n- **Tenant default keys** are `(tenant_id, slug, context)`, plus `_meta_type`\n for STI. `ManifestGenerator.normalizeConflictColumns()` and\n `ObjectRegistry.getConflictColumns()` share `src/schema/conflict-target.ts`:\n resolve tenant fields through the schema owner/STI root, report group/bucket\n columns through the report, and custom PKs through their key. Explicit\n `conflictColumns` remain unchanged. The manifest, schema, knowledge, and\n runtime must carry the same value.\n Names remain `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so\n migration replaces a same-name global unique with tenant-led columns. That\n prefix serves tenant and tenant-scoped slug reads; a legacy standalone tenant\n index is dropped only with `--drop-indexes`.\n- **Widening an explicit key** (e.g. `['key']` → `['tenant_id', 'key']`,\n #3126) renames its index, so migration adds the new unique and\n `SchemaComparer` drops the superseded one without `--drop-indexes`: a UNIQUE\n index named `conflictIndexName()` for its own columns, with no predicate,\n whose columns are a strict subset of a declared UNIQUE index. It is a pure\n relaxation (every row satisfies the wider key) and otherwise would keep\n rejecting rows the model allows. The maintenance-window rule below applies.\n- **Optional NULL tenants** retain the SDK's NULL-equal upsert identity. Generated\n framework conflict indexes with nullable keys carry `nullsNotDistinct: true`;\n PostgreSQL 15+ creates `UNIQUE NULLS NOT DISTINCT`, enabling warm single-statement\n native upserts. Earlier PostgreSQL, SQLite and DuckDB retain adapter fallback\n semantics. Existing ordinary indexes are upgraded only through the explicit\n [NULL-equal maintenance migration](null-equal-indexes.md). Optional business\n unique fields remain NULLS DISTINCT; this marker is not inferred from them.\n- **Tenant-key rollout requires a maintenance window.** Old code/new indexes\n and new code/old indexes both fail new-object saves because conflict column\n sets must match exactly; persisted ID-based saves still work. Backfill legacy\n NULL tenants first or scoped ingestion creates separate rows and cannot see\n the old global ones. Cross-tenant natural-key dedup now creates one row per\n tenant. Deploy code and migrate together in atomic mode: each table drops\n and recreates its same-name unique index, holding ACCESS EXCLUSIVE locks\n (including against reads) until commit. Size `statementTimeout` for the\n largest table. A valid old subset unique guarantees the superset build;\n missing/nonunique old indexes may contain duplicates and need dedup first.\n Include the reference-index wave in that atomic window. `--postgres-safe` is\n suitable for an additive reference-index-only wave, but a key replacement\n leaves a per-table gap between drop/build and a failed build leaves no arbiter\n until rerun. There is no automatic DOWN; reverting code requires deliberately\n recreating its old indexes.\n\n- **STI `@field({ unique: true })` is enforced through indexes** (the differ can\n add an index to an existing table, never a column constraint): a full\n `<table>_<col>_unique_idx` when the STI base declares it, one\n `<table>_<col>_<class>_unique_idx WHERE _meta_type = '<qualified>'` per class\n when only descendants do — uniqueness per concrete class, not across the\n subtree. DuckDB/JSON have no partial indexes, so the descendant-scoped shape\n (`isStiSubtypeUniqueIndex`) is not emitted there — degrading it to a full\n UNIQUE would constrain every subtype; the DDL strategy and the differ both\n skip it, while other partial indexes keep degrading to full ones as before. Remember the\n framework serializes an unset text field as `''`, so a unique optional text\n field must be `nullable: true` with a `null` initializer or every unset row\n collides.\n- **Every class in an STI hierarchy carries the schema of the one shared\n table**, generated from the root base (`ManifestGenerator.generateSchemas()`\n resolves the root through `findSTIBaseInfo`), so a child never treats its own\n descendant-only unique field as base-declared.\n\n`src/schema/utils.ts` sits in between, and the two exports differ:\n\n- `generateSchema()` (reached from `SmrtCollection.generateSchema()`) always\n rebuilds from the registry and writes the result back into the registry,\n replacing whatever the manifest registered for that class.\n- `ensureSchema()` (reached from the deprecated `smrt db:setup`) is\n manifest-first: it takes `ObjectRegistry.getSchema()` plus the merged\n `getAllSchemasAsDefinitions()` table definition, and only falls back to\n `generateSchema()` when no schema is registered at all.\n\n## Verification\n\nExtend `src/schema/schema-path-parity.test.ts` for every generator change;\nmanifest, registry, and merged migration schemas must agree. Inspect regenerated\n`dist/manifest.json` and schemas across affected packages, not only decorators.\nRuntime `verifyPersistenceTable()` checks table existence only. Database drift\nchecks compare with generated artifacts; they cannot detect an omission shared\nby those artifacts. Use `smrt doctor --db` / `db:status --parity` for live parity.\n\nEvery new query predicate needs its index or an explicit reason none is needed.\nRun `pnpm --filter @happyvertical/smrt-core test:postgres` for numeric types,\nUUID casts, conflict targets, timestamps, or migrations. Schema-affecting options\nmust reach `SchemaGeneratorConfig` and both config rebuild sites:\n`src/schema/utils.ts` and `src/testing/database.ts`.\n\nTenant uniqueness and conflict targets must include the tenant column; explicit\n`conflictColumns` are author-owned and never rewritten. All reads, including\nhydration, slug lookup, vector search, and memory, remain interceptor-aware.\nRetry only transient errors classified through the cause chain; never retry an\naborted PostgreSQL transaction (`25P02`).\n\n### Composite indexes are declared, not inferred (#2357)\n\nThe generated set only covers foreign keys, unique/conflict columns, the STI\ndiscriminator, reference columns (#2359), default list ordering, and single columns opted in with `@field({ indexed: true })`. A list\nworkload's access path is composite, so declare it:\n\n```ts\n@smrt({\n indexes: [\n { name: 'contents_tenant_id_publish_date_idx',\n columns: ['tenantId', 'publish_date'] },\n ],\n})\n```\n\n`columns` takes field names or column names in access-path order — filter\ncolumns first, sort column last. Declare columns, not a direction: PostgreSQL\nscans a btree either way, so an ascending index also serves the matching\n`ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`\n(partial index) are honoured.\n\n`appendDeclaredIndexes()` runs first on all four entry points, ahead of\n`ensureDefaultListOrderingIndex()` (default ordering below) and `ensureReferenceColumnIndexes()`,\nso a declared composite leading with the tenant column (or any reference column)\nreplaces the automatic standalone index rather than duplicating it.\nUnknown columns, malformed entries, and a name collision with a different index\nall fail generation — a silently dropped index only surfaces later as a\nproduction slowdown. Keep both config rebuild sites aligned.\n\n### Relationship targets resolve to a class name on both paths\n\n`@foreignKey`/`@oneToMany`/`@manyToMany` accept a class, a name string, or a\n`() => Target` thunk. The decorator invokes the thunk and throws when the target\ncannot be resolved (never `related: ''`); the scanner unwraps the same thunk\nfrom raw source (never `related: '() => Target'`). An unresolved target silently\ncosts the relationship edge, `loadRelated()`, and the FK-derived index (#2379).\nA thunk resolves at decoration time, so a target declared later in the same\nmodule is still in its temporal dead zone — use the string form there.\nRuntime decorators retain the resolved constructor alongside the display name.\nInverse lookup and cascade planning resolve that constructor when queried, so\nthe target may register after its child without binding to a same-name class\nin another package. String targets prefer the declaring package; ambiguous\nunqualified targets never match an unrelated package's inverse edge.\nRegistry schema generation and the merged migration schema resolve the same\ncanonical target before selecting both its table and identifier type. The\nmanifest generator resolves unqualified targets in the declaring package.\nKeep the merged schema in parity tests: `getTestDatabase()` preserves its\nauthoritative FK metadata over the generated columns, so testing only the\nstandalone generator cannot prove the constraint that reaches the database.\n\nField decorator registration retains constructor ownership separately from the\nsimple-name inspection view. Custom decorators should pass the callback's\nconstructor as `registerFieldDecorator`'s fourth argument. Omit it only for\nintentional legacy string-only metadata; that legacy metadata still composes\nwith constructor-owned options, but mirrored public decorator fields do not.\n\n### A SQLite type change is a table rebuild (#2370)\n\nSQLite has no `ALTER TABLE ... ALTER COLUMN ... TYPE`, so\n`src/migrations/sqlite-rebuild.ts` answers a `type_upgrade` on SQLite with the\nstatement list SQLite's own docs prescribe: stage a new table under\n`_smrt_rebuild_<table>`, copy, drop, rename, replay the indexes and triggers.\n`SchemaComparer.compareTable` swaps that plan in for the differ's\n\"requires table recreation\" placeholder, so `db:migrate` applies it inside the\nnormal atomic batch instead of exiting 1 forever.\n\nFour properties of that module are load-bearing; keep them if you touch it:\n\n- **The target shape comes from the live `sqlite_master` DDL**, retyping only\n the drifted columns. It is not regenerated from the manifest, so the rebuild\n never becomes an implicit `DROP COLUMN`, and it preserves table constraints,\n `CHECK`s, and `WITHOUT ROWID`/`STRICT`.\n- **The rebuild is hoisted ahead of the table's other column changes.** Its\n staging DDL and copy list are captured at diff time, and the differ emits\n changes in manifest field order, so a new field declared above the retyped\n one would otherwise run `ALTER TABLE ... ADD COLUMN` first and have the\n rebuild silently drop it — both statements succeed and the batch commits.\n Rebuild first, then add columns to the rebuilt table.\n- **The copy carries no `CAST`.** SQLite applies the destination column's\n affinity on insert — the same conversion a fresh table performs. An explicit\n cast is worse: non-numeric TEXT cast to REAL/INTEGER silently becomes `0`,\n and an ISO timestamp cast to NUMERIC-affinity `DATETIME` becomes its year.\n- **It refuses when any table has a foreign key onto the target and\n `PRAGMA foreign_keys` is ON** (the SMRT adapter's default). `DROP TABLE`\n performs an implicit `DELETE FROM` that fires `ON DELETE CASCADE` on\n children, and `defer_foreign_keys` defers constraint *checks*, not FK\n *actions* — verified: the child rows go. The target's own self-reference\n counts, because the staging table copies that clause and becomes a child of\n the table being dropped (verified: a two-row self-referencing table finishes\n the rebuild holding one row). Such a column stays manual drift.\n- **`PRAGMA legacy_alter_table` brackets the rename**, because SQLite ≥ 3.25\n re-parses the schema on `ALTER TABLE ... RENAME` and a view still pointing at\n the just-dropped table makes it fail outright. It is restored immediately\n after; a rolled-back batch leaves it set on that connection, which is inert\n here only because nothing else in SMRT renames a table.\n\nAll the drifted columns of one table share a single rebuild: the first change\ncarries the plan and the rest become `no change needed` comments that the CLI\nclassifies as no-ops.\n\n### Explicit SQLite NOT NULL relaxation (#4413)\n\n`relaxColumns: true` / `smrt db:migrate --relax-columns` also supports removing\nan ordinary inline `NOT NULL` clause on a manifest-declared column. The local\napplication entry point forwards `smrt app migrate --relax-columns` under its\nexisting operation lock and refuses a live application writer. The programmatic\n`migrateSmrtSchemas` / `getPendingSchemaStatements` entry points accept the same\nexplicit `relaxColumns` option. Defaults remain advisory; this does not relax\norphan columns or change SQLite defaults/tightening behavior.\n\nThe rebuild preserves live DDL, indexes/triggers and assigned rowids. Named\nNOT NULL constraints, `ON CONFLICT`, AUTOINCREMENT tables, primary/generated target columns, missing\nclauses and tables shadowing every rowid alias refuse rather than guessing.\nA nullable-relaxation rebuild emits `PRAGMA foreign_keys = OFF` as a tracker\nhandoff: `MigrationTracker` suspends FK enforcement **before** opening its\ntransaction, verifies suspension on both the base and transaction connection,\nand checks `PRAGMA foreign_key_check` before commit. This avoids inbound FK\n`ON DELETE` actions and preserves referencing rows. An active borrowed transaction\nthat cannot suspend enforcement, a mismatched transaction connection or an\nadapter without transactions refuses. FK and legacy rename settings are restored\nin `finally`, including after rollback. Run this maintenance operation with\nexclusive access to the SQLite connection. Type-only rebuilds retain the older\ninbound-FK refusal described above.\n\n## What the differ compares (#2369)\n\n`SchemaComparer` (`src/migrations/differ.ts`) compares each manifest column's\ntype, then — unless the type itself is drifting — its nullability and default,\nand always reports what it will not touch:\n\n- **Strengthening** (`SET NOT NULL`, `SET DEFAULT`) is executable on\n PostgreSQL/DuckDB. `SET NOT NULL` is preceded by an `UPDATE … WHERE c IS NULL`\n backfill of the manifest default; without a default the live data is probed\n and, if NULLs exist, the change is reported (comment SQL + `advisory`) instead\n of emitting an ALTER that would abort the atomic batch.\n- **Relaxing** (`DROP NOT NULL`, `DROP DEFAULT`) is a report-only advisory until\n the caller passes `relaxColumns` (`db:migrate --relax-columns`). The manifest\n can be under-specified (#2372 registration-order weakness), so a live column\n that is stricter than the manifest is never weakened silently.\n- **Orphans** — DB columns absent from the manifest, DB tables no manifest\n declares (`SchemaDiff.orphan_tables`), and unclaimed `*_key` unique constraint\n indexes — are always reported. A NOT NULL orphan without a default is a\n `warning` advisory (every ORM insert fails on it); `includeDroppedColumns`\n (`--drop-columns`) drops it, `relaxColumns` relaxes it. Advisory-only changes\n carry no SQL, never reach the tracker, and do not fail `db:migrate`.\n- **ADD COLUMN** is planned per engine: DuckDB rejects every inline constraint\n (add with `DEFAULT`, then `SET NOT NULL`, `CREATE UNIQUE INDEX`); SQLite\n rejects inline `UNIQUE` (separate `CREATE UNIQUE INDEX <table>_<col>_key`, the\n PostgreSQL constraint-index name, so the orphan sweep leaves it alone) and\n `NOT NULL` without a default on a populated table; PostgreSQL keeps constraints\n inline. DuckDB has no `ADD CONSTRAINT`, so the separate index is the only\n way to add uniqueness there; the bundled DuckDB 1.4.x resolves\n `ON CONFLICT (col)` through that index (the old #12684 limitation the DuckDB\n strategy's `requiresInlineUnique()` note describes no longer reproduces —\n the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced inline only on an empty\n table. On a populated one it needs a per-row `backfill` (#3008,\n `ColumnDefinition.backfill` from `@field({ backfill })`): PostgreSQL/DuckDB\n run `ADD COLUMN` (nullable) → `UPDATE … SET col = (backfill) WHERE col IS\n NULL` → `SET DEFAULT` (deferred, if declared) → `SET NOT NULL` → the unique\n index; SQLite adds it through the table rebuild (`sqliteBackfillPlaceholderSql`\n → `planSqliteTableRebuilds` appends `\"col\" TYPE NOT NULL` and copies\n `(backfill)`), one rebuild per table for every such column. The differ first\n probes `WHERE (backfill) IS NULL` on the live rows. With no default and no\n backfill, or with a backfill that yields NULL or will not evaluate, the\n column is **not added**: a manual `alter_column` (`mismatch.actual ===\n REQUIRED_COLUMN_NOT_ADDED`) names table and column, and `compareTable`\n withholds `add_index`/`add_foreign_key` changes on it. The same backfill\n repairs nullability drift (an existing nullable column holding NULLs).\n A backfill for a single-column unique target is also probed for duplicate\n values and refused by name. Partial unique indexes are exempt from that\n probe only where `supportsPartialIndexes()` holds (SQLite/PostgreSQL);\n DuckDB/JSON build them as full indexes, so they are probed there (#3015).\n DuckDB refuses `ALTER COLUMN` while any index depends on the table, so its\n `SET NOT NULL` is bracketed by `DROP INDEX`/recreate from `duckdb_indexes().sql`.\n- **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual\n (comment SQL → `db:migrate` exit 1), except a NOT NULL tightening with a\n declared `backfill`, which the rebuild performs (`COALESCE(col, backfill)`\n in the copy). The rebuild otherwise consumes `type_upgrade` placeholders.\n- Defaults compare through `canonicalizeDefault()`, which folds engine\n renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs\n `now()`) by manifest type; an unclassifiable rendering skips the comparison\n rather than risking a false positive that would churn every run. The\n round-trip test (create from each DDL strategy → compare → zero changes) in\n `src/migrations/__tests__/issue-2369-*.test.ts` guards this.\n\n### `schema.ddl` is a preview, not the table\n\n`SchemaDefinition.ddl` / `manifest.json` `schema.ddl` is the engine-neutral\nCREATE TABLE string from `SchemaGenerator.generateSQL()` with no engine: no\nindexes, no triggers, abstract `REAL`/`JSON`/`UUID`/`TIMESTAMP`. It is kept for\nbackward compatibility only. Everything that needs an executable table renders\n`columns` + `indexes` through `getDDLStrategy(engine)` — `db:migrate`\n(`migrations/orchestrate.ts`), `MigrationGenerator` (default\n`materializeStructuredSchema: true`; `false` is a deprecated opt-out),\n`SchemaAggregator`, and `createIsolatedTestDbFromManifest` in smrt-vitest, the\nlast two via `src/schema/manifest-schema.ts` (`collectManifestTables` /\n`renderCollectedManifestTable`). The cached string is merged in only for a\ntable whose contributors expose no structured columns (hand-authored\nmanifests); table constraints that exist only in the string are dropped with a\nwarning, as `db:migrate` drops them. Do not add a new consumer of the\nstring, and do not write a private CREATE INDEX renderer — the retired ones\ndropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out\n`PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.\n\n### The merged table shape is registration-order independent (#2372)\n\n`getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that\nshares a physical table — the whole STI hierarchy — into one shape. Both route\nthrough `buildMergedTableSchemas()`, which groups contributors by table and\nthen merges them in a **deterministic** order: the STI base first, then\nancestors before descendants, then by qualified name.\n\nThe first contributor supplies fallback columns, `idType`, conflict columns,\ncached DDL, and wins column conflicts. Keep base-first ordering even when a\nchild without manifest schema registers first.\n\nTwo invariants keep the two assembly paths agreeing:\n\n- `createBaseColumns()` mirrors what `generateSchemaFromManifest` /\n `generateSTISchemaFromManifest` emit for the same table, so a table built\n from runtime field metadata alone has the same NOT NULL/DEFAULT shape as one\n built from a manifest. Note `_meta_type` is `TEXT NOT NULL` with **no**\n default, matching the generator.\n- `fieldsToColumns()` reads `required`, `default`, and `description` from the\n top level *or* `_meta`. Registry fields normalize them into `_meta`\n (`manifest-field-merge.ts`), so reading only the top level silently dropped\n NOT NULL and DEFAULT for every registry-sourced field.\n\nSTI columns stay nullable regardless of the field's `required` flag\n(`fieldsToColumns(fields, { stiUnionColumns: true })`): the table holds the\nunion of all subtypes' fields, so a column only one subtype declares is never\npopulated on a sibling's row. Declared defaults are still emitted. This matches\n`generateSTISchemaFromManifest`, which sets `notNull: false` on every non-system\nSTI column.\n\nWhen adding a class-level input to the merged shape, take it from the seeding\ncontributor rather than \"whichever class arrives first\", and cover it with a\nchild-first/base-first equality test.\n\n### Default list ordering indexes\n\nAll four generators index `DEFAULT_LIST_ORDER_BY` (`created_at DESC, <pk> ASC`):\n`ensureDefaultListOrderingIndex()` emits `(tenant column, created_at)` when\nscoped, otherwise `(created_at)`. Resolve tenant columns by `referenceKind ===\n'tenantId'`, not spelling. The tenant-leading pair also serves the reference\nindex requirement.\n\nOnly an unqualified, non-JSON-path index with the same leading columns suppresses\nit. Append declared composites first, then default ordering, then reference\nindexes. `(tenant_id, created_at, status)` replaces the default pair;\n`(tenant_id, publish_date)` does not. Emit one plain index per STI table, since\nbase polymorphic reads lack `_meta_type` predicates.\n\nDo not add direction or PK columns by inference: `IndexDefinition` has no\nper-column directions, backward B-tree scans serve descending timestamps, and\nthe mixed-direction PK tie-break still needs incremental sorting within equal\ntimestamps.\n\n### One conflict-target rule, applied on every producer\n\n`save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must\ncarry exactly one unique index over those columns (or they must be the\nprimary key). Keep the derivation in `src/schema/conflict-target.ts` and let\nevery producer call it — the three manifest pipelines share\n`ManifestGenerator.applyGenerationPasses()` since #2360 because\n`ManifestBuilder` had silently skipped the report passes for months. When you\nadd a way for the key to vary (a new decorator option, a new class kind),\nthread it through `getConflictColumns()`, `normalizeConflictColumns()` and the\ngenerator's `resolveConflictTarget()` together, and extend the parity test's\n\"unique index == conflict target\" assertion; a key the runtime uses and the\nschema does not index is a hard PostgreSQL error (42P10) on the first save,\nand a key the schema indexes without the tenant column is the silent\ncross-tenant overwrite this rule exists for.\n\n### Tenant OWNERSHIP, not tenant scope, leads the default key\n\nA class with a `tenantId` field is tenant-owned even when it declares no\ntenancy — consumers register such classes with the tenancy interceptor at\nruntime (Anytown's Ludis `League`/`Team`, smrt-users `Group`/`Role`/…), which\ncore never reads. `resolveOwnershipTenantColumn()` (declared tenant column,\nelse `tenant_id`) leads the default natural key on every producer:\n`ObjectRegistry.getOwnershipTenantColumn()` / `getConflictColumns()`,\n`normalizeConflictColumns()`, the generator's `resolveConflictTarget()` and the\nregistry schema-builder. The tenancy read policy is still never inferred\n(`getTenantScopedConfig()` stays undefined). Explicit `conflictColumns` are\nnever rewritten. Old code's `ON CONFLICT (slug, context)` and new code's\n`ON CONFLICT (tenant_id, slug, context)` each need their own unique index, so\nthe inferred tenant-led key is named by the custom-key rule\n(`<table>_tenant_id_slug_idx`; only a DECLARED tenant column keeps\n`<table>_slug_context_idx`) and `smrt db:migrate` builds it BESIDE the legacy\nglobal unique (expand). The differ keeps the legacy index\n(`isLegacyNaturalKeyIndex()`, exempt from the #3126 superseded-index sweep and\nfrom `--drop-indexes`) until `--drop-legacy-natural-key` /\n`DiffOptions.dropLegacyNaturalKey` (contract). `db:status` exits 1 while the\ntenant-led unique is missing and warns while the legacy one survives\n(`cli/src/commands/tenant-natural-keys.ts`). Deploy order and locking:\n`docs/content/core.md` (\"Upgrading an existing database\"). The implicit\n`tenant_id` column never makes a foreign key identifying\n(`resolveForeignKeyDeleteAction({ columnName })`): a tenant FK stays\n`NO ACTION`, so deleting a tenant does not cascade through tenant-owned tables.\n\nThe save path backs this up where the key still omits the owner (an explicit\nkey, or a live table not yet migrated) — `SmrtObject.writeNaturalKeyRow()`. The\nrow a NEW object's conflict target matches is read directly, bypassing read\ninterceptors. A different owner on any ownership column\n(`ObjectRegistry.getOwnershipColumns()`: `tenant_id`, the declared tenant\ncolumn, `@tenantId` fields and runtime registrations reported through\n`ObjectRegistry.registerOwnershipColumnSource()` — the tenancy package reports\n`registerTenantScopedClass(…, { field })` columns) raises\n`TenantIsolationError.naturalKeyOwnedElsewhere()` (`TENANT_ISOLATION_VIOLATION`,\nnever retried, never naming the other owner), also under `withSystemContext()`;\nNULL — or an ownership column the save leaves unset — counts as an owner. A\nsame-owner row is adopted by id (keeping its `created_at`, applied to the\ninstance only after the write succeeds), so `DO UPDATE SET` never rewrites a\nprimary key (no `ON UPDATE CASCADE` churn). Adoption needs an explicit natural\nkey: when the match is only through a slug `getSlug()` derived from\n`name`/`title`/`label` (a human value, not an identity), the new object keeps\nits id, moves to the first free `<base>-2` … `<base>-9` (one `slug IN (…)`\nprobe; then `<base>-<8 id chars>`) and INSERTs — whoever owns the taken row, so\nit is never refused. `getOrUpsert()`'s create path opts back into adoption\n(`_adoptNaturalKey`): a miss on a non-key field updates the row the derived\nslug names instead of adding `<slug>-2`. On PostgreSQL with no NULL conflict\nvalue the write is `INSERT … ON CONFLICT (…) DO NOTHING RETURNING id` first, so\na new key costs one statement and no pre-read, and concurrent first creates\nnever collapse: the loser re-reads and adopts (explicit key) or moves (derived\nslug, then straight to the id suffix), bounded retries. Embedded engines\n(serialized by the in-process write queue) and NULL-bearing keys read first.\nThe junction batch path falls back to per-item saves when\nits conflict target omits an ownership column present in the row (`tenant_id`,\nthe declared tenant column, or any `@tenantId`-marked field), and when any new\nitem's natural key already names a row the batch is not removing; its\n`DO UPDATE SET` never lists `id`, `created_at` or an ownership column. Strict\ninserts (`_insertOnly`) check NULL-bearing keys the same way, because NULLs\nare distinct in the unique index on SQLite, DuckDB and PostgreSQL < 15.\n\nA save with no owner (`tenantId: null`, typically under `withSystemContext()`)\nis deliberately NOT refused when a tenant row shares its slug under the\ntenant-led key: it lands as a separate global row, the global-default /\ntenant-override shape #2360 keeps. Refusing it would need a cross-owner read on\nevery global save and would break that pattern. A model that needs a slug\nunique across every owner declares explicit `conflictColumns` without the\ntenant column; then the guard refuses the colliding save instead.\n\n### Every generated index name is length-guarded before it leaves a path (#2374)\n\nPostgreSQL truncates identifiers beyond 63 bytes; two generated names sharing\nthat prefix can make `CREATE INDEX IF NOT EXISTS` silently skip an index.\n\n`schema/index-utils.ts` owns the guard, and it splits by who owns the name:\n\n- **Generated index, trigger and PL/pgSQL function names** →\n `shortenIdentifier()`. Deterministic `<head>_<digest><suffix>`, digest taken\n over the **full** original so a shared prefix still yields distinct names, and\n a recognised suffix (`_idx`, `_unique_idx`, `_key`, `_pkey`) preserved.\n- **Hand-declared `@smrt({ indexes: [{ name }] })`** → `assertIdentifierFits()`,\n a hard error in `validateDeclaredIndex()`. Renaming what a developer wrote is\n worse than refusing it, and `SchemaComparer` matches indexes **by name**\n first, so a 70-byte declaration could never match the 63-byte index\n PostgreSQL stored and `db:migrate` would emit `add_index` forever.\n- **Table and column names** are not guarded: PostgreSQL truncates their\n declarations and references consistently. `smrt-users` tests intentionally\n long table names; collision risk remains with the author.\n\n`enforceIdentifierLimits()` is the single call site per path, placed **after**\n`ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing\nthe shortening at the end rather than at each `indexes.push()` is safe because\nthe digest covers the whole original name, so entries distinct before shortening\nstay distinct after; the helper still throws if two ever collide. The migrate\nleg's `withConflictIndex()` (`registry/schema-builder.ts`) and the PostgreSQL\ntrigger-function name call `shortenIdentifier()` directly, because they compose\na name outside the generator's index list. Note that an over-long *table* name\nstill yields in-limit, distinct *index* names, because the shortening runs over\nthe whole composed name.\n\nThe digest is FNV-1a, not `node:crypto`: `index-utils.ts` is re-exported from\n`schema/utils.ts`, which exists to keep Node built-ins out of browser bundles.\nIt only has to be *stable* — a shortened name that changed between releases\nwould make every deployment drop and recreate the index — so the parity and\nunit tests pin the literal output rather than recomputing it. Unpaired\nsurrogates are folded to U+FFFD before both counting and hashing, so the digest\nis taken over exactly the bytes the driver transmits.\n\nExisting databases migrate **by name swap, without a rebuild**: the live index\nstill carries the name PostgreSQL truncated it to, the manifest now carries the\nshortened one, and the differ claims it by signature (columns + uniqueness +\npredicate), emitting nothing — including under `includeDroppedIndexes`. See\n`migrations/__tests__/index-drift.test.ts` and the PostgreSQL lane test\n`schema/issue-2374-identifier-length-postgres.optional.test.ts`.\n\nOut of scope, deliberately: constraint names PostgreSQL invents for itself. A\nCTI table's inline `UNIQUE` produces an implicit `<table>_<column>_key`, which\ncan exceed 63 bytes even when the table and column each fit. SMRT never names\nit, and PostgreSQL disambiguates its own truncations by appending a counter\nrather than collapsing them, so there is no silent-collision hazard there.\n\n### The `_smrt_` prefix does not mean \"system table\" (#2376)\n\n`SmrtClass` coalesces concurrent system-table initialization through completion\nof bootstrap, deferred compatibility, and vector setup (#3944). In-flight work\nis shared only by the same database handle. Distinct handles at the same URL\nretain database-level coordination: a caller-owned transaction may finish its\ninitialization statements before its tables become visible to another handle.\nAll same-handle waiters observe failure; the pending entry is removed so a fresh\ninstance can retry. Existing completed-success cache identities are unchanged.\nProvision system tables on a base connection before caller-owned transactions;\nconstructor initialization still preserves PostgreSQL's transaction lock lifetime.\n\n`bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`\ntables belong to `@smrt()` models and are created by `db:migrate` (feature\nflags, prompt overrides, subscription plans, report schedules, field policies,\njobs). Never classify by prefix — use `SYSTEM_TABLE_NAMES`\n(`schema/system-table-shapes.ts`, derived from the DDL parse) plus\n`FRAMEWORK_OPERATIONAL_TABLES` / `RETIRED_SYSTEM_TABLES` in `system/schema.ts`.\nThe change-feed writer skipped by prefix, so clients syncing those domain\ntables through `_changes` never saw an update.\n\nEditing `ALL_SYSTEM_TABLES` requires bumping `SMRT_SCHEMA_VERSION` *and*\nappending to `SMRT_SCHEMA_DDL_CHECKSUMS` — the version gates the DDL replay, so\nwithout a bump no existing database ever applies the change. A new **column**\nadditionally needs an `addColumnIfMissing()` entry in `system/compatibility.ts`\n(`CREATE TABLE IF NOT EXISTS` is a no-op on an existing table).\n`system-schema-evolution.test.ts` enforces both, and asserts a legacy database\nupgrades to exactly the shape a fresh install gets.\n\n`_smrt_jobs` / `_smrt_job_events` are dual-owned: `db:migrate` creates them,\nthe compatibility pass reshapes them. On a fresh install bootstrap runs first,\nso their pass is deferred — `ensureDeferredSystemTableCompatibility()` re-runs\nuntil the tables exist, then stamps a `<version>+deferred-compat` marker. It\nruns OUTSIDE the bootstrap lock and swallows its own failures: those statements\ntarget tables the framework does not own, and inside the PostgreSQL transaction\none failure would roll back system-table creation with it. Only\n`ensureBootstrapSystemTableCompatibility()` (the tables the DDL itself creates)\nbelongs inside the lock.\n\nReconciling `_smrt_jobs.task_id` uniqueness reads the live index catalog, which\nis implemented for PostgreSQL and SQLite only; DuckDB and the JSON adapter keep\nthe redundant compat index rather than risk dropping the one that enforces the\nupsert conflict target. When reading a PostgreSQL catalog array, cast it\n(`attname::text`) and parse both shapes — a driver with no parser registered for\nthe array OID returns the raw `{a,b}` literal, and reading that as \"no columns\"\nsilently inverts an index-existence decision.\n\n## Same-package referential integrity uses two matching rails\n\n`@foreignKey(Target)` emits a physical database constraint when the target is\nin the same package and applies the same action through `SmrtObject.delete()`\nin `src/cascade.ts`. A shared delete-action resolver keeps both paths aligned;\nthe established generated `ON UPDATE CASCADE` default remains unchanged:\n\n| Reference | Default when `onDelete` is absent |\n|---|---|\n| Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |\n| Polymorphic `(metaType, metaId)` association row | `CASCADE` |\n| Ordinary same-package reference | `NO ACTION` — deletion is refused while references remain |\n| Every `@tenantId()` field | Excluded from physical constraints and delete cascades |\n\nThe natural-key rule is what cleans junction rows up without any per-package\nannotation: a junction declares\n`@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the\nrow is *identified* by the content and cannot outlive it. An ordinary child\n(`Order.customerId`) is keyed by `(slug, context)` and therefore defaults to\nimmediate `NO ACTION` unless it opts in explicitly.\n\n**`@tenantId()` is excluded even though it lands in `conflictColumns`.**\n#2360 leads every tenant-scoped class's *default* natural key with the\ntenant column, so without this exclusion, deleting one `Tenant` row would\nrecursively CASCADE through every tenant-scoped table in the schema that has\nnot declared its own `conflictColumns` — the overwhelming majority. The\ntenant column scopes ownership; it does not identify the row the way a\njunction's foreign key does. Detected via the `__tenancy.isTenantIdField`\nmarker on `FieldMeta` (`smrt-core` reads it structurally so it never depends\non `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so\nthis cannot currently be overridden per field.\n\nOn PostgreSQL `db:migrate` converges a live SMRT-owned constraint whose only\ndrift is its action (#3023, `SchemaComparer.findReplaceableForeignKey()`):\nbuild-then-swap via `renderForeignKeyReplaceStatements()` — staged\n`<name>_smrt_new` added `NOT VALID` and validated before the old constraint is\ndropped and the staged one renamed; `--postgres-safe` runs the VALIDATE and the\nswap outside the batch transaction. A change toward `CASCADE` / `SET NULL` /\n`SET DEFAULT` still applies (the manifest declares it) but carries a\n`warning` advisory that `db:diff`, `db:status` and `db:migrate` print as\nDESTRUCTIVE.\n\n`@crossPackageRef()` remains runtime-only: it registers relationship loading\nand indexes but deliberately emits no physical constraint, avoiding circular\npackage DDL. Tenant markers follow the same non-constraint rule because a\ntenant is a scope, not an ownership edge.\n\nSame-package archival identifiers may explicitly use `@foreignKey(Target, {\nconstraint: false })`: preserve relationship loading and indexing, but omit\nphysical constraints, schema dependencies, and application cascade/preflight\nso the identifier survives parent deletion. Document the retention reason at\nthe field; ordinary references remain constrained.\n\nFor a same-package relationship whose semantics are portable but whose physical\nconstraint shape is not, `@foreignKey(Target, { constraint: { engines: [...] } })`\nis the public exception. The allowlist scopes physical DDL and dependency\nplanning only. Relationship metadata, UUID representation, derived indexes, and\nthe application delete rail remain canonical on every engine. Empty or unknown\nengine lists fail closed; unannotated unsupported DuckDB cycles and actions keep\ntheir actionable refusal.\n\nEvery schema creation entry point uses the same deterministic dependency\nplanner. Parents are created before children. SQLite keeps cycle constraints\ninline because it can create them safely. PostgreSQL creates mutually dependent\ntables first and adds their named constraints afterward. DuckDB refuses cycles,\nself-references, `CASCADE`, and `SET NULL` with an actionable error because its\ncurrent ALTER/constraint support cannot enforce those shapes safely.\n\nRollback drops children before parents, removes deferred PostgreSQL cycle\nconstraints first, and defers SQLite checks while dropping populated cycles.\nAggregation that filters a parent also removes a retained child's physical FK.\nPostgreSQL deferred constraint adds are idempotent. Generated `ON UPDATE\nCASCADE` remains the default; DuckDB/JSON must refuse unsupported actions\nrather than silently stripping them.\n\nFor existing tables, PostgreSQL checks the exact child table/column against the\nexact referenced table/column before adding a constraint as `NOT VALID` and\nthen validating it. The probe uses distinct child/parent aliases and, when both\nmanifest columns are UUIDs, bases its guarded casts on both live column types:\nmatching live types compare directly, while a legacy text side is shape-checked\nbefore casting. This keeps a self-reference or malformed legacy value from\ninvalidating the query. An orphan stops migration with detector SQL and an\nexecutable repair suggestion: nullable FKs are cleared, while required FKs\nrequire an explicit operator decision to reassign the reference or deliberately\nremove a child row after preserving its required data. A probe failure is\nsurfaced as a database/framework error, never misreported as orphan data.\nWhen the referenced parent is absent but scheduled for creation by the same\n`compare()` batch (#3240), PostgreSQL first proves the entire existing child\ntable empty instead of querying the absent parent. Nonempty children are blocked\nwithout executable repair SQL: create and populate the parent deliberately,\nthen rerun the ordinary orphan preflight. The exception never applies to a\nstandalone `compareTable()` or an undeclared missing parent, and incompatible\ncolumn types, missing target columns, and existing conflicting constraints stay\nblocked. Planned-parent and newly added child-column types are compared using\nthe PostgreSQL DDL mapping (for example, `TIMESTAMP` becomes `TIMESTAMPTZ`,\n`INTEGER` becomes `BIGINT`); existing columns retain their live physical types. `NOT VALID` plus `VALIDATE CONSTRAINT` remains the authoritative check;\na writer racing the preflight causes the atomic migration to roll back.\nSQLite emits inline `REFERENCES` when adding a new nullable column with no\nnon-NULL default or backfill (#3795): existing rows receive NULL and the database\nenforces subsequent writes. An existing-column constraint or another column\nshape still requires a deliberate table rebuild. DuckDB reports the unsupported ALTER\npath. Neither engine treats an unsupported constraint addition as a successful\nno-op.\n\n**Counting orphans is a separate, read-only concern (#2753).**\n`renderForeignKeyOrphanDetector({ limitOne: true })` makes the probe a gate;\n`schema/foreign-key-orphan-report.ts`'s `collectForeignKeyOrphanCounts()` runs\nthe identical predicate as `COUNT(*)` (via the same function's `countOnly`\noption, so the FROM/JOIN/WHERE clause is never duplicated) for every\nmanifest-declared foreign key, and reports child/parent table and column, the\nlive count, and child-column nullability. A relationship whose child or parent\ntable does not exist live is skipped and reported separately rather than\nfailing the whole run. It never repairs anything — the CLI surface is\n`smrt db:orphans` (`packages/cli/src/commands/db-orphans.ts`), and `db:status`\nprints a compact per-foreign-key summary when any count is nonzero.\n\n**Diagnostics are latency-bound, not query-bound.** Each `getTableSchema()` is\nfive catalog round trips, so `compare()` and the orphan report read each table\nat most once per run, with bounded concurrency on PostgreSQL\n(`schema/bounded-concurrency.ts`: 8 introspections, 4 data-scanning probes; 1\nelsewhere). `compare()` reads partial-index predicates in one `pg_indexes`\nquery, and `db:status` seeds the orphan report with\n`SchemaComparer.getLiveSchemaSnapshot()`. Never add a per-table or\nper-relationship query to these paths without batching or bounding it.\n\n**Partial apply and opt-in orphan disposition (#2748).** The batch an\nunflagged `db:migrate` attempts already excludes every blocked change (a\nFK-with-orphans, a manual `type_upgrade`/`alter_column`) — those never leave\n`SchemaDiff.changes` as executable SQL, so they never enter the atomic\ntracker batch on their own. `--apply-unblocked` adds a second, opt-in\nsafety layer on top of that exclusion: the CLI partitions the remaining\n\"safe\" bucket by *dependency*, not just by whether a change is individually\nblocked. `computeBlockedColumns()` (`packages/cli/src/commands/\ndb-migrate-actions.ts`) reads every manual intervention's blocked\n`table.column` identity; `partitionUnblockedMigrations()` then withholds any\n`add_index`/`add_foreign_key`/`alter_column`/`drop_column` that reads or\nwrites one of those columns (an index on a column whose type upgrade is\nblocked, or a FK whose child *or* parent column is blocked) and applies\neverything else through the normal transaction/tracker path. Off by\ndefault: without the flag, the batch stays exactly what it always was.\n\nThe FK-orphan advisory branch in `compareForeignKeys()` (`migrations/\ndiffer.ts`) tags its `SchemaChange` with `orphanBlocked: true` and\n`orphanNullable` (mirroring `renderForeignKeyOrphanRepair()`'s own\nnullable/not-nullable branch) so a caller can identify this specific\nmanual-intervention reason without parsing `advisory.message`. `db:migrate\n--null-orphans` reads that tag: for a nullable child column it runs the\ndiffer's own suggested `UPDATE ... SET <column> = NULL` (from\n`advisory.suggestedSql[1]`, the exact statement the differ already\nrendered — never re-derived), then rebuilds the `ADD CONSTRAINT NOT VALID` +\n`VALIDATE CONSTRAINT` pair via the shared `renderForeignKeyAddStatements()`\nhelper (`schema/foreign-key-ddl.ts`, also used by the differ's own safe-add\nbranch) and adds it through the normal tracker path. A NOT NULL child column\nkeeps the unconditional \"Manual repair required\" refusal — nulling isn't an\noption and the flag never deletes rows. `planOrphanDispositions()` fails\nclosed (routes to \"not nullable\"/no-op) if an orphan-blocked change is\nmissing its `foreignKey` definition or its `advisory.suggestedSql` pair, on\nthe same \"report and withhold rather than guess\" principle as the rest of\nthis gate.\n\n`getForeignKeyOrphanOptions()`'s `nullable` reads BOTH sides: the manifest\nAND the live column (`dbSchema.columns[...].notNull`), never the manifest\nalone (review, #2748). A manifest relaxed to nullable while the live column\nhas not converged yet — the same drift `--relax-columns` handles for plain\ncolumns — would otherwise report `nullable: true` from the manifest side\nonly, and `--null-orphans` would attempt an `UPDATE` PostgreSQL rejects\noutright (`23502`), failing the whole atomic batch instead of refusing just\nthat one relationship. Nullable only when both sides agree; a real\nNOT NULL on either side keeps the unconditional refusal.\n\n### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)\n\nPostgreSQL FK columns must have matching physical types. Legacy text IDs may\nmeet newer native UUID references; neither SQLite (text UUID by design) nor\nDuckDB (no in-place type rewrite) emits this convergence.\n\n**The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`\nreads both live column types and refuses to emit `ADD CONSTRAINT` when they\ndisagree, naming both columns, both live types, and the repair. It deliberately\nskips the orphan probe in that case: across mismatched types the probe answers\na question about casted values, not about the constraint being refused, and it\nhas to run again after the columns converge anyway.\n\n**The differ converges the columns.** `planUuidConvergence()`\n(`src/schema/uuid-convergence.ts`) groups every manifest relationship that\ndeclares UUID on both sides into connected components and converges a component\nonly when the live database already proves the target shape — at least one\nmember is native `uuid`. A component that is `text` on *every* side is the\ntolerated pre-R11 deployment and is left alone; its foreign keys are\ntype-compatible today, and the R11 uuid/text equivalence in\n`migrations/differ.ts` keeps it out of the column diff.\n\nConverge entire relationship components, including siblings and self-references;\nan unreferenced legacy text ID retains its UUID/text equivalence tolerance.\n\nThe planner never coerces data. Before emitting anything it probes each column\nit would rewrite for values that are not uuid-shaped (the same `~*` canonical\npattern the orphan probe uses) and refuses the whole component — with the count\nand a sample value — if any exist, if the probe cannot run, if a member carries\nsome third physical type, or if a live foreign key still constrains a column\nthat must change. `@happyvertical/sql` introspection does not expose live\nPostgreSQL constraint names, so SMRT cannot drop and re-add those constraints\nfor you: drop them deliberately, rerun the migration to converge, and let SMRT\nre-add the manifest constraints.\n\nRefusals are reported, not silent. Each one becomes a warning advisory with no\nexecutable SQL, so it reaches `unactionableChanges` / `hasManualDrift` and\n`db:status` shows **blocked: incompatible column types** instead of *pending*.\nThe same check runs per relationship in `compareForeignKeys`, so a foreign key\nwhose live types will still disagree after this run's conversions is reported\nblocked rather than emitted as pending DDL that cannot succeed.\n\nThe planner also inspects tables the manifest no longer declares. A live\nforeign key from an orphan table onto a column that must convert still blocks\n`ALTER COLUMN … TYPE`, so the differ introspects every existing table — not\nonly the manifest ones — whenever there is at least one conversion candidate,\nand reports the dependency instead of emitting DDL PostgreSQL would reject. An\nalready-converged database has no candidates and pays nothing.\n\nOrdering is a contract. Conversions carry `SchemaChange.phase =\n'pre_foreign_key'`, and the orchestrator emits them **before every CREATE TABLE\nand every foreign-key statement in the batch**. Both halves matter:\n`planForeignKeyCreation()` only defers the constraints inside a mutual cycle,\nso an acyclic new child table keeps its foreign key *inline in `CREATE TABLE`*\n— a brand-new `uuid` child pointing at a legacy `text` parent fails exactly\nlike an existing one, before the parent could be converted. Conversions only\never rewrite columns that already exist, so leading the batch is always safe. A\nlive `DEFAULT` on a converting column is dropped first (PostgreSQL refuses\n`ALTER COLUMN … TYPE` when the default cannot be cast); the ordinary default\ncomparison re-establishes the manifest default on the next run.\n\nThere are **two** batch builders and both order on that marker:\n`collectStatementsFromDiff()` in `migrations/orchestrate.ts` (used by\n`getPendingSchemaStatements` / `migrateSmrtSchemas`) and the tracker batch\n`db:migrate` assembles by hand in `@happyvertical/smrt-cli`\n(`commands/utilities.ts`). `partitionSchemaChanges()` carries\n`SchemaChange.phase` onto `MigrationAction.phase` so the CLI can partition the\nsame way, in both the applied batch and the `--dry-run` preview. If you add a\nthird consumer, order it the same way.\n\nConvergence entries carry the manifest column definition. Every `type_upgrade`\nconsumer reads `SchemaChange.column` — `partitionSchemaChanges()` in\n`@happyvertical/smrt-cli` skips an entry without one — so a conversion missing\nit would drop out of the `db:migrate` batch while `compareForeignKeys()` still\nassumed the converged type. Refused convergences carry the same column plus an\nadvisory and no SQL, and the CLI routes them to the report-only advisories\nrather than to manual interventions or the tracker.\n\nThe uuid wording is gated on the manifest. Both the runtime guard and the\nstatus planner reach their incompatible-type branch for *any* mismatched pair,\nnot only uuid/text. A `USING …::uuid` repair is suggested only when the\nmanifest declares UUID on both sides **and** a live side is actually `text`;\notherwise the diagnostic names the two live types and asks the operator to\nalign them deliberately.\n\nThe conversion is one-time and idempotent: once the column is native `uuid`,\nthe component is uniformly UUID and the planner emits nothing.\n\n### Application cascade invariants (`src/cascade.ts`)\n\n- Rebuild the registry-derived plan on every delete; manifests register lazily.\n Caching requires invalidation across every registration path.\n- Plan from `getResolvedQualifiedName()`. Every registered polymorphic\n association class participates, since its runtime target can be any class;\n `CascadePlan.isEmpty` requires no such class anywhere and no typed references.\n Only an empty plan skips the transaction.\n- Cascades are set-based: child hooks/interceptors and change-feed tombstones do\n not run. Only the explicitly deleted object runs its lifecycle. RESTRICT\n checks precede mutations; the parent DELETE and cascades share one transaction\n where supported, so deeper refusals roll back.\n- Derived `_smrt_embeddings` / `_smrt_contexts` cleanup matches IDs AND\n `ownerClassCandidates()` (qualified and simple STI member names), never IDs\n alone: unrelated text-ID classes can collide. Cleanup failures are logged,\n not raised, because these tables may not exist in older databases.\n- Never cascade append-only `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals`,\n or dispatch logs; deleting change tombstones would break sync.\n\n### Retention (`src/system/retention.ts`)\n\n`runRetentionSweep(db, policy)` runs five built-ins in fixed order (`changes`,\n`ai-usage`, `contexts`, `dispatch`, `run-once-claims`), then\n`registerRetentionTask()` contributions. A failed task records its result and\ncontinues; a missing table is `unavailable`. The `globalThis` registry avoids\nsplit registrations under duplicate core resolution; package tasks exist only\nafter importing the package. CLI prune optionally imports jobs/users.\n\nDefaults are opt-out: changes 30 days, AI usage 90 days, completed dispatch 30\ndays/failed dispatch 90 days, contexts by `expires_at`, run-once claims 30\ndays on `completed_at` (#3080; `in_progress` rows never match — the window is\na replay deadline for `runOnce()`, not just disk hygiene, see\n`agents/run-once.md`). `smrt.configure({ retention })` tunes built-ins;\ncontributed tasks own their defaults/options.\nJobs defaults are 7 days terminal, 30 failed, 30 events via\n`registerJobRetentionTasks()` or runner `retention.jobs`; expired credentials\nhave no extra window. Disable a table/task with `false` or the whole policy with\n`enabled: false`; CLI `--skip` and runner configuration expose these controls.\nTask names use package prefixes (`jobs-records`, `users-sessions`).\n\nCore does not schedule sweeps. TaskRunner runs every six hours, first one\ninterval after start; `retention: false` opts out. `smrt db:prune` supports cron.\nEvery prune counts then deletes using the same predicate; `rowCount` is not\nportable. The two statements deliberately are not transactional, so counts are\napproximate under concurrency. Overlapping change/AI-usage bounds exclude rows\nalready counted, including dry runs.\n\nRetention indexes belong in system DDL and its versioned replay:\n`_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`, dispatch\n`(status, processed_at)` / `(status, updated_at)`, and\n`_smrt_run_once_claims(status, completed_at)`. Jobs `(status, completed_at)`\nbelongs in `ensureJobsSystemTableCompatibility()` on each collection initialize,\nsince decorated jobs tables do not exist at bootstrap.\n\nExpiry remains prune-side for object/collection `recall()`/`recallAll()`;\n`LearningMemory` separately filters it at read time.\n\n## Supported generation surfaces\n\nThe four entry points above are the supported generator paths. The unused AST\n`generateSchema(objectDef)`, `smrt:schema` / `@happyvertical/smrt-virt-schema`\nvirtual modules, and project-specific `SchemaOverrideSystem` were removed.\n\nKeep published `SchemaDefinition.triggers`, `TriggerDefinition`, and the DDL\nstrategies' trigger renderers: they support hand-authored new-table schemas.\nGenerated `@smrt()` schemas always emit `triggers: []`; no decorator populates\nit, `save()` maintains `updated_at`, and the differ never retrofits triggers.\nAdding live trigger generation requires a migration rollout design.\n`_smrt_signals` and database-persisted registry APIs are retired; see\n`RETIRED_SYSTEM_TABLES` in `src/system/schema.ts`.\n\n## PostgreSQL migration execution\n\n`MigrationTracker.applyAll({ atomic: true })` sets local lock/statement timeouts\nbefore any DDL. `postgresSafe: true` commits non-index DDL atomically, then runs\nindexes CONCURRENTLY on `db.acquireSession()` so settings and DDL share a\nconnection. This mode is not atomic. Unfinished indexes are `failed`, not\n`running`; `[smrt: concurrent-index phase 1 committed]` in `error_message`\nallows reruns to resume index work without replaying committed DDL. Inspect\n`pg_index.indisvalid` and drop INVALID indexes before rebuild (`pg_indexes`\nalone cannot detect them). Operational commands: `packages/cli/AGENTS.md`.\n\n### Subclass conflict identities (#3456)\n\nAn STI child's explicit `conflictColumns` owns a partial unique index restricted\nto its qualified discriminator. The root conflict index excludes these children;\nregistry and manifest paths emit the same predicates. Natural-key lookups include\nthat scope, PostgreSQL atomic inserts include the index predicate, and adopted\nrows update by id. Bulk writes use per-item guards for these targets. Normal\nmigrations replace the root index by name and add the child index; repair any\nexisting duplicate child natural keys before migrating. Stop old writers for this\nindex migration and load the complete consumer manifest on every shared-table\nwriter before resuming: a provider-only manifest lacks the child predicates.\nDuckDB/JSON reject\nthese schemas because partial unique conflict identities cannot be enforced.\n\n## Consumer physical table bindings (#3660)\n\n`smrt.tableNames` maps exact qualified model identities to deployment-local\nphysical tables. `registry/table-mappings.ts` projects schemas for both runtime\n`getSchema()` reads and merged migration planning, including index names and\nrelationship targets. Never remap by a raw old table name: two unrelated models\nmay own that name in their respective packages. Bind STI roots only; sensitive\nmodels are refused. Well-formed bindings may precede model registration;\npartial registries never need unrelated models merely to satisfy configuration.\nRuntime object/collection getters recheck ownership even for cached table\nnames; validation is memoized by registry generation and binding configuration.\nConfiguration must load before first database use and stay\nfixed for that process. This is not an implicit data migration; see\n[consumer table bindings](../../../docs/content/table-bindings.md).\n"
|
|
1181
|
+
"content": "# smrt-core/schema paths\n\nModule semantics for `src/schema/` — which `SchemaGenerator` entry point reaches\na real database, what each one emits, and the rules that keep them in step.\nPackage orientation, the cross-module invariants, and the traps that apply\nbefore editing anything live in [../AGENTS.md](../AGENTS.md) — read that first;\nit links the relevant runtime and generation contracts.\n\n## Four entry points, two of which ship\n\n`src/schema/generator.ts` exposes four index-emitting entry points. Their columns and indexes must agree for the same class.\n\n| Entry point | Selected by | Status |\n|---|---|---|\n| `generateSTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateCTISchemaFromManifest` | `src/scanner/manifest-generator.ts` | **production** |\n| `generateSTISchemaFromRegistry` | `src/testing/database.ts` (`getTestDatabase()`), `src/schema/utils.ts` (`generateSchema`; `ensureSchema` only as a fallback) | tests + runtime helpers |\n| `generateSchemaFromRegistry` | the same two callers | tests + runtime helpers |\n\nProduction DDL takes the manifest route:\n\n```\n@smrt() class ─▶ scanner ─▶ manifest.json ─▶ generate{STI,CTI}SchemaFromManifest\n ─▶ registered `schema` ─▶ ObjectRegistry.getAllSchemasAsDefinitions()\n ├─▶ smrt db:migrate | db:diff | db:status\n │ (the CLI drives SchemaComparer + MigrationTracker directly)\n └─▶ migrateSmrtSchemas() / getPendingSchemaStatements()\n (src/migrations/orchestrate.ts — exported for programmatic\n use; no in-repo caller outside its own tests)\n```\n\nSince #2359 the two families share one set of index helpers and\n`src/schema/schema-path-parity.test.ts` runs the same fixture manifest through\nthe manifest paths, through `ObjectRegistry.registerFromManifest()` + the\nregistry paths, and through `getAllSchemasAsDefinitions()`, asserting identical\ncolumn and index sets. Extend that fixture with every generator change; a\ndivergence is a bug in the generator, not an exception to add to the test.\n\n### Index rules (#2359)\n\n- **Reference columns are always indexed.** `ensureReferenceColumnIndexes()`\n runs last on every path and gives each `@foreignKey`, `@crossPackageRef` and\n tenant column `<table>_<column>_idx` unless an UNQUALIFIED index (no `WHERE`,\n no JSON path) already leads with it — the `conflictColumns` unique index or an\n `indexed: true` opt-in, or the column's own inline UNIQUE. A partial\n `WHERE _meta_type = …` index does not count: base-class polymorphic queries\n carry no discriminator predicate. `indexed: true` on a reference column is\n redundant. Roll the index wave out to production with\n `smrt db:migrate --postgres-safe` (concurrent-index mode, #2362): a plain\n atomic batch takes SHARE/ACCESS EXCLUSIVE locks for ~230 index builds. STI FK indexes are plain, one per\n column, not per-class partial.\n- **No index on the primary key.** `<table>_id_idx` is gone from every path,\n and `conflictColumns` equal to the PK column set emit no conflict index\n (`ON CONFLICT (id)` binds to the PK constraint). `SchemaComparer` drops the\n legacy non-unique single-column PK index from existing databases without\n `--drop-indexes` when the live table reports that column as its sole primary\n key (never a UNIQUE one — on PostgreSQL that may back a custom-named PRIMARY\n KEY constraint, and `DROP INDEX` on it would fail the atomic batch).\n- **Slug loading keeps its index.** Custom `conflictColumns` replace the\n `(slug, context)` unique index; `loadFromSlug()`/`getId()`/`getSavedId()`\n still filter on slug/context, so a plain `<table>_slug_context_idx` is kept\n (additive; routing those lookups through the conflict key would change which\n row a slug resolves to). The tenant-led default key below counts as serving\n it (`servesSlugLookup()`): a tenant-scoped slug lookup carries the tenant\n predicate (#2365) and is served by the prefix, so no second index.\n- **Tenant default keys** are `(tenant_id, slug, context)`, plus `_meta_type`\n for STI. `ManifestGenerator.normalizeConflictColumns()` and\n `ObjectRegistry.getConflictColumns()` share `src/schema/conflict-target.ts`:\n resolve tenant fields through the schema owner/STI root, report group/bucket\n columns through the report, and custom PKs through their key. Explicit\n `conflictColumns` remain unchanged. The manifest, schema, knowledge, and\n runtime must carry the same value.\n Names remain `<table>_slug_context_idx` / `_slug_context_meta_type_idx`, so\n migration replaces a same-name global unique with tenant-led columns. That\n prefix serves tenant and tenant-scoped slug reads; a legacy standalone tenant\n index is dropped only with `--drop-indexes`.\n- **Widening an explicit key** (e.g. `['key']` → `['tenant_id', 'key']`,\n #3126) renames its index, so migration adds the new unique and\n `SchemaComparer` drops the superseded one without `--drop-indexes`: a UNIQUE\n index named `conflictIndexName()` for its own columns, with no predicate,\n whose columns are a strict subset of a declared UNIQUE index. It is a pure\n relaxation (every row satisfies the wider key) and otherwise would keep\n rejecting rows the model allows. The maintenance-window rule below applies.\n DuckDB has no partial indexes, so its indexes count as predicate-free, and\n its quoted index columns (`\"key\"`) are unquoted before comparison (#3305 M1).\n- **Optional NULL tenants** retain the SDK's NULL-equal upsert identity. Generated\n framework conflict indexes with nullable keys carry `nullsNotDistinct: true`;\n PostgreSQL 15+ creates `UNIQUE NULLS NOT DISTINCT`, enabling warm single-statement\n native upserts. Earlier PostgreSQL, SQLite and DuckDB retain adapter fallback\n semantics. Existing ordinary indexes are upgraded only through the explicit\n [NULL-equal maintenance migration](null-equal-indexes.md). Optional business\n unique fields remain NULLS DISTINCT; this marker is not inferred from them.\n- **Tenant-key rollout requires a maintenance window.** Old code/new indexes\n and new code/old indexes both fail new-object saves because conflict column\n sets must match exactly; persisted ID-based saves still work. Backfill legacy\n NULL tenants first or scoped ingestion creates separate rows and cannot see\n the old global ones. Cross-tenant natural-key dedup now creates one row per\n tenant. Deploy code and migrate together in atomic mode: each table drops\n and recreates its same-name unique index, holding ACCESS EXCLUSIVE locks\n (including against reads) until commit. Size `statementTimeout` for the\n largest table. A valid old subset unique guarantees the superset build;\n missing/nonunique old indexes may contain duplicates and need dedup first.\n Include the reference-index wave in that atomic window. `--postgres-safe` is\n suitable for an additive reference-index-only wave, but a key replacement\n leaves a per-table gap between drop/build and a failed build leaves no arbiter\n until rerun. There is no automatic DOWN; reverting code requires deliberately\n recreating its old indexes.\n\n- **STI `@field({ unique: true })` is enforced through indexes** (the differ can\n add an index to an existing table, never a column constraint): a full\n `<table>_<col>_unique_idx` when the STI base declares it, one\n `<table>_<col>_<class>_unique_idx WHERE _meta_type = '<qualified>'` per class\n when only descendants do — uniqueness per concrete class, not across the\n subtree. DuckDB/JSON have no partial indexes, so the descendant-scoped shape\n (`isStiSubtypeUniqueIndex`) is not emitted there — degrading it to a full\n UNIQUE would constrain every subtype; the DDL strategy and the differ both\n skip it, while other partial indexes keep degrading to full ones as before. Remember the\n framework serializes an unset text field as `''`, so a unique optional text\n field must be `nullable: true` with a `null` initializer or every unset row\n collides.\n- **Every class in an STI hierarchy carries the schema of the one shared\n table**, generated from the root base (`ManifestGenerator.generateSchemas()`\n resolves the root through `findSTIBaseInfo`), so a child never treats its own\n descendant-only unique field as base-declared.\n\n`src/schema/utils.ts` sits in between, and the two exports differ:\n\n- `generateSchema()` (reached from `SmrtCollection.generateSchema()`) always\n rebuilds from the registry and writes the result back into the registry,\n replacing whatever the manifest registered for that class.\n- `ensureSchema()` (reached from the deprecated `smrt db:setup`) is\n manifest-first: it takes `ObjectRegistry.getSchema()` plus the merged\n `getAllSchemasAsDefinitions()` table definition, and only falls back to\n `generateSchema()` when no schema is registered at all.\n\n## Verification\n\nExtend `src/schema/schema-path-parity.test.ts` for every generator change;\nmanifest, registry, and merged migration schemas must agree. Inspect regenerated\n`dist/manifest.json` and schemas across affected packages, not only decorators.\nRuntime `verifyPersistenceTable()` checks table existence only. Database drift\nchecks compare with generated artifacts; they cannot detect an omission shared\nby those artifacts. Use `smrt doctor --db` / `db:status --parity` for live parity.\n\nEvery new query predicate needs its index or an explicit reason none is needed.\nRun `pnpm --filter @happyvertical/smrt-core test:postgres` for numeric types,\nUUID casts, conflict targets, timestamps, or migrations. Schema-affecting options\nmust reach `SchemaGeneratorConfig` and both config rebuild sites:\n`src/schema/utils.ts` and `src/testing/database.ts`.\n\nTenant uniqueness and conflict targets must include the tenant column; explicit\n`conflictColumns` are author-owned and never rewritten. All reads, including\nhydration, slug lookup, vector search, and memory, remain interceptor-aware.\nRetry only transient errors classified through the cause chain; never retry an\naborted PostgreSQL transaction (`25P02`).\n\n### Composite indexes are declared, not inferred (#2357)\n\nThe generated set only covers foreign keys, unique/conflict columns, the STI\ndiscriminator, reference columns (#2359), default list ordering, and single columns opted in with `@field({ indexed: true })`. A list\nworkload's access path is composite, so declare it:\n\n```ts\n@smrt({\n indexes: [\n { name: 'contents_tenant_id_publish_date_idx',\n columns: ['tenantId', 'publish_date'] },\n ],\n})\n```\n\n`columns` takes field names or column names in access-path order — filter\ncolumns first, sort column last. Declare columns, not a direction: PostgreSQL\nscans a btree either way, so an ascending index also serves the matching\n`ORDER BY ... DESC` as an ordered scan with no Sort node. `unique` and `where`\n(partial index) are honoured.\n\n`appendDeclaredIndexes()` runs first on all four entry points, ahead of\n`ensureDefaultListOrderingIndex()` (default ordering below) and `ensureReferenceColumnIndexes()`,\nso a declared composite leading with the tenant column (or any reference column)\nreplaces the automatic standalone index rather than duplicating it.\nUnknown columns, malformed entries, and a name collision with a different index\nall fail generation — a silently dropped index only surfaces later as a\nproduction slowdown. Keep both config rebuild sites aligned.\n\n### STI subclasses declare indexes on the shared table (#3299)\n\n`@smrt({ indexes })` on an STI **descendant** is emitted on the shared table\nby `appendStiSubclassDeclaredIndexes()` (both STI entry points, after the\nbase's declarations; `getAllSchemasAsDefinitions()` and `getTestDatabase()`\ninherit it). It goes through the same validation as a base declaration: known\ncolumns, the 63-byte name limit, and errors on a collision. A UNIQUE\ndeclaration is scoped to the declaring subtype with\n`WHERE _meta_type = '<qualified class>'`, so siblings sharing the column are\nnot constrained. As with `@field({ unique })` on a descendant, each class in\nthe declaring class's own subtree gets a partial copy\n(`<declared name, cut to 52>_<10-hex digest>`), so uniqueness holds per\nconcrete class there too, not across the subtree. DuckDB/JSON skip these\nrather than widen them. A subclass UNIQUE with its own `where` is refused; declare a\ncombined predicate on the base. A plain subclass index stays unqualified, so\npolymorphic reads can use it. Columns are emitted exactly as declared and are\nnever tenant-prefixed, as with explicit `conflictColumns`: put `tenant_id`\nfirst for a tenant-local key (`['tenantId', 'rampEventId']`). Leave it out\nonly for a reviewed global identity (#3305 dispositions).\n\nMigration: `db:migrate` adds the new index by name. A UNIQUE index fails\nclosed (the migration rolls back) when existing rows already duplicate the\nkey. Find and repair those rows first, scoped to the subtype\n(`_meta_type = '<qualified class>'`), and never by deleting history to make\nthe index fit. With `db:migrate --postgres-safe` it is built `CONCURRENTLY`.\nA failed concurrent build leaves an INVALID index, which the next run drops\nand rebuilds after the data is repaired.\n\n### Relationship targets resolve to a class name on both paths\n\n`@foreignKey`/`@oneToMany`/`@manyToMany` accept a class, a name string, or a\n`() => Target` thunk. The decorator invokes the thunk and throws when the target\ncannot be resolved (never `related: ''`); the scanner unwraps the same thunk\nfrom raw source (never `related: '() => Target'`). An unresolved target silently\ncosts the relationship edge, `loadRelated()`, and the FK-derived index (#2379).\nA thunk resolves at decoration time, so a target declared later in the same\nmodule is still in its temporal dead zone — use the string form there.\nRuntime decorators retain the resolved constructor alongside the display name.\nInverse lookup and cascade planning resolve that constructor when queried, so\nthe target may register after its child without binding to a same-name class\nin another package. String targets prefer the declaring package; ambiguous\nunqualified targets never match an unrelated package's inverse edge.\nRegistry schema generation and the merged migration schema resolve the same\ncanonical target before selecting both its table and identifier type. The\nmanifest generator resolves unqualified targets in the declaring package.\nKeep the merged schema in parity tests: `getTestDatabase()` preserves its\nauthoritative FK metadata over the generated columns, so testing only the\nstandalone generator cannot prove the constraint that reaches the database.\nWith an explicit `classes` list, `getTestDatabase()` builds that authoritative\nschema with `getAllSchemasAsDefinitions({ tables })`: only the requested tables\nand their foreign-key closure are merged and ownership-checked\n(`CONFIG_TABLE_NAME_COLLISION` still fires for any of them), so an unrelated\ncollision elsewhere in the registry cannot abort the setup. The implicit list\nchecks every registered table (#3956).\n\nField decorator registration retains constructor ownership separately from the\nsimple-name inspection view. Custom decorators should pass the callback's\nconstructor as `registerFieldDecorator`'s fourth argument. Omit it only for\nintentional legacy string-only metadata; that legacy metadata still composes\nwith constructor-owned options, but mirrored public decorator fields do not.\n\n### Decorator-only field types agree on both paths (#4295)\n\nA compiled model registered without a manifest entry sees only `@field()`\noptions, never the initializer or annotation the scanner reads. Without an\nexplicit `type`, a boolean `default` infers `boolean` on both paths\n(`inferDecoratorOnlyFieldType` in `src/registry/class-registration.ts`, step\n3.7 of the scanner's `ManifestAdapter.inferFieldType`); anything else falls\nback to `text`. Numbers are never inferred at runtime (`0` and `0.0` are the\nsame value), so a runtime-only numeric field must declare\n`type: 'integer' | 'decimal'`. Loading the manifest later still replaces the\ndecorator-only type. A database whose column was built from the old TEXT\nexpectation is not auto-cast; parity reports the drift for an explicit\nmigration.\n\n### A SQLite type change is a table rebuild (#2370)\n\nSQLite has no `ALTER TABLE ... ALTER COLUMN ... TYPE`, so\n`src/migrations/sqlite-rebuild.ts` answers a `type_upgrade` on SQLite with the\nstatement list SQLite's own docs prescribe: stage a new table under\n`_smrt_rebuild_<table>`, copy, drop, rename, replay the indexes and triggers.\n`SchemaComparer.compareTable` swaps that plan in for the differ's\n\"requires table recreation\" placeholder, so `db:migrate` applies it inside the\nnormal atomic batch instead of exiting 1 forever.\n\nFour properties of that module are load-bearing; keep them if you touch it:\n\n- **The target shape comes from the live `sqlite_master` DDL**, retyping only\n the drifted columns. It is not regenerated from the manifest, so the rebuild\n never becomes an implicit `DROP COLUMN`, and it preserves table constraints,\n `CHECK`s, and `WITHOUT ROWID`/`STRICT`.\n- **The rebuild is hoisted ahead of the table's other column changes.** Its\n staging DDL and copy list are captured at diff time, and the differ emits\n changes in manifest field order, so a new field declared above the retyped\n one would otherwise run `ALTER TABLE ... ADD COLUMN` first and have the\n rebuild silently drop it — both statements succeed and the batch commits.\n Rebuild first, then add columns to the rebuilt table.\n- **The copy carries no `CAST`.** SQLite applies the destination column's\n affinity on insert — the same conversion a fresh table performs. An explicit\n cast is worse: non-numeric TEXT cast to REAL/INTEGER silently becomes `0`,\n and an ISO timestamp cast to NUMERIC-affinity `DATETIME` becomes its year.\n- **It refuses when any table has a foreign key onto the target and\n `PRAGMA foreign_keys` is ON** (the SMRT adapter's default). `DROP TABLE`\n performs an implicit `DELETE FROM` that fires `ON DELETE CASCADE` on\n children, and `defer_foreign_keys` defers constraint *checks*, not FK\n *actions* — verified: the child rows go. The target's own self-reference\n counts, because the staging table copies that clause and becomes a child of\n the table being dropped (verified: a two-row self-referencing table finishes\n the rebuild holding one row). Such a column stays manual drift.\n- **`PRAGMA legacy_alter_table` brackets the rename**, because SQLite ≥ 3.25\n re-parses the schema on `ALTER TABLE ... RENAME` and a view still pointing at\n the just-dropped table makes it fail outright. It is restored immediately\n after; a rolled-back batch leaves it set on that connection, which is inert\n here only because nothing else in SMRT renames a table.\n\nAll the drifted columns of one table share a single rebuild: the first change\ncarries the plan and the rest become `no change needed` comments that the CLI\nclassifies as no-ops.\n\n### Explicit SQLite NOT NULL relaxation (#4413)\n\n`relaxColumns: true` / `smrt db:migrate --relax-columns` also supports removing\nan ordinary inline `NOT NULL` clause on a manifest-declared column. The local\napplication entry point forwards `smrt app migrate --relax-columns` under its\nexisting operation lock and refuses a live application writer. The programmatic\n`migrateSmrtSchemas` / `getPendingSchemaStatements` entry points accept the same\nexplicit `relaxColumns` option. Defaults remain advisory; this does not relax\norphan columns or change SQLite defaults/tightening behavior.\n\nThe rebuild preserves live DDL, indexes/triggers and assigned rowids. Named\nNOT NULL constraints, `ON CONFLICT`, AUTOINCREMENT tables, primary/generated target columns, missing\nclauses and tables shadowing every rowid alias refuse rather than guessing.\nA nullable-relaxation rebuild emits `PRAGMA foreign_keys = OFF` as a tracker\nhandoff: `MigrationTracker` suspends FK enforcement **before** opening its\ntransaction, verifies suspension on both the base and transaction connection,\nand checks `PRAGMA foreign_key_check` before commit. This avoids inbound FK\n`ON DELETE` actions and preserves referencing rows. An active borrowed transaction\nthat cannot suspend enforcement, a mismatched transaction connection or an\nadapter without transactions refuses. FK and legacy rename settings are restored\nin `finally`, including after rollback. Run this maintenance operation with\nexclusive access to the SQLite connection. Type-only rebuilds retain the older\ninbound-FK refusal described above.\n\n## What the differ compares (#2369)\n\n`SchemaComparer` (`src/migrations/differ.ts`) compares each manifest column's\ntype, then — unless the type itself is drifting — its nullability and default,\nand always reports what it will not touch:\n\n- **Strengthening** (`SET NOT NULL`, `SET DEFAULT`) is executable on\n PostgreSQL/DuckDB. `SET NOT NULL` is preceded by an `UPDATE … WHERE c IS NULL`\n backfill of the manifest default; without a default the live data is probed\n and, if NULLs exist, the change is reported (comment SQL + `advisory`) instead\n of emitting an ALTER that would abort the atomic batch.\n- **Relaxing** (`DROP NOT NULL`, `DROP DEFAULT`) is a report-only advisory until\n the caller passes `relaxColumns` (`db:migrate --relax-columns`). The manifest\n can be under-specified (#2372 registration-order weakness), so a live column\n that is stricter than the manifest is never weakened silently.\n- **Orphans** — DB columns absent from the manifest, DB tables no manifest\n declares (`SchemaDiff.orphan_tables`), and unclaimed `*_key` unique constraint\n indexes — are always reported. A NOT NULL orphan without a default is a\n `warning` advisory (every ORM insert fails on it); `includeDroppedColumns`\n (`--drop-columns`) drops it, `relaxColumns` relaxes it. Advisory-only changes\n carry no SQL, never reach the tracker, and do not fail `db:migrate`.\n- **ADD COLUMN** is planned per engine: DuckDB rejects every inline constraint\n (add with `DEFAULT`, then `SET NOT NULL`, `CREATE UNIQUE INDEX`); SQLite\n rejects inline `UNIQUE` (separate `CREATE UNIQUE INDEX <table>_<col>_key`, the\n PostgreSQL constraint-index name, so the orphan sweep leaves it alone) and\n `NOT NULL` without a default on a populated table; PostgreSQL keeps constraints\n inline. DuckDB has no `ADD CONSTRAINT`, so the separate index is the only\n way to add uniqueness there; the bundled DuckDB 1.4.x resolves\n `ON CONFLICT (col)` through that index (the old #12684 limitation the DuckDB\n strategy's `requiresInlineUnique()` note describes no longer reproduces —\n the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced inline only on an empty\n table. On a populated one it needs a per-row `backfill` (#3008,\n `ColumnDefinition.backfill` from `@field({ backfill })`): PostgreSQL/DuckDB\n run `ADD COLUMN` (nullable) → `UPDATE … SET col = (backfill) WHERE col IS\n NULL` → `SET DEFAULT` (deferred, if declared) → `SET NOT NULL` → the unique\n index; SQLite adds it through the table rebuild (`sqliteBackfillPlaceholderSql`\n → `planSqliteTableRebuilds` appends `\"col\" TYPE NOT NULL` and copies\n `(backfill)`), one rebuild per table for every such column. The differ first\n probes `WHERE (backfill) IS NULL` on the live rows. With no default and no\n backfill, or with a backfill that yields NULL or will not evaluate, the\n column is **not added**: a manual `alter_column` (`mismatch.actual ===\n REQUIRED_COLUMN_NOT_ADDED`) names table and column, and `compareTable`\n withholds `add_index`/`add_foreign_key` changes on it. The same backfill\n repairs nullability drift (an existing nullable column holding NULLs).\n A backfill for a single-column unique target is also probed for duplicate\n values and refused by name. Partial unique indexes are exempt from that\n probe only where `supportsPartialIndexes()` holds (SQLite/PostgreSQL);\n DuckDB/JSON build them as full indexes, so they are probed there (#3015).\n DuckDB refuses `ALTER COLUMN` while any index depends on the table, so its\n `SET NOT NULL` is bracketed by `DROP INDEX`/recreate from `duckdb_indexes().sql`.\n- **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual\n (comment SQL → `db:migrate` exit 1), except a NOT NULL tightening with a\n declared `backfill`, which the rebuild performs (`COALESCE(col, backfill)`\n in the copy). The rebuild otherwise consumes `type_upgrade` placeholders.\n- Defaults compare through `canonicalizeDefault()`, which folds engine\n renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs\n `now()`) by manifest type; an unclassifiable rendering skips the comparison\n rather than risking a false positive that would churn every run. The\n round-trip test (create from each DDL strategy → compare → zero changes) in\n `src/migrations/__tests__/issue-2369-*.test.ts` guards this.\n\n### `schema.ddl` is a preview, not the table\n\n`SchemaDefinition.ddl` / `manifest.json` `schema.ddl` is the engine-neutral\nCREATE TABLE string from `SchemaGenerator.generateSQL()` with no engine: no\nindexes, no triggers, abstract `REAL`/`JSON`/`UUID`/`TIMESTAMP`. It is kept for\nbackward compatibility only. Everything that needs an executable table renders\n`columns` + `indexes` through `getDDLStrategy(engine)` — `db:migrate`\n(`migrations/orchestrate.ts`), `MigrationGenerator` (default\n`materializeStructuredSchema: true`; `false` is a deprecated opt-out),\n`SchemaAggregator`, and `createIsolatedTestDbFromManifest` in smrt-vitest, the\nlast two via `src/schema/manifest-schema.ts` (`collectManifestTables` /\n`renderCollectedManifestTable`). The cached string is merged in only for a\ntable whose contributors expose no structured columns (hand-authored\nmanifests); table constraints that exist only in the string are dropped with a\nwarning, as `db:migrate` drops them. Do not add a new consumer of the\nstring, and do not write a private CREATE INDEX renderer — the retired ones\ndropped `where` and `jsonPath` (#2358). Every DDL strategy also spells out\n`PRIMARY KEY NOT NULL`: SQLite lets a bare non-INTEGER PRIMARY KEY hold NULL.\n\n### The merged table shape is registration-order independent (#2372)\n\n`getAllSchemas()` and `getAllSchemasAsDefinitions()` fold every class that\nshares a physical table — the whole STI hierarchy — into one shape. Both route\nthrough `buildMergedTableSchemas()`, which groups contributors by table and\nthen merges them in a **deterministic** order: the STI base first, then\nancestors before descendants, then by qualified name.\n\nThe first contributor supplies fallback columns, `idType`, conflict columns,\ncached DDL, and wins column conflicts. Keep base-first ordering even when a\nchild without manifest schema registers first.\n\nTwo invariants keep the two assembly paths agreeing:\n\n- `createBaseColumns()` mirrors what `generateSchemaFromManifest` /\n `generateSTISchemaFromManifest` emit for the same table, so a table built\n from runtime field metadata alone has the same NOT NULL/DEFAULT shape as one\n built from a manifest. Note `_meta_type` is `TEXT NOT NULL` with **no**\n default, matching the generator.\n- `fieldsToColumns()` reads `required`, `default`, and `description` from the\n top level *or* `_meta`. Registry fields normalize them into `_meta`\n (`manifest-field-merge.ts`), so reading only the top level silently dropped\n NOT NULL and DEFAULT for every registry-sourced field.\n\nSTI columns stay nullable regardless of the field's `required` flag\n(`fieldsToColumns(fields, { stiUnionColumns: true })`): the table holds the\nunion of all subtypes' fields, so a column only one subtype declares is never\npopulated on a sibling's row. Declared defaults are still emitted. This matches\n`generateSTISchemaFromManifest`, which sets `notNull: false` on every non-system\nSTI column.\n\nWhen adding a class-level input to the merged shape, take it from the seeding\ncontributor rather than \"whichever class arrives first\", and cover it with a\nchild-first/base-first equality test.\n\n### Default list ordering indexes\n\nAll four generators index `DEFAULT_LIST_ORDER_BY` (`created_at DESC, <pk> ASC`):\n`ensureDefaultListOrderingIndex()` emits `(tenant column, created_at)` when\nscoped, otherwise `(created_at)`. Resolve tenant columns by `referenceKind ===\n'tenantId'`, not spelling. The tenant-leading pair also serves the reference\nindex requirement.\n\nOnly an unqualified, non-JSON-path index with the same leading columns suppresses\nit. Append declared composites first, then default ordering, then reference\nindexes. `(tenant_id, created_at, status)` replaces the default pair;\n`(tenant_id, publish_date)` does not. Emit one plain index per STI table, since\nbase polymorphic reads lack `_meta_type` predicates.\n\nDo not add direction or PK columns by inference: `IndexDefinition` has no\nper-column directions, backward B-tree scans serve descending timestamps, and\nthe mixed-direction PK tie-break still needs incremental sorting within equal\ntimestamps.\n\n### One conflict-target rule, applied on every producer\n\n`save()` upserts on `ObjectRegistry.getConflictColumns()`; the schema must\ncarry exactly one unique index over those columns (or they must be the\nprimary key). Keep the derivation in `src/schema/conflict-target.ts` and let\nevery producer call it — the three manifest pipelines share\n`ManifestGenerator.applyGenerationPasses()` since #2360 because\n`ManifestBuilder` had silently skipped the report passes for months. When you\nadd a way for the key to vary (a new decorator option, a new class kind),\nthread it through `getConflictColumns()`, `normalizeConflictColumns()` and the\ngenerator's `resolveConflictTarget()` together, and extend the parity test's\n\"unique index == conflict target\" assertion; a key the runtime uses and the\nschema does not index is a hard PostgreSQL error (42P10) on the first save,\nand a key the schema indexes without the tenant column is the silent\ncross-tenant overwrite this rule exists for.\n\n### Tenant OWNERSHIP, not tenant scope, leads the default key\n\nA class with a `tenantId` field is tenant-owned even when it declares no\ntenancy — consumers register such classes with the tenancy interceptor at\nruntime (Anytown's Ludis `League`/`Team`, smrt-users `Group`/`Role`/…), which\ncore never reads. `resolveOwnershipTenantColumn()` (declared tenant column,\nelse `tenant_id`) leads the default natural key on every producer:\n`ObjectRegistry.getOwnershipTenantColumn()` / `getConflictColumns()`,\n`normalizeConflictColumns()`, the generator's `resolveConflictTarget()` and the\nregistry schema-builder. The tenancy read policy is still never inferred\n(`getTenantScopedConfig()` stays undefined). Explicit `conflictColumns` are\nnever rewritten. Old code's `ON CONFLICT (slug, context)` and new code's\n`ON CONFLICT (tenant_id, slug, context)` each need their own unique index, so\nthe inferred tenant-led key is named by the custom-key rule\n(`<table>_tenant_id_slug_idx`; only a DECLARED tenant column keeps\n`<table>_slug_context_idx`) and `smrt db:migrate` builds it BESIDE the legacy\nglobal unique (expand). The differ keeps the legacy index\n(`isLegacyNaturalKeyIndex()`, exempt from the #3126 superseded-index sweep and\nfrom `--drop-indexes`) until `--drop-legacy-natural-key` /\n`DiffOptions.dropLegacyNaturalKey` (contract). `db:status` exits 1 while the\ntenant-led unique is missing and warns while the legacy one survives\n(`cli/src/commands/tenant-natural-keys.ts`). Deploy order and locking:\n`docs/content/core.md` (\"Upgrading an existing database\"). The implicit\n`tenant_id` column never makes a foreign key identifying\n(`resolveForeignKeyDeleteAction({ columnName })`): a tenant FK stays\n`NO ACTION`, so deleting a tenant does not cascade through tenant-owned tables.\n\nThe save path backs this up where the key still omits the owner (an explicit\nkey, or a live table not yet migrated) — `SmrtObject.writeNaturalKeyRow()`. The\nrow a NEW object's conflict target matches is read directly, bypassing read\ninterceptors. A different owner on any ownership column\n(`ObjectRegistry.getOwnershipColumns()`: `tenant_id`, the declared tenant\ncolumn, `@tenantId` fields and runtime registrations reported through\n`ObjectRegistry.registerOwnershipColumnSource()` — the tenancy package reports\n`registerTenantScopedClass(…, { field })` columns) raises\n`TenantIsolationError.naturalKeyOwnedElsewhere()` (`TENANT_ISOLATION_VIOLATION`,\nnever retried, never naming the other owner), also under `withSystemContext()`;\nNULL — or an ownership column the save leaves unset — counts as an owner. A\nsame-owner row is adopted by id (keeping its `created_at`, applied to the\ninstance only after the write succeeds), so `DO UPDATE SET` never rewrites a\nprimary key (no `ON UPDATE CASCADE` churn). Adoption needs an explicit natural\nkey: when the match is only through a slug `getSlug()` derived from\n`name`/`title`/`label` (a human value, not an identity), the new object keeps\nits id, moves to the first free `<base>-2` … `<base>-9` (one `slug IN (…)`\nprobe; then `<base>-<8 id chars>`) and INSERTs — whoever owns the taken row, so\nit is never refused. `getOrUpsert()`'s create path opts back into adoption\n(`_adoptNaturalKey`): a miss on a non-key field updates the row the derived\nslug names instead of adding `<slug>-2`. On PostgreSQL with no NULL conflict\nvalue the write is `INSERT … ON CONFLICT (…) DO NOTHING RETURNING id` first, so\na new key costs one statement and no pre-read, and concurrent first creates\nnever collapse: the loser re-reads and adopts (explicit key) or moves (derived\nslug, then straight to the id suffix), bounded retries. Embedded engines\n(serialized by the in-process write queue) and NULL-bearing keys read first.\nThe junction batch path falls back to per-item saves when\nits conflict target omits an ownership column present in the row (`tenant_id`,\nthe declared tenant column, or any `@tenantId`-marked field), and when any new\nitem's natural key already names a row the batch is not removing; its\n`DO UPDATE SET` never lists `id`, `created_at` or an ownership column. Strict\ninserts (`_insertOnly`) check NULL-bearing keys the same way, because NULLs\nare distinct in the unique index on SQLite, DuckDB and PostgreSQL < 15.\n\nA save with no owner (`tenantId: null`, typically under `withSystemContext()`)\nis deliberately NOT refused when a tenant row shares its slug under the\ntenant-led key: it lands as a separate global row, the global-default /\ntenant-override shape #2360 keeps. Refusing it would need a cross-owner read on\nevery global save and would break that pattern. A model that needs a slug\nunique across every owner declares explicit `conflictColumns` without the\ntenant column; then the guard refuses the colliding save instead.\n\n### Every generated index name is length-guarded before it leaves a path (#2374)\n\nPostgreSQL truncates identifiers beyond 63 bytes; two generated names sharing\nthat prefix can make `CREATE INDEX IF NOT EXISTS` silently skip an index.\n\n`schema/index-utils.ts` owns the guard, and it splits by who owns the name:\n\n- **Generated index, trigger and PL/pgSQL function names** →\n `shortenIdentifier()`. Deterministic `<head>_<digest><suffix>`, digest taken\n over the **full** original so a shared prefix still yields distinct names, and\n a recognised suffix (`_idx`, `_unique_idx`, `_key`, `_pkey`) preserved.\n- **Hand-declared `@smrt({ indexes: [{ name }] })`** → `assertIdentifierFits()`,\n a hard error in `validateDeclaredIndex()`. Renaming what a developer wrote is\n worse than refusing it, and `SchemaComparer` matches indexes **by name**\n first, so a 70-byte declaration could never match the 63-byte index\n PostgreSQL stored and `db:migrate` would emit `add_index` forever.\n- **Table and column names** are not guarded: PostgreSQL truncates their\n declarations and references consistently. `smrt-users` tests intentionally\n long table names; collision risk remains with the author.\n\n`enforceIdentifierLimits()` is the single call site per path, placed **after**\n`ensureReferenceColumnIndexes()` — nothing may lengthen a name after it. Doing\nthe shortening at the end rather than at each `indexes.push()` is safe because\nthe digest covers the whole original name, so entries distinct before shortening\nstay distinct after; the helper still throws if two ever collide. The migrate\nleg's `withConflictIndex()` (`registry/schema-builder.ts`) and the PostgreSQL\ntrigger-function name call `shortenIdentifier()` directly, because they compose\na name outside the generator's index list. Note that an over-long *table* name\nstill yields in-limit, distinct *index* names, because the shortening runs over\nthe whole composed name.\n\nThe digest is FNV-1a, not `node:crypto`: `index-utils.ts` is re-exported from\n`schema/utils.ts`, which exists to keep Node built-ins out of browser bundles.\nIt only has to be *stable* — a shortened name that changed between releases\nwould make every deployment drop and recreate the index — so the parity and\nunit tests pin the literal output rather than recomputing it. Unpaired\nsurrogates are folded to U+FFFD before both counting and hashing, so the digest\nis taken over exactly the bytes the driver transmits.\n\nExisting databases migrate **by name swap, without a rebuild**: the live index\nstill carries the name PostgreSQL truncated it to, the manifest now carries the\nshortened one, and the differ claims it by signature (columns + uniqueness +\npredicate), emitting nothing — including under `includeDroppedIndexes`. See\n`migrations/__tests__/index-drift.test.ts` and the PostgreSQL lane test\n`schema/issue-2374-identifier-length-postgres.optional.test.ts`.\n\nOut of scope, deliberately: constraint names PostgreSQL invents for itself. A\nCTI table's inline `UNIQUE` produces an implicit `<table>_<column>_key`, which\ncan exceed 63 bytes even when the table and column each fit. SMRT never names\nit, and PostgreSQL disambiguates its own truncations by appending a counter\nrather than collapsing them, so there is no silent-collision hazard there.\n\n### The `_smrt_` prefix does not mean \"system table\" (#2376)\n\n`SmrtClass` coalesces concurrent system-table initialization through completion\nof bootstrap, deferred compatibility, and vector setup (#3944). In-flight work\nis shared only by the same database handle. Distinct handles at the same URL\nretain database-level coordination: a caller-owned transaction may finish its\ninitialization statements before its tables become visible to another handle.\nAll same-handle waiters observe failure; the pending entry is removed so a fresh\ninstance can retry. Existing completed-success cache identities are unchanged.\nProvision system tables on a base connection before caller-owned transactions;\nconstructor initialization still preserves PostgreSQL's transaction lock lifetime.\n\n`bootstrapSystemTables()` owns nine hand-written tables; ~25 more `_smrt_*`\ntables belong to `@smrt()` models and are created by `db:migrate` (feature\nflags, prompt overrides, subscription plans, report schedules, field policies,\njobs). Never classify by prefix — use `SYSTEM_TABLE_NAMES`\n(`schema/system-table-shapes.ts`, derived from the DDL parse) plus\n`FRAMEWORK_OPERATIONAL_TABLES` / `RETIRED_SYSTEM_TABLES` in `system/schema.ts`.\nThe change-feed writer skipped by prefix, so clients syncing those domain\ntables through `_changes` never saw an update.\n\nEditing `ALL_SYSTEM_TABLES` requires bumping `SMRT_SCHEMA_VERSION` *and*\nappending to `SMRT_SCHEMA_DDL_CHECKSUMS` — the version gates the DDL replay, so\nwithout a bump no existing database ever applies the change. A new **column**\nadditionally needs an `addColumnIfMissing()` entry in `system/compatibility.ts`\n(`CREATE TABLE IF NOT EXISTS` is a no-op on an existing table).\n`system-schema-evolution.test.ts` enforces both, and asserts a legacy database\nupgrades to exactly the shape a fresh install gets.\n\n`_smrt_jobs` / `_smrt_job_events` are dual-owned: `db:migrate` creates them,\nthe compatibility pass reshapes them. On a fresh install bootstrap runs first,\nso their pass is deferred — `ensureDeferredSystemTableCompatibility()` re-runs\nuntil the tables exist, then stamps a `<version>+deferred-compat` marker. It\nruns OUTSIDE the bootstrap lock and swallows its own failures: those statements\ntarget tables the framework does not own, and inside the PostgreSQL transaction\none failure would roll back system-table creation with it. Only\n`ensureBootstrapSystemTableCompatibility()` (the tables the DDL itself creates)\nbelongs inside the lock.\n\nReconciling `_smrt_jobs.task_id` uniqueness reads the live index catalog, which\nis implemented for PostgreSQL and SQLite only; DuckDB and the JSON adapter keep\nthe redundant compat index rather than risk dropping the one that enforces the\nupsert conflict target. When reading a PostgreSQL catalog array, cast it\n(`attname::text`) and parse both shapes — a driver with no parser registered for\nthe array OID returns the raw `{a,b}` literal, and reading that as \"no columns\"\nsilently inverts an index-existence decision.\n\n## Same-package referential integrity uses two matching rails\n\n`@foreignKey(Target)` emits a physical database constraint when the target is\nin the same package and applies the same action through `SmrtObject.delete()`\nin `src/cascade.ts`. A shared delete-action resolver keeps both paths aligned;\nthe established generated `ON UPDATE CASCADE` default remains unchanged:\n\n| Reference | Default when `onDelete` is absent |\n|---|---|\n| Column is part of the referencing class's `conflictColumns`, and is not a `@tenantId()` field | `CASCADE` |\n| Polymorphic `(metaType, metaId)` association row | `CASCADE` |\n| Ordinary same-package reference | `NO ACTION` — deletion is refused while references remain |\n| Every `@tenantId()` field | Excluded from physical constraints and delete cascades |\n\nThe natural-key rule is what cleans junction rows up without any per-package\nannotation: a junction declares\n`@smrt({ conflictColumns: ['content_id', 'asset_id', 'relationship'] })`, so the\nrow is *identified* by the content and cannot outlive it. An ordinary child\n(`Order.customerId`) is keyed by `(slug, context)` and therefore defaults to\nimmediate `NO ACTION` unless it opts in explicitly.\n\n**`@tenantId()` is excluded even though it lands in `conflictColumns`.**\n#2360 leads every tenant-scoped class's *default* natural key with the\ntenant column, so without this exclusion, deleting one `Tenant` row would\nrecursively CASCADE through every tenant-scoped table in the schema that has\nnot declared its own `conflictColumns` — the overwhelming majority. The\ntenant column scopes ownership; it does not identify the row the way a\njunction's foreign key does. Detected via the `__tenancy.isTenantIdField`\nmarker on `FieldMeta` (`smrt-core` reads it structurally so it never depends\non `smrt-tenancy`). `@tenantId()` exposes no `onDelete` option today, so\nthis cannot currently be overridden per field.\n\nOn PostgreSQL `db:migrate` converges a live SMRT-owned constraint whose only\ndrift is its action (#3023, `SchemaComparer.findReplaceableForeignKey()`):\nbuild-then-swap via `renderForeignKeyReplaceStatements()` — staged\n`<name>_smrt_new` added `NOT VALID` and validated before the old constraint is\ndropped and the staged one renamed; `--postgres-safe` runs the VALIDATE and the\nswap outside the batch transaction. A change toward `CASCADE` / `SET NULL` /\n`SET DEFAULT` still applies (the manifest declares it) but carries a\n`warning` advisory that `db:diff`, `db:status` and `db:migrate` print as\nDESTRUCTIVE.\n\n### Deletes inside a caller-owned transaction\n\n`runCascadeDelete()` owns the cascade's atomic scope through\n`runInOwningCascadeTransaction()` in `src/cascade.ts` (#2824). A root handle opens\nits own transaction. A transaction handle on an engine with savepoints nests.\nWhen the engine refuses to nest (`NestedTransactionError`, raised by the SDK before\nthe callback runs), the cascade joins the enclosing transaction. This happens for\nframework-owned handles, for caller-owned **native DuckDB** transactions\n(`db.transaction()` scopes and `beginTransaction()` handles passed to\n`withDatabase()`), and for the bulk junction path (#2818). A transaction handle\ncounts as native DuckDB only after core has seen its root, through\n`initialize()`, `withDatabase()` or a framework transaction. Otherwise the\nhandle is never treated as native by guesswork, and the delete keeps the\n`NestedTransactionError` refusal. The caller's commit or\nrollback then decides the outcome of the cascade, the owned `_smrt_contexts` and\n`_smrt_embeddings` cleanup, and the target row.\n\nThere is no savepoint, so a cascade that fails part-way cannot be undone on its\nown. On native DuckDB the framework therefore aborts the joined transaction\n(`SELECT error(...)`) before rethrowing, but only if the cascade has already\nissued a write. The SDK's DuckDB `delete()` reports a constant affected count,\nand the owned-row cleanup always runs. In practice, then, any failure after the\ndepth-0 RESTRICT / NO ACTION preflight aborts the transaction. That includes a\nrevision or write-guard conflict on the row itself, even one that changed no\nrows. DuckDB then rejects every later statement with \"transaction\nis aborted\", and its `COMMIT` persists nothing. A caller that swallows the error\ntherefore cannot commit a half-applied cascade, and the callback is never\nreplayed. By contrast, a refusal raised before the first write, such as the\nRESTRICT / NO ACTION preflight, leaves the caller's transaction usable.\nJSON-adapter caller transactions keep the detectable\n`NestedTransactionError` refusal, because nothing can poison them.\n\n`@crossPackageRef()` remains runtime-only: it registers relationship loading\nand indexes but deliberately emits no physical constraint, avoiding circular\npackage DDL. Tenant markers follow the same non-constraint rule because a\ntenant is a scope, not an ownership edge.\n\nSame-package archival identifiers may explicitly use `@foreignKey(Target, {\nconstraint: false })`: preserve relationship loading and indexing, but omit\nphysical constraints, schema dependencies, and application cascade/preflight\nso the identifier survives parent deletion. Document the retention reason at\nthe field; ordinary references remain constrained.\n\nFor a same-package relationship whose semantics are portable but whose physical\nconstraint shape is not, `@foreignKey(Target, { constraint: { engines: [...] } })`\nis the public exception. The allowlist scopes physical DDL and dependency\nplanning only. Relationship metadata, UUID representation, derived indexes, and\nthe application delete rail remain canonical on every engine. Empty or unknown\nengine lists fail closed; unannotated unsupported DuckDB cycles and actions keep\ntheir actionable refusal.\n\nEvery schema creation entry point uses the same deterministic dependency\nplanner. Parents are created before children. SQLite keeps cycle constraints\ninline because it can create them safely. PostgreSQL creates mutually dependent\ntables first and adds their named constraints afterward. DuckDB refuses cycles,\nself-references, `CASCADE`, and `SET NULL` with an actionable error because its\ncurrent ALTER/constraint support cannot enforce those shapes safely.\n\nRollback drops children before parents, removes deferred PostgreSQL cycle\nconstraints first, and defers SQLite checks while dropping populated cycles.\nAggregation that filters a parent also removes a retained child's physical FK.\nPostgreSQL deferred constraint adds are idempotent. Generated `ON UPDATE\nCASCADE` remains the default; DuckDB/JSON must refuse unsupported actions\nrather than silently stripping them.\n\nFor existing tables, PostgreSQL checks the exact child table/column against the\nexact referenced table/column before adding a constraint as `NOT VALID` and\nthen validating it. The probe uses distinct child/parent aliases and, when both\nmanifest columns are UUIDs, bases its guarded casts on both live column types:\nmatching live types compare directly, while a legacy text side is shape-checked\nbefore casting. This keeps a self-reference or malformed legacy value from\ninvalidating the query. An orphan stops migration with detector SQL and an\nexecutable repair suggestion: nullable FKs are cleared, while required FKs\nrequire an explicit operator decision to reassign the reference or deliberately\nremove a child row after preserving its required data. A probe failure is\nsurfaced as a database/framework error, never misreported as orphan data.\nWhen the referenced parent is absent but scheduled for creation by the same\n`compare()` batch (#3240), PostgreSQL first proves the entire existing child\ntable empty instead of querying the absent parent. Nonempty children are blocked\nwithout executable repair SQL: create and populate the parent deliberately,\nthen rerun the ordinary orphan preflight. The exception never applies to a\nstandalone `compareTable()` or an undeclared missing parent, and incompatible\ncolumn types, missing target columns, and existing conflicting constraints stay\nblocked. Planned-parent and newly added child-column types are compared using\nthe PostgreSQL DDL mapping (for example, `TIMESTAMP` becomes `TIMESTAMPTZ`,\n`INTEGER` becomes `BIGINT`); existing columns retain their live physical types. `NOT VALID` plus `VALIDATE CONSTRAINT` remains the authoritative check;\na writer racing the preflight causes the atomic migration to roll back.\nSQLite emits inline `REFERENCES` when adding a new nullable column with no\nnon-NULL default or backfill (#3795): existing rows receive NULL and the database\nenforces subsequent writes. An existing-column constraint or another column\nshape still requires a deliberate table rebuild. DuckDB reports the unsupported ALTER\npath. Neither engine treats an unsupported constraint addition as a successful\nno-op.\n\n**Counting orphans is a separate, read-only concern (#2753).**\n`renderForeignKeyOrphanDetector({ limitOne: true })` makes the probe a gate;\n`schema/foreign-key-orphan-report.ts`'s `collectForeignKeyOrphanCounts()` runs\nthe identical predicate as `COUNT(*)` (via the same function's `countOnly`\noption, so the FROM/JOIN/WHERE clause is never duplicated) for every\nmanifest-declared foreign key, and reports child/parent table and column, the\nlive count, and child-column nullability. A relationship whose child or parent\ntable does not exist live is skipped and reported separately rather than\nfailing the whole run. It never repairs anything — the CLI surface is\n`smrt db:orphans` (`packages/cli/src/commands/db-orphans.ts`), and `db:status`\nprints a compact per-foreign-key summary when any count is nonzero.\n\n**Diagnostics are latency-bound, not query-bound.** Each `getTableSchema()` is\nfive catalog round trips, so `compare()` and the orphan report read each table\nat most once per run, with bounded concurrency on PostgreSQL\n(`schema/bounded-concurrency.ts`: 8 introspections, 4 data-scanning probes; 1\nelsewhere). `compare()` reads partial-index predicates in one `pg_indexes`\nquery, and `db:status` seeds the orphan report with\n`SchemaComparer.getLiveSchemaSnapshot()`. Never add a per-table or\nper-relationship query to these paths without batching or bounding it.\n\n**Partial apply and opt-in orphan disposition (#2748).** The batch an\nunflagged `db:migrate` attempts already excludes every blocked change (a\nFK-with-orphans, a manual `type_upgrade`/`alter_column`) — those never leave\n`SchemaDiff.changes` as executable SQL, so they never enter the atomic\ntracker batch on their own. `--apply-unblocked` adds a second, opt-in\nsafety layer on top of that exclusion: the CLI partitions the remaining\n\"safe\" bucket by *dependency*, not just by whether a change is individually\nblocked. `computeBlockedColumns()` (`packages/cli/src/commands/\ndb-migrate-actions.ts`) reads every manual intervention's blocked\n`table.column` identity; `partitionUnblockedMigrations()` then withholds any\n`add_index`/`add_foreign_key`/`alter_column`/`drop_column` that reads or\nwrites one of those columns (an index on a column whose type upgrade is\nblocked, or a FK whose child *or* parent column is blocked) and applies\neverything else through the normal transaction/tracker path. Off by\ndefault: without the flag, the batch stays exactly what it always was.\n\nThe FK-orphan advisory branch in `compareForeignKeys()` (`migrations/\ndiffer.ts`) tags its `SchemaChange` with `orphanBlocked: true` and\n`orphanNullable` (mirroring `renderForeignKeyOrphanRepair()`'s own\nnullable/not-nullable branch) so a caller can identify this specific\nmanual-intervention reason without parsing `advisory.message`. `db:migrate\n--null-orphans` reads that tag: for a nullable child column it runs the\ndiffer's own suggested `UPDATE ... SET <column> = NULL` (from\n`advisory.suggestedSql[1]`, the exact statement the differ already\nrendered — never re-derived), then rebuilds the `ADD CONSTRAINT NOT VALID` +\n`VALIDATE CONSTRAINT` pair via the shared `renderForeignKeyAddStatements()`\nhelper (`schema/foreign-key-ddl.ts`, also used by the differ's own safe-add\nbranch) and adds it through the normal tracker path. A NOT NULL child column\nkeeps the unconditional \"Manual repair required\" refusal — nulling isn't an\noption and the flag never deletes rows. `planOrphanDispositions()` fails\nclosed (routes to \"not nullable\"/no-op) if an orphan-blocked change is\nmissing its `foreignKey` definition or its `advisory.suggestedSql` pair, on\nthe same \"report and withhold rather than guess\" principle as the rest of\nthis gate.\n\n`getForeignKeyOrphanOptions()`'s `nullable` reads BOTH sides: the manifest\nAND the live column (`dbSchema.columns[...].notNull`), never the manifest\nalone (review, #2748). A manifest relaxed to nullable while the live column\nhas not converged yet — the same drift `--relax-columns` handles for plain\ncolumns — would otherwise report `nullable: true` from the manifest side\nonly, and `--null-orphans` would attempt an `UPDATE` PostgreSQL rejects\noutright (`23502`), failing the whole atomic batch instead of refusing just\nthat one relationship. Nullable only when both sides agree; a real\nNOT NULL on either side keeps the unconditional refusal.\n\n### Pre-R11 `text` ids converge to `uuid` before any FK statement (#2608)\n\nPostgreSQL FK columns must have matching physical types. Legacy text IDs may\nmeet newer native UUID references; neither SQLite (text UUID by design) nor\nDuckDB (no in-place type rewrite) emits this convergence.\n\n**The runtime guard fails closed.** `SchemaManager.ensurePostgresForeignKey()`\nreads both live column types and refuses to emit `ADD CONSTRAINT` when they\ndisagree, naming both columns, both live types, and the repair. It deliberately\nskips the orphan probe in that case: across mismatched types the probe answers\na question about casted values, not about the constraint being refused, and it\nhas to run again after the columns converge anyway.\n\n**The differ converges the columns.** `planUuidConvergence()`\n(`src/schema/uuid-convergence.ts`) groups every manifest relationship that\ndeclares UUID on both sides into connected components and converges a component\nonly when the live database already proves the target shape — at least one\nmember is native `uuid`. A component that is `text` on *every* side is the\ntolerated pre-R11 deployment and is left alone; its foreign keys are\ntype-compatible today, and the R11 uuid/text equivalence in\n`migrations/differ.ts` keeps it out of the column diff.\n\nConverge entire relationship components, including siblings and self-references;\nan unreferenced legacy text ID retains its UUID/text equivalence tolerance.\n\nThe planner never coerces data. Before emitting anything it probes each column\nit would rewrite for values that are not uuid-shaped (the same `~*` canonical\npattern the orphan probe uses) and refuses the whole component — with the count\nand a sample value — if any exist, if the probe cannot run, if a member carries\nsome third physical type, or if a live foreign key still constrains a column\nthat must change. `@happyvertical/sql` introspection does not expose live\nPostgreSQL constraint names, so SMRT cannot drop and re-add those constraints\nfor you: drop them deliberately, rerun the migration to converge, and let SMRT\nre-add the manifest constraints.\n\nRefusals are reported, not silent. Each one becomes a warning advisory with no\nexecutable SQL, so it reaches `unactionableChanges` / `hasManualDrift` and\n`db:status` shows **blocked: incompatible column types** instead of *pending*.\nThe same check runs per relationship in `compareForeignKeys`, so a foreign key\nwhose live types will still disagree after this run's conversions is reported\nblocked rather than emitted as pending DDL that cannot succeed.\n\nThe planner also inspects tables the manifest no longer declares. A live\nforeign key from an orphan table onto a column that must convert still blocks\n`ALTER COLUMN … TYPE`, so the differ introspects every existing table — not\nonly the manifest ones — whenever there is at least one conversion candidate,\nand reports the dependency instead of emitting DDL PostgreSQL would reject. An\nalready-converged database has no candidates and pays nothing.\n\nOrdering is a contract. Conversions carry `SchemaChange.phase =\n'pre_foreign_key'`, and the orchestrator emits them **before every CREATE TABLE\nand every foreign-key statement in the batch**. Both halves matter:\n`planForeignKeyCreation()` only defers the constraints inside a mutual cycle,\nso an acyclic new child table keeps its foreign key *inline in `CREATE TABLE`*\n— a brand-new `uuid` child pointing at a legacy `text` parent fails exactly\nlike an existing one, before the parent could be converted. Conversions only\never rewrite columns that already exist, so leading the batch is always safe. A\nlive `DEFAULT` on a converting column is dropped first (PostgreSQL refuses\n`ALTER COLUMN … TYPE` when the default cannot be cast); the ordinary default\ncomparison re-establishes the manifest default on the next run.\n\nThere are **two** batch builders and both order on that marker:\n`collectStatementsFromDiff()` in `migrations/orchestrate.ts` (used by\n`getPendingSchemaStatements` / `migrateSmrtSchemas`) and the tracker batch\n`db:migrate` assembles by hand in `@happyvertical/smrt-cli`\n(`commands/utilities.ts`). `partitionSchemaChanges()` carries\n`SchemaChange.phase` onto `MigrationAction.phase` so the CLI can partition the\nsame way, in both the applied batch and the `--dry-run` preview. If you add a\nthird consumer, order it the same way.\n\nConvergence entries carry the manifest column definition. Every `type_upgrade`\nconsumer reads `SchemaChange.column` — `partitionSchemaChanges()` in\n`@happyvertical/smrt-cli` skips an entry without one — so a conversion missing\nit would drop out of the `db:migrate` batch while `compareForeignKeys()` still\nassumed the converged type. Refused convergences carry the same column plus an\nadvisory and no SQL, and the CLI routes them to the report-only advisories\nrather than to manual interventions or the tracker.\n\nThe uuid wording is gated on the manifest. Both the runtime guard and the\nstatus planner reach their incompatible-type branch for *any* mismatched pair,\nnot only uuid/text. A `USING …::uuid` repair is suggested only when the\nmanifest declares UUID on both sides **and** a live side is actually `text`;\notherwise the diagnostic names the two live types and asks the operator to\nalign them deliberately.\n\nThe conversion is one-time and idempotent: once the column is native `uuid`,\nthe component is uniformly UUID and the planner emits nothing.\n\n### Application cascade invariants (`src/cascade.ts`)\n\n- Rebuild the registry-derived plan on every delete; manifests register lazily.\n Caching requires invalidation across every registration path.\n- Plan from `getResolvedQualifiedName()`. Every registered polymorphic\n association class participates, since its runtime target can be any class;\n `CascadePlan.isEmpty` requires no such class anywhere and no typed references.\n Only an empty plan skips the transaction.\n- Cascades are set-based: child hooks/interceptors and change-feed tombstones do\n not run. Only the explicitly deleted object runs its lifecycle. RESTRICT\n checks precede mutations; the parent DELETE and cascades share one transaction\n where supported, so deeper refusals roll back.\n- Derived `_smrt_embeddings` / `_smrt_contexts` cleanup matches IDs AND\n `ownerClassCandidates()` (qualified and simple STI member names), never IDs\n alone: unrelated text-ID classes can collide. Cleanup failures are logged,\n not raised, because these tables may not exist in older databases.\n- Never cascade append-only `_smrt_changes`, `_smrt_ai_usage`, `_smrt_signals`,\n or dispatch logs; deleting change tombstones would break sync.\n\n### Retention (`src/system/retention.ts`)\n\n`runRetentionSweep(db, policy)` runs five built-ins in fixed order (`changes`,\n`ai-usage`, `contexts`, `dispatch`, `run-once-claims`), then\n`registerRetentionTask()` contributions. A failed task records its result and\ncontinues; a missing table is `unavailable`. The `globalThis` registry avoids\nsplit registrations under duplicate core resolution; package tasks exist only\nafter importing the package. CLI prune optionally imports jobs/users.\n\nDefaults are opt-out: changes 30 days, AI usage 90 days, completed dispatch 30\ndays/failed dispatch 90 days, contexts by `expires_at`, run-once claims 30\ndays on `completed_at` (#3080; `in_progress` rows never match — the window is\na replay deadline for `runOnce()`, not just disk hygiene, see\n`agents/run-once.md`). `smrt.configure({ retention })` tunes built-ins;\ncontributed tasks own their defaults/options.\nJobs defaults are 7 days terminal, 30 failed, 30 events via\n`registerJobRetentionTasks()` or runner `retention.jobs`; expired credentials\nhave no extra window. Disable a table/task with `false` or the whole policy with\n`enabled: false`; CLI `--skip` and runner configuration expose these controls.\nTask names use package prefixes (`jobs-records`, `users-sessions`).\n\nCore does not schedule sweeps. TaskRunner runs every six hours, first one\ninterval after start; `retention: false` opts out. `smrt db:prune` supports cron.\nEvery prune counts then deletes using the same predicate; `rowCount` is not\nportable. The two statements deliberately are not transactional, so counts are\napproximate under concurrency. Overlapping change/AI-usage bounds exclude rows\nalready counted, including dry runs.\n\nRetention indexes belong in system DDL and its versioned replay:\n`_smrt_contexts(expires_at)`, `_smrt_ai_usage(tenant_id, created_at)`, dispatch\n`(status, processed_at)` / `(status, updated_at)`, and\n`_smrt_run_once_claims(status, completed_at)`. Jobs `(status, completed_at)`\nbelongs in `ensureJobsSystemTableCompatibility()` on each collection initialize,\nsince decorated jobs tables do not exist at bootstrap.\n\nExpiry remains prune-side for object/collection `recall()`/`recallAll()`;\n`LearningMemory` separately filters it at read time.\n\n## Supported generation surfaces\n\nThe four entry points above are the supported generator paths. The unused AST\n`generateSchema(objectDef)`, `smrt:schema` / `@happyvertical/smrt-virt-schema`\nvirtual modules, and project-specific `SchemaOverrideSystem` were removed.\n\nKeep published `SchemaDefinition.triggers`, `TriggerDefinition`, and the DDL\nstrategies' trigger renderers: they support hand-authored new-table schemas.\nGenerated `@smrt()` schemas always emit `triggers: []`; no decorator populates\nit, `save()` maintains `updated_at`, and the differ never retrofits triggers.\nAdding live trigger generation requires a migration rollout design.\n`_smrt_signals` and database-persisted registry APIs are retired; see\n`RETIRED_SYSTEM_TABLES` in `src/system/schema.ts`.\n\n## PostgreSQL migration execution\n\n`MigrationTracker.applyAll({ atomic: true })` sets local lock/statement timeouts\nbefore any DDL. `postgresSafe: true` commits non-index DDL atomically, then runs\nindexes CONCURRENTLY on `db.acquireSession()` so settings and DDL share a\nconnection. This mode is not atomic. Unfinished indexes are `failed`, not\n`running`; `[smrt: concurrent-index phase 1 committed]` in `error_message`\nallows reruns to resume index work without replaying committed DDL. Inspect\n`pg_index.indisvalid` and drop INVALID indexes before rebuild (`pg_indexes`\nalone cannot detect them). Operational commands: `packages/cli/AGENTS.md`.\n\n### Subclass conflict identities (#3456)\n\nAn STI child's explicit `conflictColumns` owns a partial unique index restricted\nto its qualified discriminator. The root conflict index excludes these children;\nregistry and manifest paths emit the same predicates. Natural-key lookups include\nthat scope, PostgreSQL atomic inserts include the index predicate, and adopted\nrows update by id. Bulk writes use per-item guards for these targets. Normal\nmigrations replace the root index by name and add the child index; repair any\nexisting duplicate child natural keys before migrating. Stop old writers for this\nindex migration and load the complete consumer manifest on every shared-table\nwriter before resuming: a provider-only manifest lacks the child predicates.\nDuckDB/JSON reject\nthese schemas because partial unique conflict identities cannot be enforced.\n\n## Consumer physical table bindings (#3660)\n\n`smrt.tableNames` maps exact qualified model identities to deployment-local\nphysical tables. `registry/table-mappings.ts` projects schemas for both runtime\n`getSchema()` reads and merged migration planning, including index names and\nrelationship targets. Never remap by a raw old table name: two unrelated models\nmay own that name in their respective packages. Bind STI roots only; sensitive\nmodels are refused. Well-formed bindings may precede model registration;\npartial registries never need unrelated models merely to satisfy configuration.\nRuntime object/collection getters recheck ownership even for cached table\nnames; validation is memoized by registry generation and binding configuration.\nConfiguration must load before first database use and stay\nfixed for that process. This is not an implicit data migration; see\n[consumer table bindings](../../../docs/content/table-bindings.md).\n"
|
|
1182
1182
|
},
|
|
1183
1183
|
{
|
|
1184
1184
|
"path": "agents/deployment-phases.md",
|
|
@@ -1188,7 +1188,7 @@
|
|
|
1188
1188
|
{
|
|
1189
1189
|
"path": "agents/change-feed.md",
|
|
1190
1190
|
"module": "change-feed",
|
|
1191
|
-
"content": "# smrt-core/change feed\n\nModule semantics for `src/change-feed.ts`. 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## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry (a jittered pause of at most 40 ms between attempts, so two tight-loop writers cannot starve one another — #3062) — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- Staged appends (PostgreSQL, #2649): `MAX+1` costs a *wait* — the loser of a primary-key race waits for the winner's whole transaction — so an append inside a caller transaction that already wrote rows would let a long write transaction and an ordinary concurrent request form a real lock cycle (`40P01`, found downstream in willgriffin/willgriffin.dev#457). `_smrt_append_change` therefore checks `pg_current_xact_id_if_assigned()` (PostgreSQL 13+): when the caller already has a transaction id it stages the entry in `_smrt_changes_pending` (identity key, conflicts with nothing, never waits, still rolled back with the caller) and `appendChange()` returns **`null`** instead of a sequence. `_smrt_drain_changes()` moves *committed* staged rows into `_smrt_changes` with contiguous `MAX(seq)+row_number()` sequences under a **try-only** advisory lock, so the drain never waits either. Draining is driven from JavaScript, never inside the append helper: an entry sequenced invisibly server-side would get no live `_events` signal while the append's own signal carried a higher sequence, and a subscriber resuming from that `Last-Event-ID` would skip it permanently. `drainChangeFeed(db)` is called best-effort by `getChangesSince()`, by `pruneChangeFeed()`, and by the append path itself (throttled to one drain per 250 ms per database, bypassed immediately after this process staged an append). The append path issues ONE drain statement (a single bounded pass) and nothing else — the caller may own the surrounding transaction, and a second statement there could fail and abort it behind the feed's own error-swallowing (#2026); the helper's whole body, preflight included, sits inside its exception boundary for the same reason. It still settles that drain's signals in order without spending a statement: a drain that allocated anything assigns a transaction id, so an append that then takes the DIRECT path proves the drain committed, and its signals publish ahead of the append's own; a deferred append proves nothing and queues them. Signals for drained entries publish only after a follow-up probe proves the drain committed, so an uncommitted drain can never advertise a sequence a rollback releases for reuse. `getChangesSince()` additionally holds back everything the handle's own drains allocated while a transaction id is still assigned at read time (a per-handle watermark, so later reads in the same transaction keep holding back), and `bootstrapSystemTables()` refreshes the helpers before its schema-version fast return so an already-stamped database does not keep the deadlocking function. Consequences: the cursor guarantee is unchanged (one `MAX+1` writer at a time, only over committed work), but a staged entry becomes visible one drain after its transaction commits, its log position is drain order rather than statement order, and it publishes its live `_events` signal at drain time (post-commit — the old pre-commit signal could describe a rolled-back write). SQLite/DuckDB keep the direct insert unchanged. Residual: an append issued as a transaction's *first* statement still allocates inline — it holds no row locks then, so it cannot close a cycle, but it can make other direct appenders wait for that commit; issue explicit `bumpChangeFeed()` calls after the write or outside the transaction. A deployment that writes only through transactions and reads the feed from a connection that cannot write must schedule `drainChangeFeed()` on a writable one.\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table **plus that table's staged-but-undrained count**, so a staged write still moves the ETag and cannot false-304 a client; the sum is monotonic because draining `n` staged rows raises the table's `MAX(seq)` by at least `n`, and both terms are read in ONE statement — separate reads let a drain be counted twice, minting a version a later write re-mints; replica-stable, with no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n\n## Credential-bearing tables are never disclosed (#2937)\n\n`src/change-feed-sensitivity.ts` is a leaf module (it imports nothing, so\n`change-feed.ts`, `change-signals.ts` and the registry can all consult it\nwithout a cycle) holding the tables whose **row id or payload is a secret**. A\ntable joins it two ways: by name, via the baseline\n`CHANGE_FEED_CREDENTIAL_TABLES` (`sessions`, `users_cli_auth_requests`,\n`users_magic_link_tokens`, `magic_link_tokens`, `api_keys`,\n`nostr_identities`), or because a class declared `@smrt({ sensitive: true })`\nand registration pushed its resolved table name across\n(`declareChangeFeedSensitiveTable`, called from both the decorator and the\nmanifest-stub registration paths). Both are needed: the declaration is the\npackage's own contract but only binds in a process where that package\nregistered, while the name baseline is all the read path has when serving a\ndatabase another process writes. The set is **monotonic** — `sensitive: false`\nis not an opt-out and nothing removes a name — so a consumer whose domain\ntable is named `sessions` loses feed coverage for it and must rename it via\n`@smrt({ tableName })`.\n\n`isChangeFeedObservableTable()` ANDs the sensitivity check with\n`CHANGE_FEED_EXCLUDED_TABLES`, and the refusal is enforced at four points, not\none: the interceptor write path; `appendChange`/`appendChanges`, the lowest\nwrite API, so `bumpChangeFeed()` and any other escape hatch are covered (a\nrefused append returns `null`, indistinguishable from a staged one);\n`deliverLocally` in `change-signals.ts`, which also catches a signal broadcast\nby a peer replica running an older build; and **`getChangesSince()`**. The\nread-side refusal is what makes upgrading sufficient — rows an earlier version\nalready wrote stay in the log but are never served. A caller-named sensitive\ntable is dropped from the `tables` filter rather than erroring, so nothing\nconfirms the table exists, and a request naming *only* sensitive tables gets\nan empty page rather than an unfiltered one. Filtered rows never hold the\ncursor back: an exhaustive page still advances to the served horizon.\n\n`pruneChangeFeed()` additionally deletes sensitive rows below the horizon on\nevery sweep, ahead of either retention bound, so credentials do not sit at rest\nfor the retention window. That deletes from the middle of the retained run,\nwhich the age bound goes to lengths to avoid — permissible only because these\nrows are unservable on every read path, so no page ever contained them and no\ncursor can fall into the gap. It **does** move `floor`, and usually will: a\nsession is created before the first domain write and re-saved on every request,\nso the lowest retained sequences are typically credential rows. `floor` is read\nlive (`MIN(seq)`) on every `getChangesSince` and never cached, so the only\nconsequence is that a cursor below the new floor is answered `resyncRequired`\nand refetches — including a `since=0` client on a never-pruned feed. Extra\nresyncs, never a missed change. The horizon does not move, because the newest\nentry is retained even when it is sensitive; that preserves \"a non-empty feed\nis never emptied\" and means one credential row can linger until the next write\nmoves the horizon past it.\n\nBecause a non-observable table appends nothing, it also has no ETag source:\n`getTableVersion()` would pin at whatever an older build last wrote and then\nfall back to the global horizon, which moves only on unrelated traffic — so a\nconditional GET could answer `304` for a revoked API key or a rotated session.\n`getTableVersion()` therefore returns a deliberately **unrepeatable** value for\nsuch a table — 48 bits of `crypto` randomness per call, not a per-process\ncounter, because two replicas' clock-seeded counters drift through each other\nand the ETag carries no per-process entropy — so no *concrete* `If-None-Match`\ncan match and every such read is a full 200. It is the single point every ETag\npath goes through (the runtime `APIGenerator`, the generated\n`conditionalVersionedRead`, and route files an older generator already\nemitted), so no call site needs a special case. A wildcard `If-None-Match: *`\nstill 304s; the read paths evaluate it only after the payload is built, so it\ndistinguishes nothing an unconditional request would not.\n\nSTI sensitivity is table-wide. Manifest generation and registration refuse a\nhierarchy mixing classes declaring `sensitive: true` with classes omitting it or\ndeclaring false (`CONFIG_STI_MIXED_SENSITIVITY`, #3456). Mark every class sensitive\nor put the sensitive and public models on separate tables. This preserves the\nhistorical-read and live-signal protections without introducing a discriminator\ninto old feed rows. A uniformly sensitive hierarchy declares its shared table;\nthe authoritative write-path check still verifies the instance's actual table.\n\nScope note: the generated `_changes`/`_events` routes — both the SvelteKit\nroutes and the runtime REST generator's — authorize on an authenticated\nprincipal plus tenant scoping (`getTenantScopedChangesSince`, fail-closed) by\ndefault. A consumer-supplied table/row authorization seam beyond that —\n`change-feed-authz.ts` (#3020, below) — is optional and off until registered.\n\n## Consumer table/row authorization seam (#3020)\n\nTenant scoping keeps one tenant from seeing another's rows, but it cannot\nexpress \"table X is readable by some principals of the tenant but not others\"\nor \"principal P only sees their own rows of table X\" (e.g. a bay-tablet\nstation versus the back office). `?tables=` on `_changes` was always a\nclient-chosen filter, never enforcement — a table the client did not ask for\nwas withheld only because it did not ask, not because it could not have.\n\n`src/change-feed-authz.ts` closes that gap with two independent, optional\nhooks, dependency-inverted onto `globalThis` exactly like\n`resolveDispatchTenantScope` (`dispatch/tenant-resolver.ts`), so a consumer\nregisters them once with no change to the generated \"DO NOT EDIT\" route files.\nBoth hooks are evaluated identically by **every** transport — the generated\nSvelteKit `_changes`/`_events` routes AND the runtime REST generator's\n`_changes`/`_events` (`generators/changes-route.ts`, `generators/events-route.ts`)\n— so a consumer serving both cannot register a hook that protects one and\nleaks through the other:\n\n- `setChangeFeedAuthorizer(authorizeChangeFeed)` — table-level.\n `authorizeChangeFeed({ locals, request, tables }) => allowedTables` returns\n the tables the requester may read from the feed at all; the result is\n INTERSECTED with the client's `?tables=` filter (never unioned), so a table\n the hook does not name is never queried, let alone returned.\n- `setChangeFeedEntryVisibility(isChangeFeedEntryVisible)` — row-level, for\n scope narrower than a whole table. Applied per entry, after the table\n filter, before anything is serialized — including a live `_events` signal.\n\n`locals` is SvelteKit's `event.locals`; `request` is SvelteKit's\n`event.request` or, on REST, the `authMiddleware`-processed `Request`. REST\nhas no `locals` concept, so REST routes pass `locals: undefined` and a\nREST-hosting consumer identifies the principal from `request` instead\n(whatever the `authMiddleware` attached — headers, a decoded token, etc.).\nEvery generated route supplies `request`.\n\nBoth hooks are optional and independent; unregistered, every transport\nbehaves exactly as before (tenant-scoped only). A hook that throws or returns\nsomething other than the documented shape fails closed to \"authorizes\nnothing\" for that call — same posture as a throwing `resolveDispatchTenantScope`\nresolver, and the same 200-empty-page shape the sensitivity filter above\nalready gives a request naming only tables it may not see (never a 5xx, which\nwould tell a caller its own hook is broken — indistinguishable from \"denied\"\nfrom the caller's side).\n\nEvery filter narrows the *returned* `changes`; nothing ever touches\n`cursor`/`resyncRequired`/`resyncCursor` — mirrors the sensitivity filter's\n\"filters affect what is returned, never how the cursor advances\" and for the\nsame reason: a client must advance past a denied entry without re-polling it\nforever, and the entry's row id/timing must never be inferable from a stalled\ncursor. Table denial sets `getChangesSince`'s explicit `denyAllTables: true`\noption rather than passing an empty `tables` array, because `getChangesSince`\ntreats an omitted/empty `tables` as \"no filter\" and would otherwise widen the\nread. `denyAllTables` skips the `table_name IN (...)` clause (and the query)\nentirely — an earlier version instead passed a guaranteed-no-match sentinel\ntable name through that clause, which broke on PostgreSQL (it rejects a NUL\nbyte in a text parameter, turning a denied request into a 500 instead of the\nrequired 200 empty page, #3020 P1 follow-up). `denyAllTables` still runs every\nresync/pruned-cursor check unfiltered first, so the real horizon computation\nruns and the cursor still advances — it just matches zero rows.\n`change-feed-authz.ts`'s `isChangeFeedDenyAll()` is the one place that decides\n\"empty allow-list\" (deny all) vs. \"no hook registered\" (no filter); both\n`readAuthorized()` (pull side) and `buildChangeEventStream()`'s catch-up loop\n(push side) call it rather than re-deriving the distinction. With NO hook\nregistered, `resolveAuthorizedChangeFeedTables()` normalizes an explicit empty\nrequested `tables: []` to `undefined` before that decision runs: `?tables=`\nwith nothing in it is documented as equivalent to an omitted filter, and\nwithout a hook there is no authorized answer to narrow anything with — feeding\n`[]` through unchanged would let `isChangeFeedDenyAll()` read it as \"deny\nevery table\" and silently break the promised unchanged default (#3020\nfollow-up). Once a hook IS registered, its own answer is what decides\ndeny-all, exactly as above.\n\nA registered hook's returned table list is also validated for USABLE\nstrings, not just string-typed ones: any entry that is empty or contains a\nNUL byte fails the whole call closed to `[]` (deny all), the same posture as\na non-array or non-string result. This closes the same NUL-byte hazard the\n`denyAllTables` sentinel removal above fixed for a *synthesized* value — here\nthe NUL byte would come from the hook's OWN answer, which otherwise flows\nstraight into the ordinary `table_name IN (...)` clause and 500s on\nPostgreSQL (#3020 Copilot follow-up).\n\n`getAuthorizedChangesSince()` / `getAuthorizedTenantScopedChangesSince()` wrap\n`getChangesSince()` / `getTenantScopedChangesSince()` with both hooks applied;\nBOTH `_changes` routes (SvelteKit's `vite-plugin/changes-route.ts` and REST's\n`generators/changes-route.ts` `handleChangesRoute`) call the tenant-scoped\nvariant, and `buildChangeEventStream()` (`change-signals.md`) — shared by\nBOTH `_events` routes — applies the same hooks to catch-up replay and live\nsignal delivery, resolving the table allow-list ONCE at connection open\n(mirrors captured tenant scope).\n\n## Compatible bulk mutations (#2818)\n\n`appendChanges(db, entries)` validates all inputs before writing and returns one\nsequence (or PostgreSQL staged `null`) per entry in input order. A nonempty batch\nuses one client SQL statement and one set-based insert. The PostgreSQL\n`_smrt_append_changes(jsonb)` helper chooses the staged/direct path once before\nits own writes assign a transaction ID, and retains the same exception isolation\nas the single-row helper. Bootstrap refreshes its body marker and runtime grants\ninclude this exact helper signature.\n\nThe registered feed interceptor explicitly supports grouped lifecycle completion.\n`recordInstanceChanges()` retains observable-table filtering, row/tenant IDs,\nper-row operations, and best-effort failure handling. It publishes each direct\nsequence in order; staged entries publish through the existing committed drain.\nA batch is never replaced with a table-level bump or a single aggregate event.\nThe junction caller bounds its batch; the low-level append API does not split\none logical append into partially committed chunks.\n"
|
|
1191
|
+
"content": "# smrt-core/change feed\n\nModule semantics for `src/change-feed.ts`. 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## Change Feed (#1758)\n\nAdapter-agnostic change-observation spine (`src/change-feed.ts`) — the server half of the client/mobile sync contract (PRD #1755):\n\n- `_smrt_changes` system table: one append per framework save/delete via a GlobalInterceptors writer registered at framework init. Deletes are tombstones (`operation: 'delete'`). `_smrt_*` tables are skipped. Feed-append failures log and never fail the user's write. On PostgreSQL, `_smrt_append_change` catches the INSERT in an exception subtransaction and returns SQLSTATE as data, so swallowing/retrying a best-effort failure cannot leave a caller-managed transaction aborted with `25P02` (#2026); the feed row still commits or rolls back with the caller transaction. Raw-handle/read initialization checks for both the table and helper before issuing any DDL; a cold schema/helper install acquires the same transaction-scoped `('smrt', 'system-tables')` advisory lock as bootstrap before its first DDL and rechecks inside one server-side statement, while schema migration/bootstrap remains the authoritative replace path. No dirty-check: a field-unchanged `.save()` appends a spurious `update` entry (diff-aware paths like `getOrUpsert`/sync-apply short-circuit before `save()` and append nothing); subscribers must tolerate spurious entries — they are convergent.\n- Sequences: allocated as `MAX(seq)+1` inside the INSERT with conflict retry (a jittered pause of at most 40 ms between attempts, so two tight-loop writers cannot starve one another — #3062) — committed rows stay contiguous, so commit order == seq order on SQLite/Postgres/DuckDB (deliberately NOT identity/serial: those allocate before commit and break the cursor guarantee under concurrent writers).\n- Staged appends (PostgreSQL, #2649): `MAX+1` costs a *wait* — the loser of a primary-key race waits for the winner's whole transaction — so an append inside a caller transaction that already wrote rows would let a long write transaction and an ordinary concurrent request form a real lock cycle (`40P01`, found downstream in willgriffin/willgriffin.dev#457). `_smrt_append_change` therefore checks `pg_current_xact_id_if_assigned()` (PostgreSQL 13+): when the caller already has a transaction id it stages the entry in `_smrt_changes_pending` (identity key, conflicts with nothing, never waits, still rolled back with the caller) and `appendChange()` returns **`null`** instead of a sequence. `_smrt_drain_changes()` moves *committed* staged rows into `_smrt_changes` with contiguous `MAX(seq)+row_number()` sequences under a **try-only** advisory lock, so the drain never waits either. Draining is driven from JavaScript, never inside the append helper: an entry sequenced invisibly server-side would get no live `_events` signal while the append's own signal carried a higher sequence, and a subscriber resuming from that `Last-Event-ID` would skip it permanently. `drainChangeFeed(db)` is called best-effort by `getChangesSince()`, by `pruneChangeFeed()`, and by the append path itself (throttled to one drain per 250 ms per database, bypassed immediately after this process staged an append). The append path issues ONE drain statement (a single bounded pass) and nothing else — the caller may own the surrounding transaction, and a second statement there could fail and abort it behind the feed's own error-swallowing (#2026); the helper's whole body, preflight included, sits inside its exception boundary for the same reason. It still settles that drain's signals in order without spending a statement: a drain that allocated anything assigns a transaction id, so an append that then takes the DIRECT path proves the drain committed, and its signals publish ahead of the append's own; a deferred append proves nothing and queues them. Signals for drained entries publish only after a follow-up probe proves the drain committed, so an uncommitted drain can never advertise a sequence a rollback releases for reuse. `getChangesSince()` additionally holds back everything the handle's own drains allocated while a transaction id is still assigned at read time (a per-handle watermark, so later reads in the same transaction keep holding back), and `bootstrapSystemTables()` refreshes the helpers before its schema-version fast return so an already-stamped database does not keep the deadlocking function. Consequences: the cursor guarantee is unchanged (one `MAX+1` writer at a time, only over committed work), but a staged entry becomes visible one drain after its transaction commits, its log position is drain order rather than statement order, and it publishes its live `_events` signal at drain time (post-commit — the old pre-commit signal could describe a rolled-back write). SQLite/DuckDB keep the direct insert unchanged. Residual: an append issued as a transaction's *first* statement still allocates inline — it holds no row locks then, so it cannot close a cycle, but it can make other direct appenders wait for that commit; issue explicit `bumpChangeFeed()` calls after the write or outside the transaction. A deployment that writes only through transactions and reads the feed from a connection that cannot write must schedule `drainChangeFeed()` on a writable one.\n- Tenant typing (#3297): on PostgreSQL `_smrt_changes.tenant_id`, `_smrt_changes_pending.tenant_id` and the dispatch tables' `tenant_id` are native `uuid` on fresh installs (TEXT elsewhere, and on existing installs until `smrt db:migrate-uuid` converts them in a maintenance window). The helpers (marker `smrt-change-feed-helpers:v5`) coerce each tenant id to the LIVE column type through `jsonb_populate_record(NULL::<table>, …)`, so one body serves TEXT, uuid and the mixed state mid-conversion, and `_smrt_drain_changes()` returns `drained_tenant_id` as text. v5 bodies also carry the v4 marker so an older process's probe keeps them during a rolling upgrade; `db:migrate-uuid` installs them in its own transaction before converting. Upgrade every writer before converting: an older process that reinstalls its v4 drain cannot return a uuid tenant id. Tenant ids written here must be UUIDs (or NULL); `_smrt_ai_usage.tenant_id` and `_smrt_run_once_claims.tenant_id` stay TEXT (caller-supplied scope strings).\n- `getChangesSince(db, { since, tables?, tenantId?, limit? }) → { changes, cursor, resyncRequired?, resyncCursor? }`: strictly monotonic cursor; polling with returned cursors misses no committed change and never repeats one. A cursor that cannot be served incrementally — pruned below the retained `[floor..horizon]` run, or foreign/ahead of the horizon — gets `resyncRequired: true` with empty `changes`, an unadvanced `cursor`, and `resyncCursor` set to the current horizon so clients can full-refetch then resume incrementally; detection runs on the UNFILTERED log so `tables`/`tenantId` filters never trigger or mask it. `getTenantScopedChangesSince()` resolves tenant via the DispatchBus resolver hook (fail-closed: tenancy on + no context → global rows only; tenant `T` sees `T` + global rows, never another tenant).\n- `getTableVersion(db, table) → number`: the per-table change version (`MAX(seq)` for the table **plus that table's staged-but-undrained count**, so a staged write still moves the ETag and cannot false-304 a client; the sum is monotonic because draining `n` staged rows raises the table's `MAX(seq)` by at least `n`, and both terms are read in ONE statement — separate reads let a drain be counted twice, minting a version a later write re-mints; replica-stable, with no per-process divergence), the ETag source for zero-query conditional GETs (#1765). Advances on any framework write to the table (CRUD and sync-apply, which all `save()`/`delete()`). A table with no retained entry of its own falls back to the global horizon (never a resettable low value) so an all-pruned table cannot false-304 a stale client; only 0 when the feed is empty.\n- Generated `_changes` routes: REST (`GET {basePath}/_changes`, requires `authMiddleware`, otherwise 401 — per-model `api.public` does NOT apply) and SvelteKit (`{routesDir}/_changes/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.changesRoute.enabled: false`). Query params: `since`, `tables` (comma-separated), `limit`. Responses stay HTTP 200 in the resync state — `resyncRequired` is protocol state, not an error, and `resyncCursor` is the resume cursor after the client completes a full refetch.\n- Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.\n\n\n## Credential-bearing tables are never disclosed (#2937)\n\n`src/change-feed-sensitivity.ts` is a leaf module (it imports nothing, so\n`change-feed.ts`, `change-signals.ts` and the registry can all consult it\nwithout a cycle) holding the tables whose **row id or payload is a secret**. A\ntable joins it two ways: by name, via the baseline\n`CHANGE_FEED_CREDENTIAL_TABLES` (`sessions`, `users_cli_auth_requests`,\n`users_magic_link_tokens`, `magic_link_tokens`, `api_keys`,\n`nostr_identities`), or because a class declared `@smrt({ sensitive: true })`\nand registration pushed its resolved table name across\n(`declareChangeFeedSensitiveTable`, called from both the decorator and the\nmanifest-stub registration paths). Both are needed: the declaration is the\npackage's own contract but only binds in a process where that package\nregistered, while the name baseline is all the read path has when serving a\ndatabase another process writes. The set is **monotonic** — `sensitive: false`\nis not an opt-out and nothing removes a name — so a consumer whose domain\ntable is named `sessions` loses feed coverage for it and must rename it via\n`@smrt({ tableName })`.\n\n`isChangeFeedObservableTable()` ANDs the sensitivity check with\n`CHANGE_FEED_EXCLUDED_TABLES`, and the refusal is enforced at four points, not\none: the interceptor write path; `appendChange`/`appendChanges`, the lowest\nwrite API, so `bumpChangeFeed()` and any other escape hatch are covered (a\nrefused append returns `null`, indistinguishable from a staged one);\n`deliverLocally` in `change-signals.ts`, which also catches a signal broadcast\nby a peer replica running an older build; and **`getChangesSince()`**. The\nread-side refusal is what makes upgrading sufficient — rows an earlier version\nalready wrote stay in the log but are never served. A caller-named sensitive\ntable is dropped from the `tables` filter rather than erroring, so nothing\nconfirms the table exists, and a request naming *only* sensitive tables gets\nan empty page rather than an unfiltered one. Filtered rows never hold the\ncursor back: an exhaustive page still advances to the served horizon.\n\nThe PostgreSQL drain also refuses them (#2982). Every drain passes the live\nsensitive set to `_smrt_drain_changes(limit, excluded_tables)`. Under the drain\nlock it deletes staged rows for those tables before allocating any sequence,\nand its batch skips any that commit afterwards. Entries an older replica staged,\nor that were staged before an upgrade, therefore never reach `_smrt_changes`.\nThe single-argument `_smrt_drain_changes(limit)` remains as a compatibility\nentry point for older replicas during a rollout. It delegates with no\nexclusions, which is the old behaviour; its output stays covered by the read,\nsignal and retention refusals above. The bootstrap and raw-handle probes require\nboth overloads, so an upgraded database installs the new one before current code\ncalls it. A newly created two-argument drain inherits the compat drain's\nreconciled EXECUTE grants, so a hardened ACL with no PUBLIC EXECUTE carries\nover. The compat drain has its own exception boundary. The purge is bounded per\npass, like the sequencing batch.\n\nIn a permissions-managed deployment the migration owner installs the helpers\nbefore current replicas boot. Permission plans must come from the current CLI:\nan older planner reports the two-argument overload as an unsupported routine.\n\n`pruneChangeFeed()` additionally deletes sensitive rows below the horizon on\nevery sweep, ahead of either retention bound, so credentials do not sit at rest\nfor the retention window. That deletes from the middle of the retained run,\nwhich the age bound goes to lengths to avoid — permissible only because these\nrows are unservable on every read path, so no page ever contained them and no\ncursor can fall into the gap. It **does** move `floor`, and usually will: a\nsession is created before the first domain write and re-saved on every request,\nso the lowest retained sequences are typically credential rows. `floor` is read\nlive (`MIN(seq)`) on every `getChangesSince` and never cached, so the only\nconsequence is that a cursor below the new floor is answered `resyncRequired`\nand refetches — including a `since=0` client on a never-pruned feed. Extra\nresyncs, never a missed change. The horizon does not move, because the newest\nentry is retained even when it is sensitive; that preserves \"a non-empty feed\nis never emptied\" and means one credential row can linger until the next write\nmoves the horizon past it.\n\nBecause a non-observable table appends nothing, it also has no ETag source:\n`getTableVersion()` would pin at whatever an older build last wrote and then\nfall back to the global horizon, which moves only on unrelated traffic — so a\nconditional GET could answer `304` for a revoked API key or a rotated session.\n`getTableVersion()` therefore returns a deliberately **unrepeatable** value for\nsuch a table — 48 bits of `crypto` randomness per call, not a per-process\ncounter, because two replicas' clock-seeded counters drift through each other\nand the ETag carries no per-process entropy — so no *concrete* `If-None-Match`\ncan match and every such read is a full 200. It is the single point every ETag\npath goes through (the runtime `APIGenerator`, the generated\n`conditionalVersionedRead`, and route files an older generator already\nemitted), so no call site needs a special case. A wildcard `If-None-Match: *`\nstill 304s; the read paths evaluate it only after the payload is built, so it\ndistinguishes nothing an unconditional request would not.\n\nSTI sensitivity is table-wide. Manifest generation and registration refuse a\nhierarchy mixing classes declaring `sensitive: true` with classes omitting it or\ndeclaring false (`CONFIG_STI_MIXED_SENSITIVITY`, #3456). Mark every class sensitive\nor put the sensitive and public models on separate tables. This preserves the\nhistorical-read and live-signal protections without introducing a discriminator\ninto old feed rows. A uniformly sensitive hierarchy declares its shared table;\nthe authoritative write-path check still verifies the instance's actual table.\n\nScope note: the generated `_changes`/`_events` routes — both the SvelteKit\nroutes and the runtime REST generator's — authorize on an authenticated\nprincipal plus tenant scoping (`getTenantScopedChangesSince`, fail-closed) by\ndefault. A consumer-supplied table/row authorization seam beyond that —\n`change-feed-authz.ts` (#3020, below) — is optional and off until registered.\n\n## Consumer table/row authorization seam (#3020)\n\nTenant scoping keeps one tenant from seeing another's rows, but it cannot\nexpress \"table X is readable by some principals of the tenant but not others\"\nor \"principal P only sees their own rows of table X\" (e.g. a bay-tablet\nstation versus the back office). `?tables=` on `_changes` was always a\nclient-chosen filter, never enforcement — a table the client did not ask for\nwas withheld only because it did not ask, not because it could not have.\n\n`src/change-feed-authz.ts` closes that gap with two independent, optional\nhooks, dependency-inverted onto `globalThis` exactly like\n`resolveDispatchTenantScope` (`dispatch/tenant-resolver.ts`), so a consumer\nregisters them once with no change to the generated \"DO NOT EDIT\" route files.\nBoth hooks are evaluated identically by **every** transport — the generated\nSvelteKit `_changes`/`_events` routes AND the runtime REST generator's\n`_changes`/`_events` (`generators/changes-route.ts`, `generators/events-route.ts`)\n— so a consumer serving both cannot register a hook that protects one and\nleaks through the other:\n\n- `setChangeFeedAuthorizer(authorizeChangeFeed)` — table-level.\n `authorizeChangeFeed({ locals, request, tables }) => allowedTables` returns\n the tables the requester may read from the feed at all; the result is\n INTERSECTED with the client's `?tables=` filter (never unioned), so a table\n the hook does not name is never queried, let alone returned.\n- `setChangeFeedEntryVisibility(isChangeFeedEntryVisible)` — row-level, for\n scope narrower than a whole table. Applied per entry, after the table\n filter, before anything is serialized — including a live `_events` signal.\n\n`locals` is SvelteKit's `event.locals`; `request` is SvelteKit's\n`event.request` or, on REST, the `authMiddleware`-processed `Request`. REST\nhas no `locals` concept, so REST routes pass `locals: undefined` and a\nREST-hosting consumer identifies the principal from `request` instead\n(whatever the `authMiddleware` attached — headers, a decoded token, etc.).\nEvery generated route supplies `request`.\n\nBoth hooks are optional and independent; unregistered, every transport\nbehaves exactly as before (tenant-scoped only). A hook that throws or returns\nsomething other than the documented shape fails closed to \"authorizes\nnothing\" for that call — same posture as a throwing `resolveDispatchTenantScope`\nresolver, and the same 200-empty-page shape the sensitivity filter above\nalready gives a request naming only tables it may not see (never a 5xx, which\nwould tell a caller its own hook is broken — indistinguishable from \"denied\"\nfrom the caller's side).\n\nEvery filter narrows the *returned* `changes`; nothing ever touches\n`cursor`/`resyncRequired`/`resyncCursor` — mirrors the sensitivity filter's\n\"filters affect what is returned, never how the cursor advances\" and for the\nsame reason: a client must advance past a denied entry without re-polling it\nforever, and the entry's row id/timing must never be inferable from a stalled\ncursor. Table denial sets `getChangesSince`'s explicit `denyAllTables: true`\noption rather than passing an empty `tables` array, because `getChangesSince`\ntreats an omitted/empty `tables` as \"no filter\" and would otherwise widen the\nread. `denyAllTables` skips the `table_name IN (...)` clause (and the query)\nentirely — an earlier version instead passed a guaranteed-no-match sentinel\ntable name through that clause, which broke on PostgreSQL (it rejects a NUL\nbyte in a text parameter, turning a denied request into a 500 instead of the\nrequired 200 empty page, #3020 P1 follow-up). `denyAllTables` still runs every\nresync/pruned-cursor check unfiltered first, so the real horizon computation\nruns and the cursor still advances — it just matches zero rows.\n`change-feed-authz.ts`'s `isChangeFeedDenyAll()` is the one place that decides\n\"empty allow-list\" (deny all) vs. \"no hook registered\" (no filter); both\n`readAuthorized()` (pull side) and `buildChangeEventStream()`'s catch-up loop\n(push side) call it rather than re-deriving the distinction. With NO hook\nregistered, `resolveAuthorizedChangeFeedTables()` normalizes an explicit empty\nrequested `tables: []` to `undefined` before that decision runs: `?tables=`\nwith nothing in it is documented as equivalent to an omitted filter, and\nwithout a hook there is no authorized answer to narrow anything with — feeding\n`[]` through unchanged would let `isChangeFeedDenyAll()` read it as \"deny\nevery table\" and silently break the promised unchanged default (#3020\nfollow-up). Once a hook IS registered, its own answer is what decides\ndeny-all, exactly as above.\n\nA registered hook's returned table list is also validated for USABLE\nstrings, not just string-typed ones: any entry that is empty or contains a\nNUL byte fails the whole call closed to `[]` (deny all), the same posture as\na non-array or non-string result. This closes the same NUL-byte hazard the\n`denyAllTables` sentinel removal above fixed for a *synthesized* value — here\nthe NUL byte would come from the hook's OWN answer, which otherwise flows\nstraight into the ordinary `table_name IN (...)` clause and 500s on\nPostgreSQL (#3020 Copilot follow-up).\n\n`getAuthorizedChangesSince()` / `getAuthorizedTenantScopedChangesSince()` wrap\n`getChangesSince()` / `getTenantScopedChangesSince()` with both hooks applied;\nBOTH `_changes` routes (SvelteKit's `vite-plugin/changes-route.ts` and REST's\n`generators/changes-route.ts` `handleChangesRoute`) call the tenant-scoped\nvariant, and `buildChangeEventStream()` (`change-signals.md`) — shared by\nBOTH `_events` routes — applies the same hooks to catch-up replay and live\nsignal delivery, resolving the table allow-list ONCE at connection open\n(mirrors captured tenant scope).\n\n## Compatible bulk mutations (#2818)\n\n`appendChanges(db, entries)` validates all inputs before writing and returns one\nsequence (or PostgreSQL staged `null`) per entry in input order. A nonempty batch\nuses one client SQL statement and one set-based insert. The PostgreSQL\n`_smrt_append_changes(jsonb)` helper chooses the staged/direct path once before\nits own writes assign a transaction ID, and retains the same exception isolation\nas the single-row helper. Bootstrap refreshes its body marker and runtime grants\ninclude this exact helper signature.\n\nThe registered feed interceptor explicitly supports grouped lifecycle completion.\n`recordInstanceChanges()` retains observable-table filtering, row/tenant IDs,\nper-row operations, and best-effort failure handling. It publishes each direct\nsequence in order; staged entries publish through the existing committed drain.\nA batch is never replaced with a table-level bump or a single aggregate event.\nThe junction caller bounds its batch; the low-level append API does not split\none logical append into partially committed chunks.\n"
|
|
1192
1192
|
},
|
|
1193
1193
|
{
|
|
1194
1194
|
"path": "agents/change-signals.md",
|
|
@@ -1198,12 +1198,12 @@
|
|
|
1198
1198
|
{
|
|
1199
1199
|
"path": "agents/generators.md",
|
|
1200
1200
|
"module": "generators",
|
|
1201
|
-
"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"
|
|
1201
|
+
"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).\nClient entry selection skips the framework base classes exactly as route\nemission does (`isFrameworkBaseClass`, #2642): a consumer aggregating\n`@happyvertical/smrt-core` receives `SmrtObject`/`SmrtCollection`, and the\nclient must not advertise their CRUD or methods (static helpers included) as\ncallable actions that no route serves (#3295). Static model methods stay\ncollection-scoped (`method(options)`), instance methods item-scoped\n(`method(id, options)`), matching the emitted route files.\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).\nThe generated sync-apply route keys `SYNC_TARGETS` by segment and resolves one\nowner per segment among models exposing a mutating op: STI base (an\nAPI-enabled base without mutating ops fails the segment closed), else first in\nqualified-identity order. A shared segment's registry key and write policy are\nbyte-stable across scan orders (#2807).\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\nObject row-type declarations (`XData` in `smrt-objects.d.ts`, `smrt-types.d.ts`,\nthe Vite ambient `virtual-modules.d.ts`, and the `@smrt/types` module) are one\nper CLASS, selected by `selectObjectDeclarations()` in\n`src/manifest/object-declarations.ts`; every emitter reads it. Built workspace\nlibraries list a consumed model under their own package name (#3110), so an\naggregated consumer manifest can name one class several times: listings with\nthe same absolute declaring `filePath`, `className`, and field shape collapse\nto one declaration, rendered from the first listing in qualified-identity order\n(#3296). A path alone is machine-local evidence, so a relative or missing\n`filePath`, or disagreeing fields, keeps the listing separate. The aggregated\nmanifest itself keeps every listing.\n\nNames follow the qualified identity when simple names collide (#3115): a\nunique class keeps `${className}Data`; distinct classes sharing a simple name\neach get a package-qualified name (`@ergot/market-core:LicenseSale` ->\n`ErgotMarketCoreLicenseSaleData`), none keeps the bare name (a stem without a\nknown package is suffixed), and the generator warns with each name and every\nlisting it covers. The prefix is the package of the declaration's first\nlisting in qualified-identity order, which for a class relisted by consumer\npackages (#3296) need not be the declaring package. One class whose listings\ndisagree on fields (a stale relisting) also forms a collision group, so it\nloses its bare name until the stale package is rebuilt. Client and web declarations resolve row types\nthrough `createObjectDeclarationIndex().interfaceNameFor()` and import them\n(`import('./smrt-objects')`, `import('@happyvertical/smrt-virt-types')`);\na bare name does not resolve inside an ambient module.\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; one that configures `mcp` itself keeps its\ncustom methods, but the runtime CRUD cap below still drops its MCP writes. The CLI treats\na collection's `include` as the complete allowlist, as MCP and REST do.\n\nThe scanner records the surfaces it copied as `inheritedExposure` on the\ncollection's manifest entry. At runtime those surfaces follow the model's\neffective config, narrowed and never widened (`false` wins, `include` lists\nintersect, `exclude` lists union), so a consumer that closes the model\n(`registerOverride`, or a subclass manifest that replaces it) closes the\nundecorated collection too. On `mcp`/`cli` the runtime also caps every\ncollection's generated CRUD verbs to the model's effective ones, decorated or\nnot; custom methods a collection lists stay its own opt-in, closed with\n`registerOverride` on the collection class. `exclude: ['*']` is not a\nwildcard any generator reads, so both the scanner and `getConfig()` normalize\nit to `include: []` (fail closed; smrt-profiles `MagicLinkToken`). See #3468,\n`registry/__tests__/issue-3468-closed-collection-exposure.test.ts`. Surface\ntests must match `<model>collection_` tool names as well as `<model>_`.\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\nA parameter typed with a bare alias name declared in the method's own module\n(`type ActionOptions = Record<string, unknown>`) is resolved by the scanner\n(`resolveParameterAlias`, `packages/scanner/src/oxc-parser.ts`). Chains are\nfollowed, and the alias target is recorded as `resolvedType` beside the authored\n`type`, using only the declaring module's aliases, so neither a same-named alias\nelsewhere nor a subclass in another module can rebind an inherited parameter.\nSchema emitters and argument decoders read `declaredParameterType(parameter)`\nrather than `parameter.type`. A cyclic or over-long chain sets\n`aliasUnresolved` and is advertised with no type constraint. An imported or\nunknown name keeps the existing fallback, and the wire-ability gate still\njudges the authored name (#4327).\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 under ESM\nimport conditions (the shared `consumerResolvesImport` guard used by\n`_resources`; the subpath is `types`/`import`-only, #3913). 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"
|
|
1202
1202
|
},
|
|
1203
1203
|
{
|
|
1204
1204
|
"path": "agents/build-knowledge.md",
|
|
1205
1205
|
"module": "build-knowledge",
|
|
1206
|
-
"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\nThe same server/component split applies to consumer development SSR (#4475).\nVite's dev import analysis ignores a resolver's `external` flag, so broad\n`noExternal` alone creates another module instance even when native Node and\nVite resolve the identical file. That duplicates module-local enablement and\ninterceptors while an AsyncLocalStorage singleton can still appear shared.\nThe consumer resolver records only exact server entry files selected through\nthe importer's installed export map. Its dev SSR environment `fetchModule`\ntransport returns those files as native external modules; component barrels,\nSvelte and CSS continue through Vite. Production keeps bare external imports.\nNever externalize the complete component-bearing package or switch its mutable\nruntime flags to globals to mask duplicate execution. The real `createServer` /\n`ssrLoadModule` regression in `selective-ssr-dev.test.ts` initializes native\nserver state and verifies root/subpath reads plus direct and transitive peer\nSvelte/CSS compilation in one dev request.\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`_resources` and application-runtime config detection resolve their exact\n`@happyvertical/smrt-users/sveltekit` and `@happyvertical/smrt-app-runtime/sveltekit`\nimports with ESM import conditions anchored\nto the consumer package URL, using `import-meta-resolve`. A `types`/`import`-only\nexport is valid; a CommonJS `createRequire.resolve` probe would falsely skip it.\nVerify the selected target is an existing file because import-meta resolution\ncan return URLs for missing files/directories. Missing/unexported dependencies\nremain skipped, ancestor-hoisted installs resolve normally, and handwritten\n`+server.ts`/`+server.js` ownership and explicit disablement remain unchanged.\nDo not change a package's export map to accommodate a mismatched detector.\n\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\n## AGENTS.md discovery in a monorepo (#3476)\n\n`buildDomainKnowledgeManifest()` takes the agent doc from the project root, else from the nearest ancestor `AGENTS.md` up to the workspace root (the nearest directory with `pnpm-workspace.yaml`, else the git root, a directory containing `.git`). It never walks past that boundary, and with no boundary above the project root it checks only the root. `agentDocPath` stays relative to the project root (so an inherited doc reads `../../AGENTS.md`), and `agentDoc` and `sourceHashes.agents` are populated from it. Module docs linked from the doc (`moduleDocs`) are only followed when `AGENTS.md` sits at the project root, since their links resolve against that directory.\n\n## Host tool-name collisions (#4520)\n\n`smrtPlugin({ hostToolNames })` (or `smrt({ hostToolNames })` in `vite.config.ts`, which forwards it unchanged) takes `{ name, surface: 'workflow' | 'bespoke' | 'mcp', declaredBy }`\ndeclarations (`src/vite-plugin/host-tool-names.ts`). Host-vs-host duplicates fail at plugin\nconstruction; a host name shadowing a generated model tool fails after the scan. The same\n`declaredBy` on several surfaces is one operation and is allowed. Unlike the advisory\nintent-vs-generated check, these throw and fail the build.\n"
|
|
1206
|
+
"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\n**TypeScript-source providers are Vite-only (#3092).** A workspace package\nlinked into the consumer may export TypeScript source (`\"import\":\n\"./src/index.ts\"`). Plain Node cannot evaluate it as a provider (no `./x.js` →\n`./x.ts` rewriting, no type stripping under `node_modules`, no decorator\nstripping), so any register import whose target resolves to `.ts`/`.mts`/\n`.cts`/`.tsx` (`resolvesToTypeScriptSource`, ESM `import` conditions from the\nconsumer, or from the provider for transitive modules) is emitted as\n`SMRT_VITE_RUNTIME ? await import(...) : {}` with `SMRT_VITE_RUNTIME =\nBoolean(import.meta.env)`, and its export/collection consistency throws are\ngated the same way. Vite SSR therefore registers those constructors exactly as\nbefore, while `smrt db:migrate` and CLIs load the register, warn once on\nstderr, and rely on `.smrt/manifest.json` for those objects. Generation warns\ntoo. Not covered: the compiled worker artifact (`.smrt/runtime/register.js`) is\na Vite build, so `import.meta.env` is constant there and the externalized\nTypeScript import still fails when a plain-Node worker starts. Publish a\ncompiled JS export to register such objects outside Vite.\nRegression: the `#3092` tests in `index.test.ts` (plain-Node and real Vite\n`ssrLoadModule` loads of one generated register, and the transitive gating).\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\nThe same server/component split applies to consumer development SSR (#4475).\nVite's dev import analysis ignores a resolver's `external` flag, so broad\n`noExternal` alone creates another module instance even when native Node and\nVite resolve the identical file. That duplicates module-local enablement and\ninterceptors while an AsyncLocalStorage singleton can still appear shared.\nThe consumer resolver records only exact server entry files selected through\nthe importer's installed export map. Its dev SSR environment `fetchModule`\ntransport returns those files as native external modules; component barrels,\nSvelte and CSS continue through Vite. Production keeps bare external imports.\nNever externalize the complete component-bearing package or switch its mutable\nruntime flags to globals to mask duplicate execution. The real `createServer` /\n`ssrLoadModule` regression in `selective-ssr-dev.test.ts` initializes native\nserver state and verifies root/subpath reads plus direct and transitive peer\nSvelte/CSS compilation in one dev request.\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 writes no knowledge and never touches the production pair:\n`smrtVitestPlugin()` writes a per-run snapshot, and core's own `generate:test`\nwrites only `src/manifest/test-manifest.json` (`outputMode: 'explicit'`; its\nTurbo outputs exclude `.smrt/`). Disabling knowledge in a production build\nremoves a previous local knowledge artifact, so consumers cannot mistake it for\ncurrent 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 `artifactPurpose: 'test'` and are valid only as the active\ntest manifest of the run that registered them. Every other reader refuses them\n(`isRefusedTestArtifact`, #2760): `LocalManifestReader`, the runtime loader's\nlocal, workspace-source, external and explicit-path loads, dependency\ndiscovery (including `.smrt` fallbacks), CLI project/package discovery (the\n`db:*` schema gate), CLI data portability, and smrt-dev-mcp's runtime boot and\nknowledge reads. A test manifest left at `.smrt/manifest.json` by an older\nrunner therefore fails closed (`missing_local_manifest`) instead of surfacing\ninline fixtures as phantom objects; regression\n`packages/vitest/src/__tests__/issue-2760-test-then-cli-parity.test.ts`. Unmarked runtime manifests,\nempty published providers, and source-only development providers remain eligible.\n`ManifestBuilder` accepts explicit `outputMode: 'dev' | 'build' | 'explicit'`;\nomitted mode retains legacy filename inference. `'explicit'` writes only\n`outputDir/outputName`, never the unified `.smrt`/`dist` location. Vitest uses\nit for its per-run snapshot `.smrt/test-runs/<run>/manifest.json`, published\nper root in `__SMRT_TEST_MANIFEST_PATHS__` (`manifest/test-artifacts.ts`).\n`LocalManifestReader.loadLocal()` (and so the runtime loader and manifest test\ndatabases) reads that active snapshot before `.smrt/manifest.json` for the\nregistered root only; `loadForExternalPackage()` never reads it, and a process\noutside the run never sees it (#4128). Core's own Vitest config registers `src/manifest/test-manifest.json`\nthe same way. Relative output directories are resolved against the builder's\nproject 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\nEvery generated file gated on a consumer dependency (`_resources` →\n`@happyvertical/smrt-users/sveltekit`, application-runtime config →\n`@happyvertical/smrt-app-runtime/sveltekit`, `_dev` →\n`@happyvertical/smrt-dev-mcp/dev-plane`) detects it through\n`consumerResolvesImport` (`src/vite-plugin/consumer-import.ts`, #3913): ESM\nimport conditions anchored to the consumer package URL, using\n`import-meta-resolve`. A `types`/`import`-only export is valid; a CommonJS\n`createRequire.resolve` probe would falsely skip it, so new gated routes use\nthe shared resolver. `generated-route-dependencies-packed-consumer.test.ts`\nproves all three against a real packed Core tarball plus minimal packages\ncarrying the dependencies' real published export maps (direct, hoisted,\nabsent); it does not exercise those packages' runtime graphs.\nVerify the selected target is an existing file because import-meta resolution\ncan return URLs for missing files/directories. Missing/unexported dependencies\nremain skipped, ancestor-hoisted installs resolve normally, and handwritten\n`+server.ts`/`+server.js` ownership and explicit disablement remain unchanged.\nDo not change a package's export map to accommodate a mismatched detector.\n\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\n## AGENTS.md discovery in a monorepo (#3476)\n\n`buildDomainKnowledgeManifest()` takes the agent doc from the project root, else from the nearest ancestor `AGENTS.md` up to the workspace root (the nearest directory with `pnpm-workspace.yaml`, else the git root, a directory containing `.git`). It never walks past that boundary, and with no boundary above the project root it checks only the root. `agentDocPath` stays relative to the project root (so an inherited doc reads `../../AGENTS.md`), and `agentDoc` and `sourceHashes.agents` are populated from it. Module docs linked from the doc (`moduleDocs`) are only followed when `AGENTS.md` sits at the project root, since their links resolve against that directory.\n\n## Host tool-name collisions (#4520)\n\n`smrtPlugin({ hostToolNames })` (or `smrt({ hostToolNames })` in `vite.config.ts`, which forwards it unchanged) takes `{ name, surface: 'workflow' | 'bespoke' | 'mcp', declaredBy }`\ndeclarations (`src/vite-plugin/host-tool-names.ts`). Host-vs-host duplicates fail at plugin\nconstruction; a host name shadowing a generated model tool fails after the scan. The same\n`declaredBy` on several surfaces is one operation and is allowed. Unlike the advisory\nintent-vs-generated check, these throw and fail the build.\n"
|
|
1207
1207
|
},
|
|
1208
1208
|
{
|
|
1209
1209
|
"path": "agents/memory.md",
|
|
@@ -1248,7 +1248,7 @@
|
|
|
1248
1248
|
{
|
|
1249
1249
|
"path": "agents/browser-entry.md",
|
|
1250
1250
|
"module": "browser-entry",
|
|
1251
|
-
"content": "# Browser entry: no Node-only modules (#2838)\n\n`src/browser.ts` is what `package.json#exports[\".\"].browser` resolves to. Its\nmodule graph must build for a browser **and evaluate** in one. Two guards hold\nit:\n\n- `packages/bundle-gate` builds it with Vite browser conditions and fails on\n `node:` built-ins, `pg`, `cosmiconfig`, `jiti` and the rest of\n `FORBIDDEN_NODE_ONLY_MODULES` (core is held to zero even while its ratchet\n entry exists for another reason).\n- `pnpm test:browser` bundles the built `dist/browser.js`, loads it in\n Chromium, registers a decorated class, and fails on any evaluation error or\n any module Vite externalized \"for browser compatibility\". Set\n `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to use another Chromium. CI runs it in\n the workbench browser job.\n\n`@noble/hashes`, `import-meta-resolve` and `minimatch` remain external runtime dependencies in the preserve-modules\npackage build. Bundling it under `dist/node_modules` leaves dangling imports\nwhen npm excludes that directory from tarballs. The packed MCP consumer tests\nthe installed relative-import closure and real SHA-256 execution with normal and CI prepack paths (#4008).\n\n## How a Node-only dependency stays out\n\nNever import it statically from a module the browser graph reaches. Pick one:\n\n| Need | Use |\n|---|---|\n| `getDatabase`, `buildWhere`, `raw`, `NestedTransactionError`, the AI SDK, installed-package discovery | `src/host.ts`. `package.json#browser` maps `./dist/host.js` to `./dist/host.browser.js`; Node and every test resolve `host.ts`. |\n| JSON parse/stringify | `src/utils/json.ts`; the browser build is `utils/json.browser.ts` (native `JSON`). |\n| `node:fs`/`path`/`module` for manifest discovery | `src/manifest/node-host.ts`, which resolves them with `process.getBuiltinModule()`. No host answers \"absent\". |\n| SHA-256 | `sha256Hex` (`src/utils/sha256.ts`, `@noble/hashes`). Not `node:crypto`. |\n| Random ids | `globalThis.crypto.randomUUID()` / `getRandomValues()`. |\n| Async-local state | `createAsyncContext()` (`src/utils/async-context.ts`). Check `propagatesAcrossAwait` before relying on the store. |\n| A whole Node-only module (registry snapshot) | Keep it out of `system/browser.ts` and the browser entry. |\n\n`browser.ts` and `host.browser.ts` are typed against their Node counterparts, so\nthe surfaces cannot drift. A new browser variant needs its source pair, an\n`entries[...]` line in `vite.config.ts` (nothing imports it, so the build would\nnot emit it), and a `browser` mapping; `browser-field.test.ts` checks all three.\n\n## Known limits\n\n- The browser uses `getDatabase` from `@happyvertical/sql/pglite` and\n `buildWhere`, `raw`, and `NestedTransactionError` from the browser-safe\n `@happyvertical/sql/query` entry. It rejects non-PGlite engines.\n- The AI SDK is unavailable in the browser build.\n- The embedded write queue runs unqueued without async-local state; its only\n browser database, PGlite, serializes statements itself.\n- `smrt-config`'s browser loader resolves to `{}`; use `setConfig()`.\n"
|
|
1251
|
+
"content": "# Browser entry: no Node-only modules (#2838)\n\n`src/browser.ts` is what `package.json#exports[\".\"].browser` resolves to. Its\nmodule graph must build for a browser **and evaluate** in one. Two guards hold\nit:\n\n- `packages/bundle-gate` builds it with Vite browser conditions and fails on\n `node:` built-ins, `pg`, `cosmiconfig`, `jiti` and the rest of\n `FORBIDDEN_NODE_ONLY_MODULES` (core is held to zero even while its ratchet\n entry exists for another reason).\n- `pnpm test:browser` bundles the built `dist/browser.js`, loads it in\n Chromium, registers a decorated class, and fails on any evaluation error or\n any module Vite externalized \"for browser compatibility\". Set\n `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to use another Chromium. CI runs it in\n the workbench browser job.\n\n`@noble/hashes`, `import-meta-resolve` and `minimatch` remain external runtime dependencies in the preserve-modules\npackage build. Bundling it under `dist/node_modules` leaves dangling imports\nwhen npm excludes that directory from tarballs. The packed MCP consumer tests\nthe installed relative-import closure and real SHA-256 execution with normal and CI prepack paths (#4008).\n\nCommonJS runtime dependencies are bundled, not external (#4054). Consumers\nthat exclude smrt-core from Vite dependency pre-bundling (#3843) receive\n`dist/*.js` as native browser ESM, where a bare import of a CommonJS package\nfails (`does not provide an export named 'default'`). `pluralize` is\ntherefore not in `vite.config.ts` `external`; its converted module is emitted\nunder `dist/vendor/` (the `entryFileNames` rule maps any bundled\n`node_modules/` module there, since npm never packs `dist/node_modules`), and\nit is a devDependency. `pnpm test:browser` also runs\n`scripts/browser-unbundled-dev.mjs`, which serves `dist/utils/naming.js`\nunbundled from a real Vite dev server (no dependency discovery) and evaluates\nit in Chromium. A new CommonJS runtime dependency on a browser-reachable path\nfollows the same rule.\n\n## How a Node-only dependency stays out\n\nNever import it statically from a module the browser graph reaches. Pick one:\n\n| Need | Use |\n|---|---|\n| `getDatabase`, `buildWhere`, `raw`, `NestedTransactionError`, the AI SDK, installed-package discovery | `src/host.ts`. `package.json#browser` maps `./dist/host.js` to `./dist/host.browser.js`; Node and every test resolve `host.ts`. |\n| JSON parse/stringify | `src/utils/json.ts`; the browser build is `utils/json.browser.ts` (native `JSON`). |\n| `node:fs`/`path`/`module` for manifest discovery | `src/manifest/node-host.ts`, which resolves them with `process.getBuiltinModule()`. No host answers \"absent\". |\n| SHA-256 | `sha256Hex` (`src/utils/sha256.ts`, `@noble/hashes`). Not `node:crypto`. |\n| Random ids | `globalThis.crypto.randomUUID()` / `getRandomValues()`. |\n| Async-local state | `createAsyncContext()` (`src/utils/async-context.ts`). Check `propagatesAcrossAwait` before relying on the store. |\n| A whole Node-only module (registry snapshot) | Keep it out of `system/browser.ts` and the browser entry. |\n\n`browser.ts` and `host.browser.ts` are typed against their Node counterparts, so\nthe surfaces cannot drift. A new browser variant needs its source pair, an\n`entries[...]` line in `vite.config.ts` (nothing imports it, so the build would\nnot emit it), and a `browser` mapping; `browser-field.test.ts` checks all three.\n\n## Known limits\n\n- The browser uses `getDatabase` from `@happyvertical/sql/pglite` and\n `buildWhere`, `raw`, and `NestedTransactionError` from the browser-safe\n `@happyvertical/sql/query` entry. It rejects non-PGlite engines.\n- The AI SDK is unavailable in the browser build.\n- The embedded write queue runs unqueued without async-local state; its only\n browser database, PGlite, serializes statements itself.\n- `smrt-config`'s browser loader resolves to `{}`; use `setConfig()`.\n- The whole browser entry does not yet evaluate unbundled: several modules\n read `process.env` at module scope, and `@happyvertical/utils` imports\n CommonJS `pluralize` itself (#4534).\n"
|
|
1252
1252
|
},
|
|
1253
1253
|
{
|
|
1254
1254
|
"path": "agents/test-types.md",
|