@happyvertical/smrt-core 0.57.6 → 0.57.8

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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `SmrtError`, the base of every SMRT framework error, on its own module
3
+ * (#4100). It imports nothing, so browser-safe code (smrt-reports' `./spec`
4
+ * grammar) can extend it through `@happyvertical/smrt-core/smrt-error` without
5
+ * reaching the root entry's registry and manifest loader. `errors.ts`
6
+ * re-exports this same class, so `instanceof SmrtError` holds across both.
7
+ */
8
+ /**
9
+ * Abstract base class for all SMRT framework errors.
10
+ *
11
+ * Adds a structured `code` (machine-readable string constant), a `category`
12
+ * (coarse error domain), optional structured `details`, and an optional
13
+ * causal `Error` chain on top of the standard `Error` class.
14
+ *
15
+ * Never throw `SmrtError` directly — use one of the concrete subclasses
16
+ * (`DatabaseError`, `AIError`, `ValidationError`, etc.) or their static
17
+ * factory methods for consistent error codes and messages.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * try {
22
+ * await product.save();
23
+ * } catch (err) {
24
+ * if (err instanceof ValidationError) {
25
+ * console.error(err.code, err.details); // 'VALIDATION_REQUIRED_FIELD', { fieldName, objectType }
26
+ * }
27
+ * if (err instanceof SmrtError) {
28
+ * logger.error(ErrorUtils.sanitizeError(err));
29
+ * }
30
+ * }
31
+ * ```
32
+ */
33
+ export declare abstract class SmrtError extends Error {
34
+ readonly code: string;
35
+ readonly category: 'database' | 'ai' | 'filesystem' | 'validation' | 'network' | 'configuration' | 'runtime';
36
+ readonly details?: Record<string, unknown>;
37
+ readonly cause?: Error;
38
+ constructor(message: string, code: string, category: SmrtError['category'], details?: Record<string, unknown>, cause?: Error);
39
+ /**
40
+ * Converts error to a serializable object for logging/debugging
41
+ */
42
+ toJSON(): {
43
+ name: string;
44
+ message: string;
45
+ code: string;
46
+ category: "database" | "ai" | "filesystem" | "validation" | "network" | "configuration" | "runtime";
47
+ details: Record<string, unknown> | undefined;
48
+ stack: string | undefined;
49
+ cause: {
50
+ name: string;
51
+ message: string;
52
+ stack: string | undefined;
53
+ } | undefined;
54
+ };
55
+ }
56
+ //# sourceMappingURL=smrt-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smrt-error.d.ts","sourceRoot":"","sources":["../src/smrt-error.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,8BAAsB,SAAU,SAAQ,KAAK;IAC3C,SAAgB,IAAI,EAAE,MAAM,CAAC;IAC7B,SAAgB,QAAQ,EACpB,UAAU,GACV,IAAI,GACJ,YAAY,GACZ,YAAY,GACZ,SAAS,GACT,eAAe,GACf,SAAS,CAAC;IACd,SAAgB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClD,SAAgB,KAAK,CAAC,EAAE,KAAK,CAAC;gBAG5B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,SAAS,CAAC,UAAU,CAAC,EAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,KAAK,CAAC,EAAE,KAAK;IAef;;OAEG;IACH,MAAM;;;;;;;;;;;;;CAiBP"}
@@ -0,0 +1,70 @@
1
+ //#region src/smrt-error.ts
2
+ /**
3
+ * `SmrtError`, the base of every SMRT framework error, on its own module
4
+ * (#4100). It imports nothing, so browser-safe code (smrt-reports' `./spec`
5
+ * grammar) can extend it through `@happyvertical/smrt-core/smrt-error` without
6
+ * reaching the root entry's registry and manifest loader. `errors.ts`
7
+ * re-exports this same class, so `instanceof SmrtError` holds across both.
8
+ */
9
+ /**
10
+ * Abstract base class for all SMRT framework errors.
11
+ *
12
+ * Adds a structured `code` (machine-readable string constant), a `category`
13
+ * (coarse error domain), optional structured `details`, and an optional
14
+ * causal `Error` chain on top of the standard `Error` class.
15
+ *
16
+ * Never throw `SmrtError` directly — use one of the concrete subclasses
17
+ * (`DatabaseError`, `AIError`, `ValidationError`, etc.) or their static
18
+ * factory methods for consistent error codes and messages.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * try {
23
+ * await product.save();
24
+ * } catch (err) {
25
+ * if (err instanceof ValidationError) {
26
+ * console.error(err.code, err.details); // 'VALIDATION_REQUIRED_FIELD', { fieldName, objectType }
27
+ * }
28
+ * if (err instanceof SmrtError) {
29
+ * logger.error(ErrorUtils.sanitizeError(err));
30
+ * }
31
+ * }
32
+ * ```
33
+ */
34
+ var SmrtError = class extends Error {
35
+ code;
36
+ category;
37
+ details;
38
+ cause;
39
+ constructor(message, code, category, details, cause) {
40
+ super(message);
41
+ this.name = this.constructor.name;
42
+ this.code = code;
43
+ this.category = category;
44
+ this.details = details;
45
+ this.cause = cause;
46
+ if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
47
+ }
48
+ /**
49
+ * Converts error to a serializable object for logging/debugging
50
+ */
51
+ toJSON() {
52
+ return {
53
+ name: this.name,
54
+ message: this.message,
55
+ code: this.code,
56
+ category: this.category,
57
+ details: this.details,
58
+ stack: this.stack,
59
+ cause: this.cause ? {
60
+ name: this.cause.name,
61
+ message: this.cause.message,
62
+ stack: this.cause.stack
63
+ } : void 0
64
+ };
65
+ }
66
+ };
67
+ //#endregion
68
+ export { SmrtError };
69
+
70
+ //# sourceMappingURL=smrt-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smrt-error.js","names":[],"sources":["../src/smrt-error.ts"],"sourcesContent":["/**\n * `SmrtError`, the base of every SMRT framework error, on its own module\n * (#4100). It imports nothing, so browser-safe code (smrt-reports' `./spec`\n * grammar) can extend it through `@happyvertical/smrt-core/smrt-error` without\n * reaching the root entry's registry and manifest loader. `errors.ts`\n * re-exports this same class, so `instanceof SmrtError` holds across both.\n */\n\n/**\n * Abstract base class for all SMRT framework errors.\n *\n * Adds a structured `code` (machine-readable string constant), a `category`\n * (coarse error domain), optional structured `details`, and an optional\n * causal `Error` chain on top of the standard `Error` class.\n *\n * Never throw `SmrtError` directly — use one of the concrete subclasses\n * (`DatabaseError`, `AIError`, `ValidationError`, etc.) or their static\n * factory methods for consistent error codes and messages.\n *\n * @example\n * ```typescript\n * try {\n * await product.save();\n * } catch (err) {\n * if (err instanceof ValidationError) {\n * console.error(err.code, err.details); // 'VALIDATION_REQUIRED_FIELD', { fieldName, objectType }\n * }\n * if (err instanceof SmrtError) {\n * logger.error(ErrorUtils.sanitizeError(err));\n * }\n * }\n * ```\n */\nexport abstract class SmrtError extends Error {\n public readonly code: string;\n public readonly category:\n | 'database'\n | 'ai'\n | 'filesystem'\n | 'validation'\n | 'network'\n | 'configuration'\n | 'runtime';\n public readonly details?: Record<string, unknown>;\n public readonly cause?: Error;\n\n constructor(\n message: string,\n code: string,\n category: SmrtError['category'],\n details?: Record<string, unknown>,\n cause?: Error,\n ) {\n super(message);\n this.name = this.constructor.name;\n this.code = code;\n this.category = category;\n this.details = details;\n this.cause = cause;\n\n // Maintain proper stack trace for V8\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, this.constructor);\n }\n }\n\n /**\n * Converts error to a serializable object for logging/debugging\n */\n toJSON() {\n return {\n name: this.name,\n message: this.message,\n code: this.code,\n category: this.category,\n details: this.details,\n stack: this.stack,\n cause: this.cause\n ? {\n name: this.cause.name,\n message: this.cause.message,\n stack: this.cause.stack,\n }\n : undefined,\n };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,IAAsB,YAAtB,cAAwC,MAAM;CAC5C;CACA;CAQA;CACA;CAEA,YACE,SACA,MACA,UACA,SACA,OACA;EACA,MAAM,OAAO;EACb,KAAK,OAAO,KAAK,YAAY;EAC7B,KAAK,OAAO;EACZ,KAAK,WAAW;EAChB,KAAK,UAAU;EACf,KAAK,QAAQ;EAGb,IAAI,MAAM,mBACR,MAAM,kBAAkB,MAAM,KAAK,WAAW;CAElD;;;;CAKA,SAAS;EACP,OAAO;GACL,MAAM,KAAK;GACX,SAAS,KAAK;GACd,MAAM,KAAK;GACX,UAAU,KAAK;GACf,SAAS,KAAK;GACd,OAAO,KAAK;GACZ,OAAO,KAAK,QACR;IACE,MAAM,KAAK,MAAM;IACjB,SAAS,KAAK,MAAM;IACpB,OAAO,KAAK,MAAM;GACpB,IACA,KAAA;EACN;CACF;AACF"}
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.57.6",
6
+ "packageVersion": "0.57.8",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "c9b90a2b8c18b9bc7bc3f41a85016069cb4c75279f58c8b6daa20329bc1700b9",
11
- "packageJson": "ffe5e240f4494c7fabf186429e2ba45f72182ca0e8b47d04b9a58a3874ab9ab2",
12
- "agents": "78f7d87febb6c83e539df800175e82d603dcd9667b2d5310787cba8216119d66",
10
+ "manifest": "6a8575f45e7c50f832677a798b48fee6924d74a307965c1af69dab93c597dbaa",
11
+ "packageJson": "dee3cf74dcf22bb08a267dbf04a35ce84b0aa0bab75620c2d20c85bc6b874968",
12
+ "agents": "e7153f663754c70fdc7c1a9f014b081f583298eda6387248048d00d852b17685",
13
13
  "moduleDoc:agents/object-runtime.md": "fd2748649e820c2fe5960fa4a44c5f3e2a5afa57da89925e8b4cb3b53d67a07b",
14
14
  "moduleDoc:agents/revision-guard.md": "f6ea32a6860964c98aba14b93587f40b33c8f7d0deccb2c54d21bff45db3da42",
15
15
  "moduleDoc:agents/collection-reads.md": "39acea7faf3505d38bfb999e414f4c4c6f964ec7c3d19cc52993302c29223029",
@@ -25,7 +25,7 @@
25
25
  "moduleDoc:agents/registry-snapshot.md": "6234aa540427396d1dda1825a4641b1072e247187d5ddf413ae46c691344ee30",
26
26
  "moduleDoc:agents/model-moves.md": "c64147fee1b7ec242fd1c178e7aea7cc90cafc704dee2738141c7a6d61403cd8",
27
27
  "moduleDoc:agents/run-once.md": "43c589a3cab2ff61147128014cc0084509faf623eb76860140d589539420a1f3",
28
- "moduleDoc:agents/recipes.md": "8be8ec8b49367b80caa2d9827db79424acfc99343445602d6964ea49139d865d",
28
+ "moduleDoc:agents/recipes.md": "b8a0661d03d3f8df07874456164ef5648c62c775267d8e8340dac0a5d383a54e",
29
29
  "moduleDoc:agents/cookbook.md": "20c256f4f6219f9b2add6365ef930e0bb3eda37e7866ddbc4bdd466830f76e4f",
30
30
  "moduleDoc:agents/cookbook-engine.md": "4ea0b3cdb952788372596cbf1f51e326b0d696302f6358a21e3330402ef1f8aa",
31
31
  "moduleDoc:agents/browser-entry.md": "8d928babfe7308512c901a12c23fbfb7ff395f6b1cddce5cd069e4ece38743da",
@@ -58,6 +58,7 @@
58
58
  "./scanner/types",
59
59
  "./schema",
60
60
  "./schema/utils",
61
+ "./smrt-error",
61
62
  "./smrt-knowledge.json",
62
63
  "./table-cache",
63
64
  "./testing",
@@ -1146,7 +1147,7 @@
1146
1147
  "polymorphicAssociations": 1,
1147
1148
  "uuidColumns": 3
1148
1149
  },
1149
- "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/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- 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. 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",
1150
+ "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/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. 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",
1150
1151
  "moduleDocs": [
1151
1152
  {
1152
1153
  "path": "agents/object-runtime.md",
@@ -1226,7 +1227,7 @@
1226
1227
  {
1227
1228
  "path": "agents/recipes.md",
1228
1229
  "module": "recipes",
1229
- "content": "# SmrtRecipe: declared units of app functionality (#3590)\n\nA **recipe** is a small, named, user-facing unit (\"Sales\", \"Customers\"),\nsmaller than a package. An app or agent adds a recipe and gets that section:\nnavigation, list and create/edit views, and its prerequisites, without every\nmodel in the owning package. Source: `src/recipe.ts` (authoring base class),\n`packages/scanner/src/recipes.ts` (matcher and validation), types in\n`@happyvertical/smrt-types` (`RecipeDefinition`).\n\n## Authoring\n\n`SmrtRecipe` is an abstract, **non-persisted, non-`@smrt()`** base class\nexported from both core entries (`index.ts` and `browser.ts`). A recipe is a\nsubclass with static properties, in the package that owns its models:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n static id = 'commerce.sales'; // dotted lowercase, unique\n static label = 'Sales';\n static summary = 'Take customer orders and track them.';\n static synonyms = ['sales orders', 'orders'];\n static models = [Order]; // class references\n static nav = [{ label: 'Sales Orders', model: Order }];\n static requires = ['commerce.customers']; // recipe ids, any package\n static options = { Order: { fields: { status: { default: 'draft' } } } };\n}\n```\n\nStatics are read **structurally** (nothing is evaluated), so each must be\nspelled literally; `models` and `nav[].model` take identifiers. Write\n`static options = { ... } as const` so `visibility` stays a literal type.\nThe emitted `options` are keyed by qualified model name.\n\n## Host metadata: group, section, nav detail, alternatives (#3604)\n\nEverything a catalog host (an app shell, an agent, a CLI) needs\nto present a recipe lives on the recipe, so it reads the manifest or knowledge\nartifact instead of importing runtime code. All are optional and additive:\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `group` | `{ id, label, summary? }` | Recipes with the same `group.id` share one card; the first declaration of an id supplies `label` and `summary`. Declaration order is the sub-switch order. |\n| `section` | `{ id, label, icon?, description? }` | Navigation section the recipe suggests for its `nav` entries. The host owns sections and the user may rename them, so keep `id` stable; the same `id` shares a section. |\n| `requiresAny` | `string[][]` | At least one id of each inner list must be on (`[['products.simple', 'products.clothing']]`); adding the recipe with none on adds the first. |\n| `nav[].icon` / `description` | string | Shell icon name and one friendly line for a business owner. The icon must be a key of `SHELL_ICON_PATHS` (smrt-svelte `admin-shell/shell-icons.ts`, camelCase such as `fileText` or `shoppingBag`); the same holds for `section.icon`. `packages/scanner/src/__tests__/workspace-recipes.test.ts` fails on any other name. |\n| `nav[].noun` | string | What \"New\" creates when the label is not countable (\"Stock levels\" -> `stock entry`). |\n| `nav[].key` | slug | Fixed key for the layout id `item:<pkg>:<Model>:<key>`. Required for a second entry over the same model and for a `filter`. |\n| `nav[].filter` | `{ field, value }` | Narrows the entry to rows where `field` equals `value` (Ingredients over Products: `productType` = `material`). Needs a `key`. |\n| `options.<Model>.fields.<f>.required` | boolean | The form refuses to save without a value. |\n\nAcross recipes of one package the scanner also rejects a repeated nav `key`\nover the same model (it names one layout id) and one `group`/`section` id with\ntwo different labels. `packages/scanner/src/__tests__/workspace-recipes.test.ts`\nscans every recipe in the workspace and checks ids, `requires`/`requiresAny`\ntargets (which a single package cannot see) and shared labels.\n\nChild models (line items) are simply listed in `models` without a `nav` entry.\nValidation: `group`/`section` take only their keys, slug ids, non-empty\nstrings; `requiresAny` ids follow the recipe-id format and cannot name the\nrecipe itself; each (model, key) pair appears once in `nav`; a `filter.field`\nmust be a field the model declares (checked in core on the merged manifest,\n`assertRecipeOptions`).\n\nNot part of the recipe declaration (still host-local): a host's\ncross-package `forms` and `extends` (form records spanning profiles, variants\nand SKUs).\n\nUser-facing field text for the recipes comes from `@field({ description })` on\nthe models (the glossary seed), so a package that declares a recipe carries the\ndescriptions of every field the recipe shows. A field the package does not\ndeclare itself (`parentId` from `SmrtHierarchical`) is described by the recipe's\n`options.<Model>.fields.<f>.help`, which wins over the description in the\nglossary. Write all of it in plain words: no API, JSON, REST, MCP, SQL or UUID\noutside help that is about connecting other tools.\n\n## Non-model surfaces, providers, runtime, demo seed (#3708)\n\nMany features are not a list of models: a shell widget (assistant dock,\nnotification bell), a component, a route, a settings panel. All four statics are\noptional and additive; a recipe that declares none emits none of the keys. They\nare emitted on the recipe entry in `manifest.json` and `smrt-knowledge.json`.\nComponents are named by `'<package specifier>#<ExportName>'` and never imported\nat scan time; a host resolves them. Relative or absolute specifiers are rejected.\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `surfaces` | `RecipeSurface[]` | `{ kind: 'shell-widget', slot, export, label, icon? }`, `{ kind: 'route', path, export, label }`, `{ kind: 'settings-panel', export, label }`, `{ kind: 'playground', export, label? }`, `{ kind: 'widget', type, export, label, ... }` (see below). `slot` is a smrt-svelte `ShellSlot`; `path` starts with `/` and has no whitespace, `?`, `#` or `..`. |\n| `providers` | `RecipeProvider[]` | `{ id, kind, options, required, secrets?, browserOptions? }`: lowercase slugs, `options` non-empty and distinct, `required` always written, `secrets` distinct `UPPER_SNAKE` names (never values), `browserOptions` a non-empty subset of `options` that run in a browser with none of the secrets (#3709). Ids are unique per recipe. |\n| `runtime` | `'browser' \\| 'server' \\| 'both'` | Where the recipe's runtime pieces can run. Omitted means `both`; the value is emitted as authored. The build refines it into `demo` (below). |\n| `demoSeed` | `{ export } \\| { data }` | A fixture export reference, or inline JSON of at most 8 KB, for demo hosts. Exactly one key. |\n\nWrite `surfaces` with `as const` (like `options`) so `kind` and `slot` stay\nliteral types. The slot union is `RecipeShellSlot` in smrt-types (types only) and the\nscanner's `RECIPE_SHELL_SLOTS` list copies smrt-svelte's `SHELL_SLOTS`; a\ntype-level test in smrt-svelte and a scanner test keep them equal, so there is\nno runtime dependency on smrt-svelte. Inline `demoSeed.data` must be finite JSON. Every violation (unknown\nkind or slot, extra key, duplicate route path or provider id, malformed\nreference) is a scan error. Example: `events.calendar` in\n`packages/events/src/recipes.ts`.\n\n## Widget surfaces (#3727)\n\n`{ kind: 'widget', ... }` contributes a widget to customizable overviews\n(smrt-svelte `./overview`). The manifest carries everything a host needs to\nregister it; the helper `registerRecipeWidgets` in smrt-svelte does that with an\ninjected module resolver, so nothing is imported at scan time.\n\n```ts\nstatic surfaces = [\n {\n kind: 'widget',\n type: 'sales-total', // registry type, unique per package\n export: '@acme/shop/svelte#SalesTotal', // the component\n label: 'Sales total', // text or an i18n key\n description: 'Order totals for the period.',\n icon: 'chart',\n version: 2, // option-schema version (default 1)\n migrate: '@acme/shop/server#migrateSalesTotal', // needs version above 1\n optionsEditor: '@acme/shop/svelte#SalesOptions', // optional dependent-choice editor\n options: [\n { key: 'model', type: 'model', label: 'Model', required: true },\n { key: 'style', type: 'enum', label: 'Style', default: 'bar',\n choices: [{ value: 'bar', label: 'Bar' }, { value: 'line', label: 'Line' }] },\n ],\n data: { load: '@acme/shop/server#loadSalesTotal', models: ['@acme/shop:Order'] },\n allowedIn: ['shop.home'], // overview ids; omit for any\n defaultSpan: 2, minSpan: 1, maxSpan: 3,\n },\n] as const;\n```\n\nEmitted keys, in this order: `kind`, `type`, `export`, `label`, `description`,\n`icon`, `version`, `migrate`, `optionsEditor`, `options`, `data`, `allowedIn`, `defaultSpan`,\n`minSpan`, `maxSpan`; absent ones are omitted. Rules (each violation is a scan\nerror, same strictness as the other kinds):\n\n- `type` is a lowercase kebab id (`[a-z][a-z0-9-]{0,31}`), unique among a\n recipe's surfaces and across the package's recipes. `export`, `data.load`,\n `migrate`, and `optionsEditor` are `'<package specifier>#<ExportName>'`;\n relative specifiers are rejected. `label` is required. A UI host resolves an\n `optionsEditor` lazily for dependent or remote choices; the server registry\n never imports it, and every saved value still passes the ordinary option schema.\n- `options` is a non-empty list of plain-JSON fields limited to the overview\n option types: `text`, `markdown`, `identifier`, `model`, `integer`, `number`,\n `boolean`, closed `enum` (non-empty distinct `choices`). There is no free-form\n query, expression, URL or SQL type. Keys are camelCase (`[A-Za-z][A-Za-z0-9]{0,31}`)\n and unique; `default` must pass its field's own rule; `min`/`max` only on\n numbers, `maxLength` only on text. Only `key`, `type`, `label`, `help`,\n `required`, `default`, `min`, `max`, `maxLength`, `choices` are accepted.\n- `data` takes `load` and/or `models` (qualified class names, distinct). The\n scanner cannot see other packages, so a model is checked for format only.\n- `allowedIn` is a non-empty list of distinct overview ids\n (`[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}`); spans are integers 1 to 4 with\n `minSpan <= defaultSpan <= maxSpan`.\n\nThe scanner's copies of these rules live in `packages/scanner/src/recipe-widgets.ts`\nand a scanner test compares them to the smrt-svelte source. See\n[overview-surfaces.md](../../smrt-svelte/agents/overview-surfaces.md#recipe-widgets-recipe-widgetsts)\nfor how a host registers them.\n\n## Browser-demo mode: Live, Mock, Sample, Server (#3709)\n\nA catalog host (smrt-planner) labels each feature by how far it runs in a\nbrowser-only demo. The label is **derived at build time**, never authored: the\nmanifest build combines what the bundle-gate measured for the recipe's package\nwith the recipe's own `runtime`, `providers` and `demoSeed`, and emits it as\n`demo` on the recipe entry and `browser` on the package. A recipe that declares\nnone of those keeps its shape except for `demo`, which every recipe of a\nmeasured package gets.\n\n```jsonc\n// manifest.json and smrt-knowledge.json, top level\n\"browser\": {\n \"status\": \"browser-safe\" | \"server-only\",\n \"issues\": [\"#3624\"], // tracking issues; omitted when browser-safe\n \"reason\": \"node:crypto\", // the package's own blocker; omitted when only inherited\n \"via\": [\"@happyvertical/smrt-tenancy\"] // inherited from these; omitted when none\n}\n// on each entry of \"recipes\"\n\"demo\": {\n \"mode\": \"live\" | \"mock\" | \"sample\" | \"server\",\n \"reasons\": [\"Needs a server because ...\"], // plain sentences, in rule order\n \"mocked\": [\"smtp\"] // providers a demo fakes; omitted when none\n}\n```\n\n| Mode | Meaning |\n| --- | --- |\n| `live` | Real browser data layer (PGlite); nothing faked. |\n| `mock` | Runs in the browser, but a provider (sending mail, OAuth) is faked. |\n| `sample` | Runs in the browser on `demoSeed` fixtures only. |\n| `server` | Needs a server; a browser-only host cannot offer it. |\n\n**Package capability.** The browser gate (`packages/bundle-gate`) fails any\nmodel package that is not in its expected-failures list and does not build for\na browser, and any listed package that now builds. So a package is `server-only`\nexactly when it is listed. The ratchet attributes a defect to the package that\nowns the fix, so a dependent passes while still dragging the failure into a\nbrowser bundle; the derivation therefore also marks a package `server-only`\nwhen a workspace package it depends on (transitively, through `dependencies`)\nis, and records them in `via`. `smrt-core` is the platform baseline and does not\npropagate. The table lives in `src/manifest/browser-capability.generated.ts`\n(generated, never edited) and a bundle-gate spec fails when it is stale; see\nthat package's AGENTS.md for the regenerate command. A package the gate does\nnot measure (an app's own, a UI package) emits no `browser`, and its recipes get\na `demo` only when they declare `runtime`, `providers` or `demoSeed`.\n\n**Rules** (`src/recipe-demo.ts`, pure and browser-safe; the worst mode wins,\n`live` < `mock` < `sample` < `server`):\n\n1. `runtime: 'server'` is `server`. `runtime: 'both'` or omitted makes no claim:\n the package capability refines it, so `both` on a `server-only` package is\n `server`.\n2. A `server-only` package is `server`.\n3. A **server provider** is one that lists `secrets` (credentials a browser\n cannot hold). One with a `browserOptions` entry (an in-browser model such as\n `webllm`) is satisfied in a browser and changes nothing. Otherwise it is\n **mockable** when its `options` include `mock`. A *required* server provider\n that is neither is `sample` when the recipe has a `demoSeed` (the fixtures\n stand in for what the provider would feed), else `server`. An optional one\n that is neither leaves the mode alone and adds a reason.\n4. Any mockable server provider makes the recipe `mock` and lands in `mocked`.\n5. Otherwise `live`.\n\n**Contradictions fail the build** (like `assertRecipeOptions`), naming the\nrecipe: `runtime: 'browser'` on a `server-only` package, or beside a required\nserver provider with no in-browser and no `mock` option. Fix by giving the\nprovider a `browserOptions` entry or a `mock` option, making it optional, or\ndeclaring `both`/`server`. An explicit `both` or\n`server` on a `server-only` package is not a contradiction.\n\n**Dependencies between recipes.** `demo` is the recipe's own answer. A host that\nwants the answer including what the recipe needs calls\n`effectiveRecipeDemo(id, recipes)` (exported from both core entries): the worst\nof its own mode and every `requires` (transitively), counting the best known\nalternative of each `requiresAny` list; unknown ids are ignored.\n\n**Importing it in a browser page.** Use the subpath\n`@happyvertical/smrt-core/recipe-demo` (`deriveRecipeDemo`,\n`effectiveRecipeDemo`, `RECIPE_DEMO_MODES` and the demo types). Its module graph\nis `src/recipe-demo.ts` alone, with type-only imports, so it carries no `node:`\nimport. The root entry and `/browser` both also export these names, but both\nreach `manifest/manifest-loader.ts` through the registry, and that module calls\n`createRequire` from `node:module` as it loads, which a browser page cannot\nsatisfy. `src/__tests__/recipe-demo-entry.test.ts` walks the module graph and\nfails if the subpath gains a `node:` or package import.\n\nThe classification runs in `ManifestGenerator.applyGenerationPasses` (the one\npass sequence the Vite plugin, `ManifestBuilder` and `generateManifest()` share)\nthrough `applyRecipeDemo` in `src/scanner/recipe-demo-pass.ts`, after the\nmerged-manifest recipe assertions. It needs the package name, so it cannot run\nin the scanner, which resolves recipes before it knows the package.\n\n## How the scanner collects it\n\nThe scanner class pass finds classes by decorator, which a recipe lacks, so\n`recipes.ts` is a second matcher in the style of the agent-surface one: a class\nwhose `extends` resolves to the `SmrtRecipe` import binding from\n`@happyvertical/smrt-core` (named, aliased `SmrtRecipe as R`, or namespace\n`Core.SmrtRecipe`; any core subpath such as `/browser`). A same-named local\nclass or an import from another library is not a recipe. A recipe must extend\n`SmrtRecipe` **directly**. Within one file, extending another recipe or declaring\none as a class expression or inside a block is a scan error rather than a silent\ndrop. Across files it is not detected: a module that never names `SmrtRecipe`\nis not read, so a recipe extending a recipe or intermediate base imported from\nanother module is unsupported and would be missing from the artifacts.\n\nClass references resolve to qualified names (`@scope/pkg:Class`) through the\nimport binding: a relative specifier picks the scanned class by file, then by\nclass name (barrel re-exports keep the name); an aliased import follows the\n*imported* name; `Ns.Class` namespace members and same-file classes work; a\nbare package specifier is rejected (models must belong to this package). When\nthe imported file declares no such class, the barrel fallback binds a same-named\nscanned class, so keep model class names unique within a package. This\nwas chosen over qualified-name strings because it type-checks and survives\nrenames; strings were not needed.\n\nRecipe discovery is independent of the model `include` glob (like intents; it is\nnot gated by the `agentSurface` option), and\n`ScanResults.recipes` is populated by `resolve()`. Every error is a scan error,\nso every producer that reads `scanAndResolve()` (`smrtPlugin`, `ManifestBuilder`)\nfails the build. (The legacy `ManifestGenerator.generateManifest()` path takes\npre-scanned results and never sees recipes.) The adapter qualifies names and emits a top-level `recipes` array in\n`manifest.json`; `buildDomainKnowledgeManifest` projects the same array into\n`smrt-knowledge.json`. A package with no recipe emits no key.\n\n## Build-time validation\n\nIds are dotted lowercase and unique; `models` is non-empty, resolvable, and\nduplicate-free; every `nav` model is in `models`; `requires` are well-formed,\nnot self-referential or duplicated, and acyclic within the package (ids in other\npackages cannot be checked at scan time); `options` name only listed models and\nand only the keys below. Field names are checked in core\n(`ManifestGenerator.assertRecipeOptions`, after inherited fields merge) against\nthe merged manifest, so STI-merged, tenant, and framework-base fields such as\n`parentId` are accepted along with the universal `id`, `slug`, `context`,\n`created_at`, `updated_at`; anything else fails the build.\n\n## options: curation hints, never a second schema\n\nKeyed by model class name. `fields.<name>` accepts the smrt-fields policy\nvocabulary: `default`, `label`, `help`, `order`, `visibility`\n(`basic | advanced | hidden`), `locked`; applying a recipe produces\nfield-policy rows. `exposure.<api|mcp|cli>` accepts only `false` or\nnon-empty `{ exclude: [...] }` naming a CRUD verb (`list`, `get`, `create`,\n`update`, `delete`) or a public instance method the model declares or inherits: `true` and `include` are\nrejected because they can widen what the model already declares. Custom fields are out of scope.\n\n## User-facing help (#3591)\n\nHelp is documentation for the people using the app, not developers. It lives\nbeside the recipe as Markdown and is pointed to by `static help`:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n // ...\n static help = './sales.recipe.md'; // relative to the recipe file, .md only\n}\n```\n\nThe file holds an **Overview** and **Tasks** (short how-tos). A step names a\nfield as `{field:<fieldName>}` or `{field:<Model>.<fieldName>}`; the Markdown\nblock (heading, paragraph, or list item) containing a reference is **tied to\nthat field**. Keep prose in the separate file rather than the recipe class.\nField text itself comes from `@field({ description })`, the user-facing seed\nsmrt-fields uses for form help; every field a recipe shows should carry one.\n\n**Emission.** The scanner reads the file (a missing, empty, non-`.md`, absolute\nor `..`-climbing path is a scan error) and emits\n`help: { markdown, fieldRefs }` on the recipe entry in `manifest.json` and\n`smrt-knowledge.json`, so a static host needs no further request.\n`fieldRefs` (distinct references as written, sorted) is **derived**, never\nauthored. Knowledge fields also carry `description` (sensitive fields stay\nexcluded). `ManifestGenerator.assertRecipeHelp` fails the build on a reference\nto a field the recipe's models do not declare, and on a `fieldRefs` list that\ndisagrees with the Markdown. The scanner has its own copy of the reference\ngrammar (`deriveHelpFieldRefs`; it cannot import core), so that comparison also\nkeeps the two from drifting. A reference to a **sensitive** or universal (`id`,\n`slug`, ...) field also fails the build: neither reaches the knowledge artifact,\nso a host building its field list from it would silently drop the step naming\none; describe such a field in prose without a reference.\n\nKnown limitation: the help file is a generator input, but editing only it does\nnot trigger a dev-server rescan (the watch matches source globs) and generation\nsnapshots do not digest it, so rebuild (or touch a `.ts` source) after editing.\n\n**Rendering.** `src/recipe-help.ts` is pure and browser-safe (exported from\nboth core entries). `renderHelp(help, models, options?)` takes the recipe's\nhelp plus the app's effective field policies (`HelpModel[]`; label, help,\nvisibility, disabled in smrt-fields vocabulary, with `@field` descriptions) and\nreturns `{ blocks, glossary, connect? }`:\n\n- `{field:x}` becomes the effective label;\n- a block tied to a hidden, disabled, undeclared, or **advanced** field (unless\n `showAdvanced`) is dropped; a list item drops alone; a heading whose section\n had content that was all dropped is pruned (a heading that never had content\n stays);\n- the glossary lists each shown field with its effective help, else its\n description. A shown field with neither is **skipped**, not listed blank;\n- `connect` is the collapsed \"Connect other tools\" section: the recipe's\n generated REST routes, MCP tools and CLI commands (`options.surfaces`, from\n the knowledge artifact), minus the recipe's `exposure` narrowing\n (`options.exposure`). Hosts render it last.\n\nThe result is an AST of text nodes (`text`, `code`, `strong`, `em`), never HTML,\nso authored markup stays inert text and nothing needs sanitizing. Only\n`##`-`####` headings, paragraphs, flat lists and bold/italic/code are\nunderstood. `helpToMarkdown(rendered)` flattens it for hosts with their own\nrenderer; render that with raw HTML disabled. `validateHelp(help, models)`\nreturns the build-failure problems for hand-built entries.\n"
1230
+ "content": "# SmrtRecipe: declared units of app functionality (#3590)\n\nA **recipe** is a small, named, user-facing unit (\"Sales\", \"Customers\"),\nsmaller than a package. An app or agent adds a recipe and gets that section:\nnavigation, list and create/edit views, and its prerequisites, without every\nmodel in the owning package. Source: `src/recipe.ts` (authoring base class),\n`packages/scanner/src/recipes.ts` (matcher and validation), types in\n`@happyvertical/smrt-types` (`RecipeDefinition`).\n\n## Authoring\n\n`SmrtRecipe` is an abstract, **non-persisted, non-`@smrt()`** base class\nexported from both core entries (`index.ts` and `browser.ts`). A recipe is a\nsubclass with static properties, in the package that owns its models:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n static id = 'commerce.sales'; // dotted lowercase, unique\n static label = 'Sales';\n static summary = 'Take customer orders and track them.';\n static synonyms = ['sales orders', 'orders'];\n static models = [Order]; // class references\n static nav = [{ label: 'Sales Orders', model: Order }];\n static requires = ['commerce.customers']; // recipe ids, any package\n static options = { Order: { fields: { status: { default: 'draft' } } } };\n}\n```\n\nStatics are read **structurally** (nothing is evaluated), so each must be\nspelled literally; `models` and `nav[].model` take identifiers. Write\n`static options = { ... } as const` so `visibility` stays a literal type.\nThe emitted `options` are keyed by qualified model name.\n\n## Host metadata: group, section, nav detail, alternatives (#3604)\n\nEverything a catalog host (an app shell, an agent, a CLI) needs\nto present a recipe lives on the recipe, so it reads the manifest or knowledge\nartifact instead of importing runtime code. All are optional and additive:\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `group` | `{ id, label, summary? }` | Recipes with the same `group.id` share one card; the first declaration of an id supplies `label` and `summary`. Declaration order is the sub-switch order. |\n| `section` | `{ id, label, icon?, description? }` | Navigation section the recipe suggests for its `nav` entries. The host owns sections and the user may rename them, so keep `id` stable; the same `id` shares a section. |\n| `requiresAny` | `string[][]` | At least one id of each inner list must be on (`[['products.simple', 'products.clothing']]`); adding the recipe with none on adds the first. |\n| `nav[].icon` / `description` | string | Shell icon name and one friendly line for a business owner. The icon must be a key of `SHELL_ICON_PATHS` (smrt-svelte `admin-shell/shell-icons.ts`, camelCase such as `fileText` or `shoppingBag`); the same holds for `section.icon`. `packages/scanner/src/__tests__/workspace-recipes.test.ts` fails on any other name. |\n| `nav[].noun` | string | What \"New\" creates when the label is not countable (\"Stock levels\" -> `stock entry`). |\n| `nav[].key` | slug | Fixed key for the layout id `item:<pkg>:<Model>:<key>`. Required for a second entry over the same model and for a `filter`. |\n| `nav[].filter` | `{ field, value }` | Narrows the entry to rows where `field` equals `value` (Ingredients over Products: `productType` = `material`). Needs a `key`. |\n| `options.<Model>.fields.<f>.required` | boolean | The form refuses to save without a value. |\n\nAcross recipes of one package the scanner also rejects a repeated nav `key`\nover the same model (it names one layout id) and one `group`/`section` id with\ntwo different labels. `packages/scanner/src/__tests__/workspace-recipes.test.ts`\nscans every recipe in the workspace and checks ids, `requires`/`requiresAny`\ntargets (which a single package cannot see) and shared labels.\n\nChild models (line items) are simply listed in `models` without a `nav` entry.\nValidation: `group`/`section` take only their keys, slug ids, non-empty\nstrings; `requiresAny` ids follow the recipe-id format and cannot name the\nrecipe itself; each (model, key) pair appears once in `nav`; a `filter.field`\nmust be a field the model declares (checked in core on the merged manifest,\n`assertRecipeOptions`).\n\nNot part of the recipe declaration (still host-local): a host's\ncross-package `forms` and `extends` (form records spanning profiles, variants\nand SKUs).\n\nUser-facing field text for the recipes comes from `@field({ description })` on\nthe models (the glossary seed), so a package that declares a recipe carries the\ndescriptions of every field the recipe shows. A field the package does not\ndeclare itself (`parentId` from `SmrtHierarchical`) is described by the recipe's\n`options.<Model>.fields.<f>.help`, which wins over the description in the\nglossary. Write all of it in plain words: no API, JSON, REST, MCP, SQL or UUID\noutside help that is about connecting other tools.\n\n## Non-model surfaces, providers, runtime, demo seed (#3708)\n\nMany features are not a list of models: a shell widget (assistant dock,\nnotification bell), a component, a route, a settings panel. All four statics are\noptional and additive; a recipe that declares none emits none of the keys. They\nare emitted on the recipe entry in `manifest.json` and `smrt-knowledge.json`.\nComponents are named by `'<package specifier>#<ExportName>'` and never imported\nat scan time; a host resolves them. Relative or absolute specifiers are rejected.\n\n| Static | Shape | Meaning |\n| --- | --- | --- |\n| `surfaces` | `RecipeSurface[]` | `{ kind: 'shell-widget', slot, export, label, icon? }`, `{ kind: 'route', path, export, label }`, `{ kind: 'settings-panel', export, label }`, `{ kind: 'playground', export, label? }`, `{ kind: 'widget', type, export, label, ... }` (see below). `slot` is a smrt-svelte `ShellSlot`; `path` starts with `/` and has no whitespace, `?`, `#` or `..`. |\n| `providers` | `RecipeProvider[]` | `{ id, kind, options, required, secrets?, browserOptions? }`: lowercase slugs, `options` non-empty and distinct, `required` always written, `secrets` distinct `UPPER_SNAKE` names (never values), `browserOptions` a non-empty subset of `options` that run in a browser with none of the secrets (#3709). Ids are unique per recipe. |\n| `runtime` | `'browser' \\| 'server' \\| 'both'` | Where the recipe's runtime pieces can run. Omitted means `both`; the value is emitted as authored. The build refines it into `demo` (below). |\n| `demoSeed` | `{ export } \\| { data }` | A fixture export reference, or inline JSON of at most 8 KB, for demo hosts. Exactly one key. |\n\nWrite `surfaces` with `as const` (like `options`) so `kind` and `slot` stay\nliteral types. The slot union is `RecipeShellSlot` in smrt-types (types only) and the\nscanner's `RECIPE_SHELL_SLOTS` list copies smrt-svelte's `SHELL_SLOTS`; a\ntype-level test in smrt-svelte and a scanner test keep them equal, so there is\nno runtime dependency on smrt-svelte. Inline `demoSeed.data` must be finite JSON. Every violation (unknown\nkind or slot, extra key, duplicate route path or provider id, malformed\nreference) is a scan error. Example: `events.calendar` in\n`packages/events/src/recipes.ts`.\n\n## Widget surfaces (#3727)\n\n`{ kind: 'widget', ... }` contributes a widget to customizable overviews\n(smrt-svelte `./overview`). The manifest carries everything a host needs to\nregister it; the helper `registerRecipeWidgets` in smrt-svelte does that with an\ninjected module resolver, so nothing is imported at scan time.\n\n```ts\nstatic surfaces = [\n {\n kind: 'widget',\n type: 'sales-total', // registry type, unique per package\n export: '@acme/shop/svelte#SalesTotal', // the component\n label: 'Sales total', // text or an i18n key\n description: 'Order totals for the period.',\n icon: 'chart',\n version: 2, // option-schema version (default 1)\n migrate: '@acme/shop/server#migrateSalesTotal', // needs version above 1\n optionsEditor: '@acme/shop/svelte#SalesOptions', // optional dependent-choice editor\n options: [\n { key: 'model', type: 'model', label: 'Model', required: true },\n { key: 'style', type: 'enum', label: 'Style', default: 'bar',\n choices: [{ value: 'bar', label: 'Bar' }, { value: 'line', label: 'Line' }] },\n ],\n data: { load: '@acme/shop/server#loadSalesTotal', models: ['@acme/shop:Order'] },\n allowedIn: ['shop.home'], // overview ids; omit for any\n sectionDefault: true, // optional: this widget IS the section page\n defaultSpan: 2, minSpan: 1, maxSpan: 3,\n },\n] as const;\n```\n\nEmitted keys, in this order: `kind`, `type`, `export`, `label`, `description`,\n`icon`, `version`, `migrate`, `optionsEditor`, `options`, `data`, `allowedIn`,\n`sectionDefault`, `defaultSpan`, `minSpan`, `maxSpan`; absent ones are omitted. Rules (each violation is a scan\nerror, same strictness as the other kinds):\n\n- `type` is a lowercase kebab id (`[a-z][a-z0-9-]{0,31}`), unique among a\n recipe's surfaces and across the package's recipes. `export`, `data.load`,\n `migrate`, and `optionsEditor` are `'<package specifier>#<ExportName>'`;\n relative specifiers are rejected. `label` is required. A UI host resolves an\n `optionsEditor` lazily for dependent or remote choices; the server registry\n never imports it, and every saved value still passes the ordinary option schema.\n- `options` is a non-empty list of plain-JSON fields limited to the overview\n option types: `text`, `markdown`, `identifier`, `model`, `integer`, `number`,\n `boolean`, closed `enum` (non-empty distinct `choices`). There is no free-form\n query, expression, URL or SQL type. Keys are camelCase (`[A-Za-z][A-Za-z0-9]{0,31}`)\n and unique; `default` must pass its field's own rule; `min`/`max` only on\n numbers, `maxLength` only on text. Only `key`, `type`, `label`, `help`,\n `required`, `default`, `min`, `max`, `maxLength`, `choices` are accepted.\n- `sectionDefault: true` makes the widget the section page of the recipe's\n section: when the recipe leads a navigation section (the first recipe\n suggesting it), a host rendering data widgets defaults that section's\n overview to this widget alone, full width, instead of the generic shortcuts,\n count and latest records (smrt-svelte `sectionDefaultWidget`). At most one per\n recipe, and every required option needs a `default`, since it is placed with\n no options. The person can still edit the page.\n- `data` takes `load` and/or `models` (qualified class names, distinct). The\n scanner cannot see other packages, so a model is checked for format only.\n- `allowedIn` is a non-empty list of distinct overview ids\n (`[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}`); spans are integers 1 to 4 with\n `minSpan <= defaultSpan <= maxSpan`.\n\nThe scanner's copies of these rules live in `packages/scanner/src/recipe-widgets.ts`\nand a scanner test compares them to the smrt-svelte source. See\n[overview-surfaces.md](../../smrt-svelte/agents/overview-surfaces.md#recipe-widgets-recipe-widgetsts)\nfor how a host registers them.\n\n## Browser-demo mode: Live, Mock, Sample, Server (#3709)\n\nA catalog host (smrt-planner) labels each feature by how far it runs in a\nbrowser-only demo. The label is **derived at build time**, never authored: the\nmanifest build combines what the bundle-gate measured for the recipe's package\nwith the recipe's own `runtime`, `providers` and `demoSeed`, and emits it as\n`demo` on the recipe entry and `browser` on the package. A recipe that declares\nnone of those keeps its shape except for `demo`, which every recipe of a\nmeasured package gets.\n\n```jsonc\n// manifest.json and smrt-knowledge.json, top level\n\"browser\": {\n \"status\": \"browser-safe\" | \"server-only\",\n \"issues\": [\"#3624\"], // tracking issues; omitted when browser-safe\n \"reason\": \"node:crypto\", // the package's own blocker; omitted when only inherited\n \"via\": [\"@happyvertical/smrt-tenancy\"] // inherited from these; omitted when none\n}\n// on each entry of \"recipes\"\n\"demo\": {\n \"mode\": \"live\" | \"mock\" | \"sample\" | \"server\",\n \"reasons\": [\"Needs a server because ...\"], // plain sentences, in rule order\n \"mocked\": [\"smtp\"] // providers a demo fakes; omitted when none\n}\n```\n\n| Mode | Meaning |\n| --- | --- |\n| `live` | Real browser data layer (PGlite); nothing faked. |\n| `mock` | Runs in the browser, but a provider (sending mail, OAuth) is faked. |\n| `sample` | Runs in the browser on `demoSeed` fixtures only. |\n| `server` | Needs a server; a browser-only host cannot offer it. |\n\n**Package capability.** The browser gate (`packages/bundle-gate`) fails any\nmodel package that is not in its expected-failures list and does not build for\na browser, and any listed package that now builds. So a package is `server-only`\nexactly when it is listed. The ratchet attributes a defect to the package that\nowns the fix, so a dependent passes while still dragging the failure into a\nbrowser bundle; the derivation therefore also marks a package `server-only`\nwhen a workspace package it depends on (transitively, through `dependencies`)\nis, and records them in `via`. `smrt-core` is the platform baseline and does not\npropagate. The table lives in `src/manifest/browser-capability.generated.ts`\n(generated, never edited) and a bundle-gate spec fails when it is stale; see\nthat package's AGENTS.md for the regenerate command. A package the gate does\nnot measure (an app's own, a UI package) emits no `browser`, and its recipes get\na `demo` only when they declare `runtime`, `providers` or `demoSeed`.\n\n**Rules** (`src/recipe-demo.ts`, pure and browser-safe; the worst mode wins,\n`live` < `mock` < `sample` < `server`):\n\n1. `runtime: 'server'` is `server`. `runtime: 'both'` or omitted makes no claim:\n the package capability refines it, so `both` on a `server-only` package is\n `server`.\n2. A `server-only` package is `server`.\n3. A **server provider** is one that lists `secrets` (credentials a browser\n cannot hold). One with a `browserOptions` entry (an in-browser model such as\n `webllm`) is satisfied in a browser and changes nothing. Otherwise it is\n **mockable** when its `options` include `mock`. A *required* server provider\n that is neither is `sample` when the recipe has a `demoSeed` (the fixtures\n stand in for what the provider would feed), else `server`. An optional one\n that is neither leaves the mode alone and adds a reason.\n4. Any mockable server provider makes the recipe `mock` and lands in `mocked`.\n5. Otherwise `live`.\n\n**Contradictions fail the build** (like `assertRecipeOptions`), naming the\nrecipe: `runtime: 'browser'` on a `server-only` package, or beside a required\nserver provider with no in-browser and no `mock` option. Fix by giving the\nprovider a `browserOptions` entry or a `mock` option, making it optional, or\ndeclaring `both`/`server`. An explicit `both` or\n`server` on a `server-only` package is not a contradiction.\n\n**Dependencies between recipes.** `demo` is the recipe's own answer. A host that\nwants the answer including what the recipe needs calls\n`effectiveRecipeDemo(id, recipes)` (exported from both core entries): the worst\nof its own mode and every `requires` (transitively), counting the best known\nalternative of each `requiresAny` list; unknown ids are ignored.\n\n**Importing it in a browser page.** Use the subpath\n`@happyvertical/smrt-core/recipe-demo` (`deriveRecipeDemo`,\n`effectiveRecipeDemo`, `RECIPE_DEMO_MODES` and the demo types). Its module graph\nis `src/recipe-demo.ts` alone, with type-only imports, so it carries no `node:`\nimport. The root entry and `/browser` both also export these names, but both\nreach `manifest/manifest-loader.ts` through the registry, and that module calls\n`createRequire` from `node:module` as it loads, which a browser page cannot\nsatisfy. `src/__tests__/recipe-demo-entry.test.ts` walks the module graph and\nfails if the subpath gains a `node:` or package import.\n\nThe classification runs in `ManifestGenerator.applyGenerationPasses` (the one\npass sequence the Vite plugin, `ManifestBuilder` and `generateManifest()` share)\nthrough `applyRecipeDemo` in `src/scanner/recipe-demo-pass.ts`, after the\nmerged-manifest recipe assertions. It needs the package name, so it cannot run\nin the scanner, which resolves recipes before it knows the package.\n\n## How the scanner collects it\n\nThe scanner class pass finds classes by decorator, which a recipe lacks, so\n`recipes.ts` is a second matcher in the style of the agent-surface one: a class\nwhose `extends` resolves to the `SmrtRecipe` import binding from\n`@happyvertical/smrt-core` (named, aliased `SmrtRecipe as R`, or namespace\n`Core.SmrtRecipe`; any core subpath such as `/browser`). A same-named local\nclass or an import from another library is not a recipe. A recipe must extend\n`SmrtRecipe` **directly**. Within one file, extending another recipe or declaring\none as a class expression or inside a block is a scan error rather than a silent\ndrop. Across files it is not detected: a module that never names `SmrtRecipe`\nis not read, so a recipe extending a recipe or intermediate base imported from\nanother module is unsupported and would be missing from the artifacts.\n\nClass references resolve to qualified names (`@scope/pkg:Class`) through the\nimport binding: a relative specifier picks the scanned class by file, then by\nclass name (barrel re-exports keep the name); an aliased import follows the\n*imported* name; `Ns.Class` namespace members and same-file classes work; a\nbare package specifier is rejected (models must belong to this package). When\nthe imported file declares no such class, the barrel fallback binds a same-named\nscanned class, so keep model class names unique within a package. This\nwas chosen over qualified-name strings because it type-checks and survives\nrenames; strings were not needed.\n\nRecipe discovery is independent of the model `include` glob (like intents; it is\nnot gated by the `agentSurface` option), and\n`ScanResults.recipes` is populated by `resolve()`. Every error is a scan error,\nso every producer that reads `scanAndResolve()` (`smrtPlugin`, `ManifestBuilder`)\nfails the build. (The legacy `ManifestGenerator.generateManifest()` path takes\npre-scanned results and never sees recipes.) The adapter qualifies names and emits a top-level `recipes` array in\n`manifest.json`; `buildDomainKnowledgeManifest` projects the same array into\n`smrt-knowledge.json`. A package with no recipe emits no key.\n\n## Build-time validation\n\nIds are dotted lowercase and unique; `models` is non-empty, resolvable, and\nduplicate-free; every `nav` model is in `models`; `requires` are well-formed,\nnot self-referential or duplicated, and acyclic within the package (ids in other\npackages cannot be checked at scan time); `options` name only listed models and\nand only the keys below. Field names are checked in core\n(`ManifestGenerator.assertRecipeOptions`, after inherited fields merge) against\nthe merged manifest, so STI-merged, tenant, and framework-base fields such as\n`parentId` are accepted along with the universal `id`, `slug`, `context`,\n`created_at`, `updated_at`; anything else fails the build.\n\n## options: curation hints, never a second schema\n\nKeyed by model class name. `fields.<name>` accepts the smrt-fields policy\nvocabulary: `default`, `label`, `help`, `order`, `visibility`\n(`basic | advanced | hidden`), `locked`; applying a recipe produces\nfield-policy rows. `exposure.<api|mcp|cli>` accepts only `false` or\nnon-empty `{ exclude: [...] }` naming a CRUD verb (`list`, `get`, `create`,\n`update`, `delete`) or a public instance method the model declares or inherits: `true` and `include` are\nrejected because they can widen what the model already declares. Custom fields are out of scope.\n\n## User-facing help (#3591)\n\nHelp is documentation for the people using the app, not developers. It lives\nbeside the recipe as Markdown and is pointed to by `static help`:\n\n```ts\nexport class SalesRecipe extends SmrtRecipe {\n // ...\n static help = './sales.recipe.md'; // relative to the recipe file, .md only\n}\n```\n\nThe file holds an **Overview** and **Tasks** (short how-tos). A step names a\nfield as `{field:<fieldName>}` or `{field:<Model>.<fieldName>}`; the Markdown\nblock (heading, paragraph, or list item) containing a reference is **tied to\nthat field**. Keep prose in the separate file rather than the recipe class.\nField text itself comes from `@field({ description })`, the user-facing seed\nsmrt-fields uses for form help; every field a recipe shows should carry one.\n\n**Emission.** The scanner reads the file (a missing, empty, non-`.md`, absolute\nor `..`-climbing path is a scan error) and emits\n`help: { markdown, fieldRefs }` on the recipe entry in `manifest.json` and\n`smrt-knowledge.json`, so a static host needs no further request.\n`fieldRefs` (distinct references as written, sorted) is **derived**, never\nauthored. Knowledge fields also carry `description` (sensitive fields stay\nexcluded). `ManifestGenerator.assertRecipeHelp` fails the build on a reference\nto a field the recipe's models do not declare, and on a `fieldRefs` list that\ndisagrees with the Markdown. The scanner has its own copy of the reference\ngrammar (`deriveHelpFieldRefs`; it cannot import core), so that comparison also\nkeeps the two from drifting. A reference to a **sensitive** or universal (`id`,\n`slug`, ...) field also fails the build: neither reaches the knowledge artifact,\nso a host building its field list from it would silently drop the step naming\none; describe such a field in prose without a reference.\n\nKnown limitation: the help file is a generator input, but editing only it does\nnot trigger a dev-server rescan (the watch matches source globs) and generation\nsnapshots do not digest it, so rebuild (or touch a `.ts` source) after editing.\n\n**Rendering.** `src/recipe-help.ts` is pure and browser-safe (exported from\nboth core entries). `renderHelp(help, models, options?)` takes the recipe's\nhelp plus the app's effective field policies (`HelpModel[]`; label, help,\nvisibility, disabled in smrt-fields vocabulary, with `@field` descriptions) and\nreturns `{ blocks, glossary, connect? }`:\n\n- `{field:x}` becomes the effective label;\n- a block tied to a hidden, disabled, undeclared, or **advanced** field (unless\n `showAdvanced`) is dropped; a list item drops alone; a heading whose section\n had content that was all dropped is pruned (a heading that never had content\n stays);\n- the glossary lists each shown field with its effective help, else its\n description. A shown field with neither is **skipped**, not listed blank;\n- `connect` is the collapsed \"Connect other tools\" section: the recipe's\n generated REST routes, MCP tools and CLI commands (`options.surfaces`, from\n the knowledge artifact), minus the recipe's `exposure` narrowing\n (`options.exposure`). Hosts render it last.\n\nThe result is an AST of text nodes (`text`, `code`, `strong`, `em`), never HTML,\nso authored markup stays inert text and nothing needs sanitizing. Only\n`##`-`####` headings, paragraphs, flat lists and bold/italic/code are\nunderstood. `helpToMarkdown(rendered)` flattens it for hosts with their own\nrenderer; render that with raw HTML disabled. `validateHelp(help, models)`\nreturns the build-failure problems for hand-built entries.\n"
1230
1231
  },
1231
1232
  {
1232
1233
  "path": "agents/cookbook.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.57.6",
3
+ "version": "0.57.8",
4
4
  "description": "Core AI agent framework with standardized collections, object-relational mapping, and code generators",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -158,6 +158,11 @@
158
158
  "import": "./dist/utils/database-url.js",
159
159
  "default": "./dist/utils/database-url.js"
160
160
  },
161
+ "./smrt-error": {
162
+ "types": "./dist/smrt-error.d.ts",
163
+ "import": "./dist/smrt-error.js",
164
+ "default": "./dist/smrt-error.js"
165
+ },
161
166
  "./utils/import-workspace-module": {
162
167
  "types": "./dist/utils/import-workspace-module.d.ts",
163
168
  "import": "./dist/utils/import-workspace-module.js",
@@ -194,9 +199,9 @@
194
199
  "@happyvertical/files": "^0.105.2",
195
200
  "@happyvertical/json": "^0.105.2",
196
201
  "@happyvertical/logger": "^0.105.2",
197
- "@happyvertical/smrt-config": "0.57.6",
198
- "@happyvertical/smrt-scanner": "0.57.6",
199
- "@happyvertical/smrt-types": "0.57.6",
202
+ "@happyvertical/smrt-config": "0.57.8",
203
+ "@happyvertical/smrt-scanner": "0.57.8",
204
+ "@happyvertical/smrt-types": "0.57.8",
200
205
  "@happyvertical/sql": "^0.105.2",
201
206
  "@happyvertical/utils": "^0.105.2",
202
207
  "@noble/hashes": "^2.4.0",