@happyvertical/smrt-core 0.44.0 → 0.44.1

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.
Files changed (42) hide show
  1. package/AGENTS.md +8 -8
  2. package/agents/generators.md +34 -0
  3. package/agents/revision-guard.md +66 -0
  4. package/dist/cascade.d.ts +5 -9
  5. package/dist/cascade.d.ts.map +1 -1
  6. package/dist/cascade.js +65 -30
  7. package/dist/cascade.js.map +1 -1
  8. package/dist/embedded-write-queue.d.ts +8 -0
  9. package/dist/embedded-write-queue.d.ts.map +1 -1
  10. package/dist/embedded-write-queue.js +12 -2
  11. package/dist/embedded-write-queue.js.map +1 -1
  12. package/dist/generators/index.d.ts +1 -0
  13. package/dist/generators/index.d.ts.map +1 -1
  14. package/dist/generators/index.js +2 -1
  15. package/dist/generators/preflight-route.d.ts +151 -0
  16. package/dist/generators/preflight-route.d.ts.map +1 -0
  17. package/dist/generators/preflight-route.js +194 -0
  18. package/dist/generators/preflight-route.js.map +1 -0
  19. package/dist/generators/rest.d.ts +12 -0
  20. package/dist/generators/rest.d.ts.map +1 -1
  21. package/dist/generators/rest.js +12 -15
  22. package/dist/generators/rest.js.map +1 -1
  23. package/dist/generators.js +2 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -2
  27. package/dist/manifest/static-manifest.d.ts.map +1 -1
  28. package/dist/manifest/static-manifest.js +6 -2
  29. package/dist/manifest/static-manifest.js.map +1 -1
  30. package/dist/manifest/store.js +1 -1
  31. package/dist/manifest/store.js.map +1 -1
  32. package/dist/manifest.json +8 -2
  33. package/dist/object.d.ts +31 -1
  34. package/dist/object.d.ts.map +1 -1
  35. package/dist/object.js +57 -9
  36. package/dist/object.js.map +1 -1
  37. package/dist/revision-guard.d.ts +84 -0
  38. package/dist/revision-guard.d.ts.map +1 -0
  39. package/dist/revision-guard.js +120 -0
  40. package/dist/revision-guard.js.map +1 -0
  41. package/dist/smrt-knowledge.json +16 -7
  42. package/package.json +4 -4
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Revision compare-and-swap predicate construction (#2620).
3
+ *
4
+ * `SmrtObject.save()` and {@link SmrtObject.claimRevision} guard every write to
5
+ * a persisted row with a predicate on the revision the caller loaded. On
6
+ * PostgreSQL that predicate cannot be a plain equality against
7
+ * `Date.prototype.toISOString()`, because the JavaScript `Date` that carries the
8
+ * revision is two lossy conversions away from the stored value:
9
+ *
10
+ * 1. **Precision.** `updated_at` is a microsecond column — `timestamptz(6)` on
11
+ * schemas this version materializes, `timestamp(6)` on older ones — so any
12
+ * row last written by raw SQL (`updated_at = CURRENT_TIMESTAMP` / `now()`)
13
+ * stores microseconds, for example `2026-09-02 08:11:28.939980`. A `Date`
14
+ * holds milliseconds, so the exact-equality predicate matched no row and
15
+ * every later `save()` on that row raised `RUNTIME_REVISION_CONFLICT`
16
+ * forever — a permanent failure, not a lost race.
17
+ * 2. **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping
18
+ * still hold `updated_at` as `timestamp WITHOUT time zone`, and `pg`
19
+ * hydrates that type by reading the stored wall clock in the *process* zone.
20
+ * On a non-UTC host the resulting `Date` is offset from the instant the
21
+ * writer meant, so `toISOString()` produced a wall clock the row never held
22
+ * and every guarded save conflicted. The same columns are also written under
23
+ * three different conventions — `pg` serializes a `Date` in the process
24
+ * zone, `claimRevision()` writes a UTC ISO string, and raw
25
+ * `CURRENT_TIMESTAMP` writes in the *server* zone — so no single rendering
26
+ * can match every row.
27
+ *
28
+ * Both are fixed here without asking callers to compensate:
29
+ *
30
+ * - the column is truncated to milliseconds in SQL, the finest precision a
31
+ * `Date` can represent, so a microsecond tail can no longer hide the row; and
32
+ * - the revision is offered in **both** wall-clock renderings — the process-zone
33
+ * one (the inverse of `pg`'s local hydration of `timestamp`) and the UTC one
34
+ * (what `timestamptz` hydration and SMRT's own ISO writes produce). Each is
35
+ * tagged `+00`, which a `timestamptz` comparison honours and a `timestamp`
36
+ * comparison discards, so the predicate never depends on the *session*
37
+ * TimeZone either. Accepting either candidate keeps the guard correct
38
+ * whatever convention the column and driver use, so a future UTC-hydration
39
+ * fix in `@happyvertical/sql` cannot silently break it. That driver-layer
40
+ * half is tracked as happyvertical/sdk#1223; this guard deliberately does not
41
+ * wait for it.
42
+ *
43
+ * Lost-race semantics are preserved. A concurrent writer advances `updated_at`
44
+ * to roughly "now" (see `nextRevisionTimestamp`), which would have to land on
45
+ * the loaded revision — or, on a non-UTC process only, on exactly that revision
46
+ * shifted by the process's whole-hour-scale UTC offset — to the millisecond
47
+ * before it could slip past. Any ordinary concurrent write differs by at least
48
+ * one millisecond and still conflicts. On a UTC process the two renderings
49
+ * coincide and the predicate is single-valued, so it is strictly no weaker than
50
+ * the exact equality it replaces. Making it single-valued on a non-UTC process
51
+ * too — by resolving the column's actual type, or by deleting the process-zone
52
+ * rendering once happyvertical/sdk#1223 hydrates `timestamp` as UTC — is
53
+ * tracked as #2623.
54
+ *
55
+ * The predicate is PostgreSQL-only. Embedded engines take the compare/upsert
56
+ * fallback in `usesEmbeddedRevisionFallback`, and remote LibSQL stores ISO text
57
+ * whose exact equality already round-trips losslessly.
58
+ */
59
+ /** The SQL expression the PostgreSQL revision predicate compares against. */
60
+ export declare const POSTGRES_REVISION_GUARD_EXPRESSION = "date_trunc('milliseconds', updated_at)";
61
+ /**
62
+ * Render a revision as every `timestamp without time zone` wall clock it could
63
+ * legitimately correspond to, at millisecond precision.
64
+ *
65
+ * The process-zone rendering comes first because it is the inverse of `pg`'s
66
+ * current hydration; the UTC rendering is what SMRT's own writes persist. On a
67
+ * UTC process the two coincide and a single candidate is returned.
68
+ *
69
+ * @param revision - Revision loaded from the row, or supplied by the caller as
70
+ * `save({ expectedUpdatedAt })` / `claimRevision()`. Strings are parsed with
71
+ * `Date` semantics, so an ISO instant and a bare SQL wall clock both work.
72
+ * @returns One or two `YYYY-MM-DD HH:MM:SS.mmm+00` strings
73
+ * @throws {RangeError} If `revision` does not parse to a valid date
74
+ */
75
+ export declare function postgresRevisionCandidates(revision: Date | string): string[];
76
+ /**
77
+ * Build the PostgreSQL revision condition for a generic `db.update()` WHERE
78
+ * clause.
79
+ *
80
+ * @param revision - The revision the writer loaded
81
+ * @returns A single-entry condition object to spread into the update predicate
82
+ */
83
+ export declare function postgresRevisionCondition(revision: Date | string): Record<string, string[]>;
84
+ //# sourceMappingURL=revision-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revision-guard.d.ts","sourceRoot":"","sources":["../src/revision-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAIH,6EAA6E;AAC7E,eAAO,MAAM,kCAAkC,2CACL,CAAC;AAgB3C;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,EAAE,CA0B5E;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,IAAI,GAAG,MAAM,GACtB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAK1B"}
@@ -0,0 +1,120 @@
1
+ import { raw } from "@happyvertical/sql";
2
+ //#region src/revision-guard.ts
3
+ /**
4
+ * Revision compare-and-swap predicate construction (#2620).
5
+ *
6
+ * `SmrtObject.save()` and {@link SmrtObject.claimRevision} guard every write to
7
+ * a persisted row with a predicate on the revision the caller loaded. On
8
+ * PostgreSQL that predicate cannot be a plain equality against
9
+ * `Date.prototype.toISOString()`, because the JavaScript `Date` that carries the
10
+ * revision is two lossy conversions away from the stored value:
11
+ *
12
+ * 1. **Precision.** `updated_at` is a microsecond column — `timestamptz(6)` on
13
+ * schemas this version materializes, `timestamp(6)` on older ones — so any
14
+ * row last written by raw SQL (`updated_at = CURRENT_TIMESTAMP` / `now()`)
15
+ * stores microseconds, for example `2026-09-02 08:11:28.939980`. A `Date`
16
+ * holds milliseconds, so the exact-equality predicate matched no row and
17
+ * every later `save()` on that row raised `RUNTIME_REVISION_CONFLICT`
18
+ * forever — a permanent failure, not a lost race.
19
+ * 2. **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping
20
+ * still hold `updated_at` as `timestamp WITHOUT time zone`, and `pg`
21
+ * hydrates that type by reading the stored wall clock in the *process* zone.
22
+ * On a non-UTC host the resulting `Date` is offset from the instant the
23
+ * writer meant, so `toISOString()` produced a wall clock the row never held
24
+ * and every guarded save conflicted. The same columns are also written under
25
+ * three different conventions — `pg` serializes a `Date` in the process
26
+ * zone, `claimRevision()` writes a UTC ISO string, and raw
27
+ * `CURRENT_TIMESTAMP` writes in the *server* zone — so no single rendering
28
+ * can match every row.
29
+ *
30
+ * Both are fixed here without asking callers to compensate:
31
+ *
32
+ * - the column is truncated to milliseconds in SQL, the finest precision a
33
+ * `Date` can represent, so a microsecond tail can no longer hide the row; and
34
+ * - the revision is offered in **both** wall-clock renderings — the process-zone
35
+ * one (the inverse of `pg`'s local hydration of `timestamp`) and the UTC one
36
+ * (what `timestamptz` hydration and SMRT's own ISO writes produce). Each is
37
+ * tagged `+00`, which a `timestamptz` comparison honours and a `timestamp`
38
+ * comparison discards, so the predicate never depends on the *session*
39
+ * TimeZone either. Accepting either candidate keeps the guard correct
40
+ * whatever convention the column and driver use, so a future UTC-hydration
41
+ * fix in `@happyvertical/sql` cannot silently break it. That driver-layer
42
+ * half is tracked as happyvertical/sdk#1223; this guard deliberately does not
43
+ * wait for it.
44
+ *
45
+ * Lost-race semantics are preserved. A concurrent writer advances `updated_at`
46
+ * to roughly "now" (see `nextRevisionTimestamp`), which would have to land on
47
+ * the loaded revision — or, on a non-UTC process only, on exactly that revision
48
+ * shifted by the process's whole-hour-scale UTC offset — to the millisecond
49
+ * before it could slip past. Any ordinary concurrent write differs by at least
50
+ * one millisecond and still conflicts. On a UTC process the two renderings
51
+ * coincide and the predicate is single-valued, so it is strictly no weaker than
52
+ * the exact equality it replaces. Making it single-valued on a non-UTC process
53
+ * too — by resolving the column's actual type, or by deleting the process-zone
54
+ * rendering once happyvertical/sdk#1223 hydrates `timestamp` as UTC — is
55
+ * tracked as #2623.
56
+ *
57
+ * The predicate is PostgreSQL-only. Embedded engines take the compare/upsert
58
+ * fallback in `usesEmbeddedRevisionFallback`, and remote LibSQL stores ISO text
59
+ * whose exact equality already round-trips losslessly.
60
+ */
61
+ /** The SQL expression the PostgreSQL revision predicate compares against. */
62
+ var POSTGRES_REVISION_GUARD_EXPRESSION = "date_trunc('milliseconds', updated_at)";
63
+ function pad(value, width = 2) {
64
+ return String(value).padStart(width, "0");
65
+ }
66
+ function formatWallClock(parts) {
67
+ const [year, month, day, hours, minutes, seconds, milliseconds] = parts;
68
+ return `${pad(year, 4)}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}+00`;
69
+ }
70
+ /**
71
+ * Render a revision as every `timestamp without time zone` wall clock it could
72
+ * legitimately correspond to, at millisecond precision.
73
+ *
74
+ * The process-zone rendering comes first because it is the inverse of `pg`'s
75
+ * current hydration; the UTC rendering is what SMRT's own writes persist. On a
76
+ * UTC process the two coincide and a single candidate is returned.
77
+ *
78
+ * @param revision - Revision loaded from the row, or supplied by the caller as
79
+ * `save({ expectedUpdatedAt })` / `claimRevision()`. Strings are parsed with
80
+ * `Date` semantics, so an ISO instant and a bare SQL wall clock both work.
81
+ * @returns One or two `YYYY-MM-DD HH:MM:SS.mmm+00` strings
82
+ * @throws {RangeError} If `revision` does not parse to a valid date
83
+ */
84
+ function postgresRevisionCandidates(revision) {
85
+ const date = revision instanceof Date ? revision : new Date(revision);
86
+ if (Number.isNaN(date.getTime())) throw new RangeError(`Revision guard requires a valid timestamp, received: ${String(revision)}`);
87
+ const local = formatWallClock([
88
+ date.getFullYear(),
89
+ date.getMonth() + 1,
90
+ date.getDate(),
91
+ date.getHours(),
92
+ date.getMinutes(),
93
+ date.getSeconds(),
94
+ date.getMilliseconds()
95
+ ]);
96
+ const utc = formatWallClock([
97
+ date.getUTCFullYear(),
98
+ date.getUTCMonth() + 1,
99
+ date.getUTCDate(),
100
+ date.getUTCHours(),
101
+ date.getUTCMinutes(),
102
+ date.getUTCSeconds(),
103
+ date.getUTCMilliseconds()
104
+ ]);
105
+ return local === utc ? [local] : [local, utc];
106
+ }
107
+ /**
108
+ * Build the PostgreSQL revision condition for a generic `db.update()` WHERE
109
+ * clause.
110
+ *
111
+ * @param revision - The revision the writer loaded
112
+ * @returns A single-entry condition object to spread into the update predicate
113
+ */
114
+ function postgresRevisionCondition(revision) {
115
+ return { [raw(`${POSTGRES_REVISION_GUARD_EXPRESSION} in`)]: postgresRevisionCandidates(revision) };
116
+ }
117
+ //#endregion
118
+ export { POSTGRES_REVISION_GUARD_EXPRESSION, postgresRevisionCandidates, postgresRevisionCondition };
119
+
120
+ //# sourceMappingURL=revision-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revision-guard.js","names":[],"sources":["../src/revision-guard.ts"],"sourcesContent":["/**\n * Revision compare-and-swap predicate construction (#2620).\n *\n * `SmrtObject.save()` and {@link SmrtObject.claimRevision} guard every write to\n * a persisted row with a predicate on the revision the caller loaded. On\n * PostgreSQL that predicate cannot be a plain equality against\n * `Date.prototype.toISOString()`, because the JavaScript `Date` that carries the\n * revision is two lossy conversions away from the stored value:\n *\n * 1. **Precision.** `updated_at` is a microsecond column — `timestamptz(6)` on\n * schemas this version materializes, `timestamp(6)` on older ones — so any\n * row last written by raw SQL (`updated_at = CURRENT_TIMESTAMP` / `now()`)\n * stores microseconds, for example `2026-09-02 08:11:28.939980`. A `Date`\n * holds milliseconds, so the exact-equality predicate matched no row and\n * every later `save()` on that row raised `RUNTIME_REVISION_CONFLICT`\n * forever — a permanent failure, not a lost race.\n * 2. **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping\n * still hold `updated_at` as `timestamp WITHOUT time zone`, and `pg`\n * hydrates that type by reading the stored wall clock in the *process* zone.\n * On a non-UTC host the resulting `Date` is offset from the instant the\n * writer meant, so `toISOString()` produced a wall clock the row never held\n * and every guarded save conflicted. The same columns are also written under\n * three different conventions — `pg` serializes a `Date` in the process\n * zone, `claimRevision()` writes a UTC ISO string, and raw\n * `CURRENT_TIMESTAMP` writes in the *server* zone — so no single rendering\n * can match every row.\n *\n * Both are fixed here without asking callers to compensate:\n *\n * - the column is truncated to milliseconds in SQL, the finest precision a\n * `Date` can represent, so a microsecond tail can no longer hide the row; and\n * - the revision is offered in **both** wall-clock renderings — the process-zone\n * one (the inverse of `pg`'s local hydration of `timestamp`) and the UTC one\n * (what `timestamptz` hydration and SMRT's own ISO writes produce). Each is\n * tagged `+00`, which a `timestamptz` comparison honours and a `timestamp`\n * comparison discards, so the predicate never depends on the *session*\n * TimeZone either. Accepting either candidate keeps the guard correct\n * whatever convention the column and driver use, so a future UTC-hydration\n * fix in `@happyvertical/sql` cannot silently break it. That driver-layer\n * half is tracked as happyvertical/sdk#1223; this guard deliberately does not\n * wait for it.\n *\n * Lost-race semantics are preserved. A concurrent writer advances `updated_at`\n * to roughly \"now\" (see `nextRevisionTimestamp`), which would have to land on\n * the loaded revision — or, on a non-UTC process only, on exactly that revision\n * shifted by the process's whole-hour-scale UTC offset — to the millisecond\n * before it could slip past. Any ordinary concurrent write differs by at least\n * one millisecond and still conflicts. On a UTC process the two renderings\n * coincide and the predicate is single-valued, so it is strictly no weaker than\n * the exact equality it replaces. Making it single-valued on a non-UTC process\n * too — by resolving the column's actual type, or by deleting the process-zone\n * rendering once happyvertical/sdk#1223 hydrates `timestamp` as UTC — is\n * tracked as #2623.\n *\n * The predicate is PostgreSQL-only. Embedded engines take the compare/upsert\n * fallback in `usesEmbeddedRevisionFallback`, and remote LibSQL stores ISO text\n * whose exact equality already round-trips losslessly.\n */\n\nimport { raw } from '@happyvertical/sql';\n\n/** The SQL expression the PostgreSQL revision predicate compares against. */\nexport const POSTGRES_REVISION_GUARD_EXPRESSION =\n \"date_trunc('milliseconds', updated_at)\";\n\nfunction pad(value: number, width = 2): string {\n return String(value).padStart(width, '0');\n}\n\nfunction formatWallClock(\n parts: [number, number, number, number, number, number, number],\n): string {\n const [year, month, day, hours, minutes, seconds, milliseconds] = parts;\n return (\n `${pad(year, 4)}-${pad(month)}-${pad(day)} ` +\n `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}+00`\n );\n}\n\n/**\n * Render a revision as every `timestamp without time zone` wall clock it could\n * legitimately correspond to, at millisecond precision.\n *\n * The process-zone rendering comes first because it is the inverse of `pg`'s\n * current hydration; the UTC rendering is what SMRT's own writes persist. On a\n * UTC process the two coincide and a single candidate is returned.\n *\n * @param revision - Revision loaded from the row, or supplied by the caller as\n * `save({ expectedUpdatedAt })` / `claimRevision()`. Strings are parsed with\n * `Date` semantics, so an ISO instant and a bare SQL wall clock both work.\n * @returns One or two `YYYY-MM-DD HH:MM:SS.mmm+00` strings\n * @throws {RangeError} If `revision` does not parse to a valid date\n */\nexport function postgresRevisionCandidates(revision: Date | string): string[] {\n const date = revision instanceof Date ? revision : new Date(revision);\n if (Number.isNaN(date.getTime())) {\n throw new RangeError(\n `Revision guard requires a valid timestamp, received: ${String(revision)}`,\n );\n }\n const local = formatWallClock([\n date.getFullYear(),\n date.getMonth() + 1,\n date.getDate(),\n date.getHours(),\n date.getMinutes(),\n date.getSeconds(),\n date.getMilliseconds(),\n ]);\n const utc = formatWallClock([\n date.getUTCFullYear(),\n date.getUTCMonth() + 1,\n date.getUTCDate(),\n date.getUTCHours(),\n date.getUTCMinutes(),\n date.getUTCSeconds(),\n date.getUTCMilliseconds(),\n ]);\n return local === utc ? [local] : [local, utc];\n}\n\n/**\n * Build the PostgreSQL revision condition for a generic `db.update()` WHERE\n * clause.\n *\n * @param revision - The revision the writer loaded\n * @returns A single-entry condition object to spread into the update predicate\n */\nexport function postgresRevisionCondition(\n revision: Date | string,\n): Record<string, string[]> {\n return {\n [raw(`${POSTGRES_REVISION_GUARD_EXPRESSION} in`)]:\n postgresRevisionCandidates(revision),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8DA,IAAa,qCACX;AAEF,SAAS,IAAI,OAAe,QAAQ,GAAW;CAC7C,OAAO,OAAO,KAAK,CAAC,CAAC,SAAS,OAAO,GAAG;AAC1C;AAEA,SAAS,gBACP,OACQ;CACR,MAAM,CAAC,MAAM,OAAO,KAAK,OAAO,SAAS,SAAS,gBAAgB;CAClE,OACE,GAAG,IAAI,MAAM,CAAC,EAAE,GAAG,IAAI,KAAK,EAAE,GAAG,IAAI,GAAG,EAAE,GACvC,IAAI,KAAK,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,IAAI,cAAc,CAAC,EAAE;AAE1E;;;;;;;;;;;;;;;AAgBA,SAAgB,2BAA2B,UAAmC;CAC5E,MAAM,OAAO,oBAAoB,OAAO,WAAW,IAAI,KAAK,QAAQ;CACpE,IAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,GAC7B,MAAM,IAAI,WACR,wDAAwD,OAAO,QAAQ,GACzE;CAEF,MAAM,QAAQ,gBAAgB;EAC5B,KAAK,YAAY;EACjB,KAAK,SAAS,IAAI;EAClB,KAAK,QAAQ;EACb,KAAK,SAAS;EACd,KAAK,WAAW;EAChB,KAAK,WAAW;EAChB,KAAK,gBAAgB;CACvB,CAAC;CACD,MAAM,MAAM,gBAAgB;EAC1B,KAAK,eAAe;EACpB,KAAK,YAAY,IAAI;EACrB,KAAK,WAAW;EAChB,KAAK,YAAY;EACjB,KAAK,cAAc;EACnB,KAAK,cAAc;EACnB,KAAK,mBAAmB;CAC1B,CAAC;CACD,OAAO,UAAU,MAAM,CAAC,KAAK,IAAI,CAAC,OAAO,GAAG;AAC9C;;;;;;;;AASA,SAAgB,0BACd,UAC0B;CAC1B,OAAO,GACJ,IAAI,GAAG,mCAAmC,IAAI,IAC7C,2BAA2B,QAAQ,EACvC;AACF"}
@@ -3,19 +3,20 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.44.0",
6
+ "packageVersion": "0.44.1",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "a72b490253afe2593b2a72115b48a456c1a0a6a956eea969bd22b85d1f3ac961",
11
- "packageJson": "f4804941176a45578bebc9972b60cca56b62f823940f4d1bc7cb7ba07ab8fdcc",
12
- "agents": "ee09cfd65c615877c7baa31add9a6165a841e1a919c9510b12ab6f541383284b",
10
+ "manifest": "baef68f9420fc9c2e760fd3e6ccb14a831c54d0aeb38e96e374a2050f741dca6",
11
+ "packageJson": "389bbac154f377dc06d190a9cc8db417f567ac8ee3f5ccee4e3a7016fc633894",
12
+ "agents": "72902bcfc539f0644a0b7f9dec17d3bb72194a753e569a5a6f63f85e52fb1810",
13
13
  "moduleDoc:agents/change-feed.md": "1530ded9ed605aa9b8a772b3ba4dd992a3f797f4d7c9ede3b3389dd7bfb401fa",
14
14
  "moduleDoc:agents/change-signals.md": "d9cb6a5541728ffea46607a6b1d4fa61d4621849f2b4ea86a0645fbb0af892e9",
15
- "moduleDoc:agents/generators.md": "f45658e75f1887ed4e8354f4e1f23145ef869d87d72ec553cc95733ca1a5d3ac",
15
+ "moduleDoc:agents/generators.md": "1c0243c353204b40ed4d5ad688cf812d8eb7e2bb45ffce7e3ce8dca894917b0a",
16
16
  "moduleDoc:agents/schema-paths.md": "561e01104eda21dd5c2c6f6d290bf722a7478c12800136ce61bff8193c8bbc2f",
17
17
  "moduleDoc:agents/data-query.md": "1b72411d441ce2285bf0c89674e7aa996832a58b552b23a6d43834b907d91355",
18
18
  "moduleDoc:agents/collection-reads.md": "4ce06e8b70b9ce9b77b47b3e2ed266c899bb7962ca015d4714f07a4aca10a865",
19
+ "moduleDoc:agents/revision-guard.md": "aa6b1ddb5b6b49fa27ebbe575ec7fcd6d5ceb35ee25acc702f877a226eb9a99a",
19
20
  "moduleDoc:agents/memory.md": "658cb34f3a499e290c14ba0dd8cf0bf82bcc571da01150b61070abf100894dfc",
20
21
  "moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5"
21
22
  },
@@ -678,6 +679,9 @@
678
679
  {
679
680
  "name": "delete",
680
681
  "async": true,
682
+ "params": [
683
+ "options?: SmrtDeleteOptions"
684
+ ],
681
685
  "returns": "Promise<void>"
682
686
  },
683
687
  {
@@ -975,7 +979,7 @@
975
979
  "polymorphicAssociations": 1,
976
980
  "uuidColumns": 3
977
981
  },
978
- "agentDoc": "# @happyvertical/smrt-core\n\nORM, code generation, AI integration, and the DispatchBus. Everything else builds on this.\n\nKey surfaces are `SmrtObject`, `SmrtCollection`, `ObjectRegistry`,\n`DispatchBus`, `GlobalInterceptors`, and `LearningMemory`; this file documents\ntheir invariants and source locations, and the module docs below cover the\nper-subsystem semantics.\n\n## Modules\n\nSubsystem semantics live in sibling module docs — read the one for the\nsubsystem you are editing. This file keeps what holds across all of them.\n\n| Module | Scope | Module doc |\n|---|---|---|\n| `src/change-feed.ts` | the adapter-agnostic change-observation spine — `_smrt_changes`, cursors, table versions, generated `_changes` routes, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` + the generated `_events` SSE route | the push companion to the change feed — the signal bus, cross-replica fan-out, the SSE route, and its documented gaps | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/` + `src/vite-plugin/web-collections.ts` | REST/CLI/MCP/web-collection generation, the `manifestHash` emission sites, and generated conditional-GET / ETag v2 semantics | [agents/generators.md](agents/generators.md) |\n| `src/schema/` | the four `SchemaGenerator` entry points, which two reach production, why schema drift stayed invisible, and the #2382 index/tenancy rules | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/data-query.ts` | canonical bounded data-query normalizer and transport-neutral envelope (#2444) | [agents/data-query.md](agents/data-query.md) |\n| `src/collection.ts` | bounded collection reads, projections, latest-related hydration, facets, counts, and read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n\n## SmrtObject Lifecycle\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` calls use the object's loaded `updated_at` revision in the\n database `UPDATE`; zero affected rows throws `RUNTIME_REVISION_CONFLICT`\n without overwriting the newer row. `save({ expectedUpdatedAt })` supplies an\n explicit revision when a caller binds the mutation to an earlier preview or\n selection snapshot. Embedded adapters serialize every same-process model\n save, delete, and complete `SmrtObject.withTransaction()` callback through\n one queue; bound saves re-enter that hold. Custom write paths must use those\n public APIs rather than bypassing the CAS ordering contract.\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\n## LearningMemory (#1886)\n\n`LearningMemory` provides tenant-isolated, confidence-scored recall over\n`_smrt_contexts` plus optional injected semantic search. `capture()` reinforces\nsuccesses and decays failures while updating outcome counters; `recall()`\napplies confidence, expiry, time-decay, and hierarchical-scope filters and\nrefreshes `last_used_at`. Detailed persistence and search semantics are in\n[agents/memory.md](agents/memory.md). Keep semantic search behind the\n`SmrtCollection.semanticSearch`-compatible injection boundary.\n\n## SmrtCollection Query\n\n```typescript\nawait collection.list({\n where: { status: 'active', 'price >': 10 },\n limit: 50, offset: 0, orderBy: 'created_at DESC'\n});\n```\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [agents/collection-reads.md](agents/collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\nThis list is the set `@happyvertical/sql`'s `buildWhere` can execute, and\n`convertWhereKeys` accepts nothing outside it — an operator accepted here but\nunknown there fails inside the query builder, after the API said the query was\nvalid (#2276). Two entries were removed for that reason and now reject at the\nAPI boundary: `contains` (never existed in the SQL layer; use `like` with\nexplicit wildcards) and dot-notation JSON paths such as `metadata.userId` (never\nrewritten into an extraction expression, so they reached SQL as qualified column\nreferences). Re-adding either requires the query builder to support it first;\n`src/__tests__/issue-2276-where-contract.test.ts` executes every accepted\noperator against a database to keep the two in step.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [agents/query-bounds.md](agents/query-bounds.md).\n\n## Canonical Bounded Data Queries (#2444)\n\nThe normalizers and fingerprint are the trust boundary for the\ntransport-neutral query envelope; full bounds, schema, and output rules live in\n[agents/data-query.md](agents/data-query.md). Adapters own tenant/principal\naccess and query execution.\n\n## Object Memory & Semantic Search\n\nContext memory and semantic search are persistence primitives inherited by\n`SmrtObject`/`SmrtCollection`; their storage, scope, expiry, and tenant\ninvariants are in [agents/memory.md](agents/memory.md).\n\n## @smrt() Decorator Options\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes, #2357 — see \"Schema paths\"), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n\n## Vite Plugin\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Schema paths (#2382)\n\n`ensureSystemTables(db, typeHint?)` is the public, idempotent provisioning\nboundary for framework-owned `_smrt_*` tables. Call it on a base PostgreSQL\nconnection before opening a caller-owned transaction; its advisory-locked\nbootstrap prevents missing-table probes from poisoning that transaction.\n\nProduction DDL comes from the **manifest** paths\n(`generateSTISchemaFromManifest`/`generateCTISchemaFromManifest`, selected in\n`src/scanner/manifest-generator.ts` → registered `schema` → `db:migrate`). The\n**registry** paths feed `getTestDatabase()`. Manifest and registry schemas must\nagree on same-package foreign keys as well as columns and indexes:\n`@foreignKey` emits a named physical constraint, while `@crossPackageRef` and\n`@tenantId` remain indexed runtime relationships without physical constraints.\nNatural-key references default to `CASCADE`; ordinary references default to\nimmediate `NO ACTION`, matching `SmrtObject.delete()`.\n\nSame-package archival/audit identifiers that intentionally outlive their\nparent may use `@foreignKey(Target, { constraint: false })`. This explicit\nexception retains relationship loading, indexing, and application-side delete\nmetadata while omitting the physical constraint, schema dependency, and\napp-side cascade/preflight action so the stored identifier survives deletion;\ndocument the retention reason at the field, and keep ordinary same-package\nrelationships constrained.\n\nWhen a relationship is valid on every engine but a particular database cannot\nfaithfully enforce its physical shape, use the public, explicit allowlist\n`@foreignKey(Target, { constraint: { engines: ['postgres', 'sqlite'] } })`.\nOnly physical DDL and schema dependency planning are engine-scoped; native UUID\nstorage, relationship loading, indexes, and application-side delete enforcement\nremain active on every engine. Empty or unknown allowlists fail closed. Do not\nuse this option to hide an otherwise invalid schema.\n\n- Change column/index emission on every shipping path, proven by the path-parity\n test `src/schema/schema-path-parity.test.ts` (#2359; index rules in the module doc). A \"same as migrations\" comment is a claim to check.\n- Every new query predicate ships with its index, or a reason it doesn't.\n- Creation is dependency-planned on every entry point. PostgreSQL defers mutual\n cycle constraints until both tables exist; SQLite keeps cycles inline;\n DuckDB refuses unsupported cycles/actions unless the field has an explicit\n physical-constraint engine allowlist rather than silently omitting them.\n In particular, generated same-package constraints retain the compatibility\n default `ON UPDATE CASCADE`; DuckDB/JSON cannot enforce that action and must\n return an actionable refusal instead of stripping the clause.\n PostgreSQL deferred adds are idempotent and probe the exact child/parent\n columns for orphans before `NOT VALID` + validation. Rollback drops children\n before parents, removes deferred PostgreSQL cycle constraints first, and\n defers SQLite checks while dropping populated cycles. Schema aggregation that\n deliberately filters a parent also removes the retained child's physical FK.\n- Numeric types, uuid casts, conflict targets, timestamps, migrations: run the\n `test:postgres` lane — SQLite affinity accepts what PostgreSQL rejects.\n- Read `dist/manifest.json`/regenerated schemas for what a decorator produced;\n count across all packages instead of sampling.\n- Tenant scoping is whole-path: every unique constraint and conflict target on a\n tenant-scoped table carries the tenant column, and every read path — not only\n `list()` — is interceptor-aware.\n- Rolling indexes out is part of the change: a bulk `CREATE INDEX` batch needs\n the bounded, concurrent migrate path (#2362, Gotchas), or it takes production\n down on deploy.\n\n## Gotchas\n\n- **Filesystem support is a lazy boundary (#1979)**: `SmrtClass` acquires `options.fs` adapters via `createFilesystemAdapter()` (`src/filesystem-loader.ts`), never a static `@happyvertical/files` import — the files SDK statically pulls @aws-sdk/client-s3 and reaches googleapis, and a static edge here would land it in every downstream SSR bundle. Node/tsx/vite-dev runtimes resolve it on first use; fully-bundled deployments import `@happyvertical/smrt-core/filesystem` at startup. Use `importOptionalDependency()` (`src/lazy-external.ts`) for any similar optional heavyweight dependency.\n- **Transaction-bound instances**: `SmrtClass.withDatabase(db, callback)` temporarily binds an initialized instance (including its public `options.db`) to a caller-owned transaction database and restores only the database binding. Transaction owners persisting one object should use `SmrtObject.withTransaction(callback)`, which restores identity/revision metadata after rollback and serializes its embedded callback with ordinary writes. Bound saves re-enter that hold. Never reach into `_db`, and do not use the same instance concurrently during either callback.\n- **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`\n- **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)\n- **No runtime schema creation**: application tables must be prepared explicitly via migrations/tooling; runtime verification is `tableExists()` only (`src/schema/table-verifier.ts`) — no column, type, or index check\n- **PostgreSQL migrate batches are always time-bounded (#2362)**: `MigrationTracker.applyAll({ atomic: true })` emits `SET LOCAL lock_timeout`/`statement_timeout` before any DDL, so a batch blocked on one table cannot hold its earlier locks indefinitely. `postgresSafe: true` adds concurrent-index mode — non-index DDL commits atomically, then index DDL runs `CONCURRENTLY` on a session pinned via `db.acquireSession()` (a pooled `db.query` would not keep the `SET` and the DDL on one connection). That mode is deliberately **not atomic**: unfinished index migrations are recorded `failed`, not `running`, and their `error_message` carries a `[smrt: concurrent-index phase 1 committed]` marker so a reconciling re-run resumes at the index build instead of replaying committed DDL. INVALID indexes are found via `pg_index.indisvalid` (`pg_indexes` reports them as present) and dropped before rebuild. Operational detail: `packages/cli/AGENTS.md`.\n- **Retry logic is transient-only (#2366)**: `db.get()`/`db.upsert()` retry 4× total (initial + 3), but `ErrorUtils.withRetry` classifies via the cause chain (`src/db-errors.ts`) and rethrows deterministic failures immediately — constraint violations, bad input syntax, missing tables, aborted PG tx (`25P02`). `@happyvertical/sql` stringifies the driver text into `context.originalError`, so **never match `error.message`**; use `classifyDatabaseError()` / `isUniqueViolationError()` / `isAbortedTransactionError()`.\n- **Field caching**: `_cachedFields` populated during `Collection.create()` — eliminates async `getFields()` per query\n- **Smart cloning**: arrays/objects shallow-cloned in property init to prevent aliasing (Issue #22)\n- **Table verification cache**: `isTableVerified(dbUrl, tableName)` avoids redundant `tableExists()` calls\n- **Manifest required**: build-time AST scanning creates manifest. Without vitest plugin → \"No field metadata\"\n- **ManifestBuilder fails on scanner errors**: every production manifest path\n must abort before adapting partial scan results. A syntax error or unresolved\n `@smrt()` config spread cannot be allowed to emit a default-open manifest.\n- **Vite plugin loads scanner from `dist/` first**: `src/vite-plugin/import-build-aware.ts` prefers `dist/` when it exists on disk; it only falls back to `src/` on fresh clones. So if you edit `src/scanner/*.ts` or `src/schema/generator.ts` and want those edits reflected in consumer manifest generation, you must rebuild (`pnpm build` or have `pnpm dev` / `pnpm build:watch` running in core). This is intentional — sniffing `.ts` vs `.js` via `import.meta.url` was non-deterministic under tsx and broke 12–13 publishes (#1139).\n- **Bundled registry ownership**: flattened production bundles can rewrite constructor names and make decorator-time stack inference attribute provider code to the consumer. Generated registration repairs identity only from the exact imported constructor plus an explicit package and isolated one-object manifest; never infer ownership from output paths, simple names, or table names. Distinct packages may export the same simple name under qualified keys. The production-consumer gate lives in `packages/bundle-gate/src/__tests__/registry-identity.spec.ts` (#2308).\n",
982
+ "agentDoc": "# @happyvertical/smrt-core\n\nORM, code generation, AI integration, and the DispatchBus. Everything else builds on this.\n\nKey surfaces are `SmrtObject`, `SmrtCollection`, `ObjectRegistry`,\n`DispatchBus`, `GlobalInterceptors`, and `LearningMemory`; this file documents\ntheir invariants and source locations, and the module docs below cover the\nper-subsystem semantics.\n\n## Modules\n\nSubsystem semantics live in sibling module docs — read the one for the\nsubsystem you are editing. This file keeps what holds across all of them.\n\n| Module | Scope | Module doc |\n|---|---|---|\n| `src/change-feed.ts` | the adapter-agnostic change-observation spine — `_smrt_changes`, cursors, table versions, generated `_changes` routes, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` + the generated `_events` SSE route | the push companion to the change feed — the signal bus, cross-replica fan-out, the SSE route, and its documented gaps | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/` + `src/vite-plugin/web-collections.ts` | REST/CLI/MCP/web-collection generation, the `manifestHash` emission sites, and generated conditional-GET / ETag v2 semantics | [agents/generators.md](agents/generators.md) |\n| `src/schema/` | the four `SchemaGenerator` entry points, which two reach production, why schema drift stayed invisible, and the #2382 index/tenancy rules | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/data-query.ts` | canonical bounded data-query normalizer and transport-neutral envelope (#2444) | [agents/data-query.md](agents/data-query.md) |\n| `src/collection.ts` | bounded collection reads, projections, latest-related hydration, facets, counts, and read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n\n## SmrtObject Lifecycle\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [agents/revision-guard.md](agents/revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\n## LearningMemory (#1886)\n\n`LearningMemory` provides tenant-isolated, confidence-scored recall over\n`_smrt_contexts` plus optional injected semantic search. `capture()` reinforces\nsuccesses and decays failures while updating outcome counters; `recall()`\napplies confidence, expiry, time-decay, and hierarchical-scope filters and\nrefreshes `last_used_at`. Detailed persistence and search semantics are in\n[agents/memory.md](agents/memory.md). Keep semantic search behind the\n`SmrtCollection.semanticSearch`-compatible injection boundary.\n\n## SmrtCollection Query\n\n```typescript\nawait collection.list({\n where: { status: 'active', 'price >': 10 },\n limit: 50, offset: 0, orderBy: 'created_at DESC'\n});\n```\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [agents/collection-reads.md](agents/collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\nThis list is the set `@happyvertical/sql`'s `buildWhere` can execute, and\n`convertWhereKeys` accepts nothing outside it — an operator accepted here but\nunknown there fails inside the query builder, after the API said the query was\nvalid (#2276). Two entries were removed for that reason and now reject at the\nAPI boundary: `contains` (never existed in the SQL layer; use `like` with\nexplicit wildcards) and dot-notation JSON paths such as `metadata.userId` (never\nrewritten into an extraction expression, so they reached SQL as qualified column\nreferences). Re-adding either requires the query builder to support it first;\n`src/__tests__/issue-2276-where-contract.test.ts` executes every accepted\noperator against a database to keep the two in step.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [agents/query-bounds.md](agents/query-bounds.md).\n\n## Canonical Bounded Data Queries (#2444)\n\nThe normalizers and fingerprint are the trust boundary for the\ntransport-neutral query envelope; full bounds, schema, and output rules live in\n[agents/data-query.md](agents/data-query.md). Adapters own tenant/principal\naccess and query execution.\n\n## Object Memory & Semantic Search\n\nContext memory and semantic search are persistence primitives inherited by\n`SmrtObject`/`SmrtCollection`; their storage, scope, expiry, and tenant\ninvariants are in [agents/memory.md](agents/memory.md).\n\n## @smrt() Decorator Options\n\nKey options: `tableName`, `tableStrategy` ('cti'|'sti'), `conflictColumns`, `indexes` (declared multi-column indexes, #2357 — see \"Schema paths\"), `api`/`mcp`/`cli` (generation config), `ai` (callable methods), `hooks` (beforeSave/afterSave/beforeDelete/afterDelete), `embeddings` (auto-generate), `tenantScoped`, `agent`, `ui` (`{ icon, label, description }` — nav/help hints round-tripped through the manifest as plain data; `description` is the object-level seed for form-level help, #2046).\n\nRegistration sets `SMRT_TABLE_NAME` static property (survives minification).\n\n## @field() UI hints (#2046)\n\n`@field({ ui: { basic, group, order, locked } })` — a static, presentation-only\nseed for the field-policy rail (epic #2045). Carried in the manifest under the\nfield's `_meta.ui` (never a top-level `FieldDefinition` key), readable at\nruntime via `getAllFields()` at `field._meta.ui`, and emitted (sanitized) with\n`description` into generated web-collection definitions and browser MCP tool\nschemas. No schema/persistence/security effect — `sensitive`/`readPermission`\nstay the security rail, and `sensitive`/`transient` fields never emit to the\nclient at all.\n\n## Domain Knowledge Artifacts\n\n`smrtPlugin()` writes runtime manifests and agent/developer knowledge artifacts:\n\n- local dev/build: `.smrt/manifest.json` and `.smrt/smrt-knowledge.json`\n- package build: `dist/manifest.json` and `dist/smrt-knowledge.json`\n\nKeep `manifest.json` runtime-focused. `smrt-knowledge.json` is the deterministic\nagent contract for downstream review and architecture tools.\n\nThe schema-version-1 object projection is additive and high-signal: it retains\nnormalized tenant mode/field, explicit `cti`/`sti` strategy, conflict columns,\nmethod signatures, and field defaults/constraints/readonly/transient flags.\nSensitive fields are removed before both `fields` and `relationships` are\nderived, including legacy flags stored under `_meta`; matching field and\nsnake-case column names are also removed from projected conflict columns, and a\nsensitive custom tenant field is omitted while retaining scope and mode.\nGenerated artifacts assert this boundary with `sensitiveFieldsExcluded: true`;\nthe optional marker keeps schema version 1 additive while letting readers\nidentify older artifacts that require raw-manifest corroboration.\n\nConfig precedence for knowledge is defaults → top-level `knowledge` in\n`smrt.config.ts` → `packages[packageName].knowledge` → plugin option →\nobject-level `@smrt({ knowledge })`.\n\nObject-level `knowledge: false` excludes an object from authored context only;\nit must not change runtime manifest registration. Use\n`knowledge: { tags, summary, risks }` for review-sensitive domain objects.\n\nHTTP knowledge routes are disabled by default. If `knowledge.api.enabled` is\ntrue, generated SvelteKit routes must stay GET-only and guarded by dev mode or\nadmin auth.\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n\n## Vite Plugin\n\n```typescript\n// vite.config.ts — required for @smrt() decorators (Vite 8+, oxc transform)\nexport default defineConfig({\n oxc: {\n decorator: {\n legacy: true,\n emitDecoratorMetadata: true,\n },\n },\n});\n```\n\nUnder Vite 8 the oxc transform does not honor the pre-Vite-8 `esbuild.tsconfigRaw`\nrecipe (or tsconfig `experimentalDecorators` reached through SvelteKit's\n`extends \"./.svelte-kit/tsconfig.json\"` chain), so that recipe throws\n`SyntaxError: Invalid or unexpected token` on the first SSR request. Configure\ndecorators through `oxc.decorator` instead. Consumers still pinned on vite<8 need\nthe legacy `esbuild.tsconfigRaw` form with `experimentalDecorators: true,\nemitDecoratorMetadata: true`.\n\nFor independent CI invocations, both `smrtPlugin()` and `smrtConsumer()` accept\nthe same `generationSnapshot: { path, sha256, provenance, sourceRoot }`. The\nschema-v1 snapshot produced by `serializeSmrtGenerationSnapshot()` contains the\nmerged project/dependency manifest, portable source paths, and source-file\ndigests; each plugin selects its own view. Reuse mode fails closed on\nbyte/provenance/path/content drift, skips scans and manifest writes, and still\ngenerates routes, types, registration, and virtual modules. Omit it for normal\nlocal development and watch mode.\n\n## Schema paths (#2382)\n\n`ensureSystemTables(db, typeHint?)` is the public, idempotent provisioning\nboundary for framework-owned `_smrt_*` tables. Call it on a base PostgreSQL\nconnection before opening a caller-owned transaction; its advisory-locked\nbootstrap prevents missing-table probes from poisoning that transaction.\n\nProduction DDL comes from the **manifest** paths\n(`generateSTISchemaFromManifest`/`generateCTISchemaFromManifest`, selected in\n`src/scanner/manifest-generator.ts` → registered `schema` → `db:migrate`). The\n**registry** paths feed `getTestDatabase()`. Manifest and registry schemas must\nagree on same-package foreign keys as well as columns and indexes:\n`@foreignKey` emits a named physical constraint, while `@crossPackageRef` and\n`@tenantId` remain indexed runtime relationships without physical constraints.\nNatural-key references default to `CASCADE`; ordinary references default to\nimmediate `NO ACTION`, matching `SmrtObject.delete()`.\n\nSame-package archival/audit identifiers that intentionally outlive their\nparent may use `@foreignKey(Target, { constraint: false })`. This explicit\nexception retains relationship loading, indexing, and application-side delete\nmetadata while omitting the physical constraint, schema dependency, and\napp-side cascade/preflight action so the stored identifier survives deletion;\ndocument the retention reason at the field, and keep ordinary same-package\nrelationships constrained.\n\nWhen a relationship is valid on every engine but a particular database cannot\nfaithfully enforce its physical shape, use the public, explicit allowlist\n`@foreignKey(Target, { constraint: { engines: ['postgres', 'sqlite'] } })`.\nOnly physical DDL and schema dependency planning are engine-scoped; native UUID\nstorage, relationship loading, indexes, and application-side delete enforcement\nremain active on every engine. Empty or unknown allowlists fail closed. Do not\nuse this option to hide an otherwise invalid schema.\n\n- Change column/index emission on every shipping path, proven by the path-parity\n test `src/schema/schema-path-parity.test.ts` (#2359; index rules in the module doc). A \"same as migrations\" comment is a claim to check.\n- Every new query predicate ships with its index, or a reason it doesn't.\n- Creation is dependency-planned on every entry point. PostgreSQL defers mutual\n cycle constraints until both tables exist; SQLite keeps cycles inline;\n DuckDB refuses unsupported cycles/actions unless the field has an explicit\n physical-constraint engine allowlist rather than silently omitting them.\n In particular, generated same-package constraints retain the compatibility\n default `ON UPDATE CASCADE`; DuckDB/JSON cannot enforce that action and must\n return an actionable refusal instead of stripping the clause.\n PostgreSQL deferred adds are idempotent and probe the exact child/parent\n columns for orphans before `NOT VALID` + validation. Rollback drops children\n before parents, removes deferred PostgreSQL cycle constraints first, and\n defers SQLite checks while dropping populated cycles. Schema aggregation that\n deliberately filters a parent also removes the retained child's physical FK.\n- Numeric types, uuid casts, conflict targets, timestamps, migrations: run the\n `test:postgres` lane — SQLite affinity accepts what PostgreSQL rejects.\n- Read `dist/manifest.json`/regenerated schemas for what a decorator produced;\n count across all packages instead of sampling.\n- Tenant scoping is whole-path: every unique constraint and conflict target on a\n tenant-scoped table carries the tenant column, and every read path — not only\n `list()` — is interceptor-aware.\n- Rolling indexes out is part of the change: a bulk `CREATE INDEX` batch needs\n the bounded, concurrent migrate path (#2362, Gotchas), or it takes production\n down on deploy.\n\n## Gotchas\n\n- **Filesystem support is a lazy boundary (#1979)**: `SmrtClass` acquires `options.fs` adapters via `createFilesystemAdapter()` (`src/filesystem-loader.ts`), never a static `@happyvertical/files` import — the files SDK statically pulls @aws-sdk/client-s3 and reaches googleapis, and a static edge here would land it in every downstream SSR bundle. Node/tsx/vite-dev runtimes resolve it on first use; fully-bundled deployments import `@happyvertical/smrt-core/filesystem` at startup. Use `importOptionalDependency()` (`src/lazy-external.ts`) for any similar optional heavyweight dependency.\n- **Transaction-bound instances**: `SmrtClass.withDatabase(db, callback)` temporarily binds an initialized instance (including its public `options.db`) to a caller-owned transaction database and restores only the database binding. Transaction owners persisting one object should use `SmrtObject.withTransaction(callback)`, which restores identity/revision metadata after rollback and serializes its embedded callback with ordinary writes. Bound saves re-enter that hold. Never reach into `_db`, and do not use the same instance concurrently during either callback.\n- **Never override toJSON()** — handles STI discriminator + meta field extraction. Use `transformJSON()`\n- **Property init order**: TypeScript initializers run first, then `initialize()` applies option values (options win)\n- **No runtime schema creation**: application tables must be prepared explicitly via migrations/tooling; runtime verification is `tableExists()` only (`src/schema/table-verifier.ts`) — no column, type, or index check\n- **PostgreSQL migrate batches are always time-bounded (#2362)**: `MigrationTracker.applyAll({ atomic: true })` emits `SET LOCAL lock_timeout`/`statement_timeout` before any DDL, so a batch blocked on one table cannot hold its earlier locks indefinitely. `postgresSafe: true` adds concurrent-index mode — non-index DDL commits atomically, then index DDL runs `CONCURRENTLY` on a session pinned via `db.acquireSession()` (a pooled `db.query` would not keep the `SET` and the DDL on one connection). That mode is deliberately **not atomic**: unfinished index migrations are recorded `failed`, not `running`, and their `error_message` carries a `[smrt: concurrent-index phase 1 committed]` marker so a reconciling re-run resumes at the index build instead of replaying committed DDL. INVALID indexes are found via `pg_index.indisvalid` (`pg_indexes` reports them as present) and dropped before rebuild. Operational detail: `packages/cli/AGENTS.md`.\n- **Retry logic is transient-only (#2366)**: `db.get()`/`db.upsert()` retry 4× total (initial + 3), but `ErrorUtils.withRetry` classifies via the cause chain (`src/db-errors.ts`) and rethrows deterministic failures immediately — constraint violations, bad input syntax, missing tables, aborted PG tx (`25P02`). `@happyvertical/sql` stringifies the driver text into `context.originalError`, so **never match `error.message`**; use `classifyDatabaseError()` / `isUniqueViolationError()` / `isAbortedTransactionError()`.\n- **Field caching**: `_cachedFields` populated during `Collection.create()` — eliminates async `getFields()` per query\n- **Smart cloning**: arrays/objects shallow-cloned in property init to prevent aliasing (Issue #22)\n- **Table verification cache**: `isTableVerified(dbUrl, tableName)` avoids redundant `tableExists()` calls\n- **Manifest required**: build-time AST scanning creates manifest. Without vitest plugin → \"No field metadata\"\n- **ManifestBuilder fails on scanner errors**: every production manifest path\n must abort before adapting partial scan results. A syntax error or unresolved\n `@smrt()` config spread cannot be allowed to emit a default-open manifest.\n- **Vite plugin loads scanner from `dist/` first**: `src/vite-plugin/import-build-aware.ts` prefers `dist/` when it exists on disk; it only falls back to `src/` on fresh clones. So if you edit `src/scanner/*.ts` or `src/schema/generator.ts` and want those edits reflected in consumer manifest generation, you must rebuild (`pnpm build` or have `pnpm dev` / `pnpm build:watch` running in core). This is intentional — sniffing `.ts` vs `.js` via `import.meta.url` was non-deterministic under tsx and broke 12–13 publishes (#1139).\n- **Bundled registry ownership**: flattened production bundles can rewrite constructor names and make decorator-time stack inference attribute provider code to the consumer. Generated registration repairs identity only from the exact imported constructor plus an explicit package and isolated one-object manifest; never infer ownership from output paths, simple names, or table names. Distinct packages may export the same simple name under qualified keys. The production-consumer gate lives in `packages/bundle-gate/src/__tests__/registry-identity.spec.ts` (#2308).\n",
979
983
  "moduleDocs": [
980
984
  {
981
985
  "path": "agents/change-feed.md",
@@ -990,7 +994,7 @@
990
994
  {
991
995
  "path": "agents/generators.md",
992
996
  "module": "generators",
993
- "content": "# smrt-core/code generators\n\nModule semantics for `src/generators/` + `src/vite-plugin/`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| CLI | `src/generators/cli.ts` | `objectname:action` admin commands — writable allowlist, exhaustive-include, `--from-file`, fail-closed tenant context |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nThe same web virtual module exports `webMcpToolDefinitions` (#2518), a\ncanonical per-tool array selected independently of list materialization. Every\nnon-empty canonical API action set contributes tools, so get-only and\ncustom-action-only models are discoverable; custom actions declared on a\n`SmrtCollection` merge into the owning row collection. Each definition carries\ncomplete route and invalidation metadata. `collectionDefinitions` and its\nembedded descriptor copy remain unchanged for existing cache-backed consumers.\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Four co-managed emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), the physical `@smrt/web` d.ts (`prebuild/index.ts`), and the hand-written type mirror in `@happyvertical/smrt-web` (`packages/smrt-web/src/index.ts` — dependency-free, so textual sync only).\n\n`webMcpToolDefinitions` is deliberately outside that digest: tool-only route,\nidentifier, or annotation changes cannot alter persisted row hydration.\n\nPer-field web emission (#2046): `buildWebFieldDefinitions` carries `description` (from `@field({ description })`) and sanitized `ui` hints (from `@field({ ui: { basic, group, order, locked } })`, read off the manifest `_meta.ui` bag through per-key type guards) into each emitted field definition, and `buildWebToolDescriptors` threads the same `description` into browser MCP tool schemas. `sensitive`/`transient` fields are excluded from emission entirely, so their descriptions never ship. Both keys are conditional, so hint-less schemas emit byte-identical definitions (and hashes) as before; adding a description/ui hint changes the manifest hash — deliberate over-invalidation, harmless per the #1764 contract.\n\n## Generated MCP server output language\n\n`MCPGenerator` builds every file as TypeScript, so the requested `outputPath`\nextension decides what is written (#2279). `.ts`/`.mts` targets keep the source\nverbatim for `tsx` or Node type stripping — which is why the generated source\nmust stay erasable-syntax-only (no parameter properties, enums, or namespaces).\nEvery other target (`.smrt/mcp-server/index.js` by default) is transpiled to\nJavaScript with lazily loaded `oxc-transform` before writing, because the\nprinted run script and the generated `claude-config.example.json` both invoke\nit with plain `node`. Ordinary core imports and `.ts`/`.mts` output therefore\ndo not load OXC's native bindings.\nThis keeps `typescript` dev-only in `@happyvertical/smrt-core`; generated MCP\nsource must remain erasable-syntax-only. A `.cjs`/`.cts` target is rejected\noutright: generated servers are ES modules. `src/generators/mcp-emit.ts` owns\nthose decisions — do not reintroduce a bare `writeFile` of generated source.\n\nModular output writes `config`, `tools/index`, and `handlers/index` with the\nentry point's own extension, and emits the entry's relative import specifiers\nwith that same extension, so the files it imports both exist and load with the\nsame module semantics — an `.mjs` entry gets `.mjs` siblings, not `.js` ones a\nCommonJS package would then parse as CommonJS. The entry is written at the\nrequested path rather than a hardcoded `index.js`.\nGenerated code also has to be valid in an ES module: `arguments` is not a legal\nbinding name there, however convenient it reads.\n\n## Emitted agent surface (#2591)\n\nGenerated model tools have always been build-time artifacts — virtual module,\nmanifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed\nonly once something mounted, so \"what can an agent do in this app\" had no answer\nshort of enumerating every route. This closes that.\n\nThe same OXC scan that builds the manifest also runs the scanner's\nagent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it\nin `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to\n`buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration\ndiscovery is NOT bound to the plugin's `include` glob — an app that scans\n`src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`\nsidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth\nholding onto:\n\n- **It never touches `manifest.json`.** The runtime manifest stays\n runtime-focused; the agent-addressable surface is an agent/developer contract,\n so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`\n only, under `agentSurface: { intents, playbooks, diagnostics }`.\n- **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a\n native parser binary and `smrt-core`'s main entry is browser-reachable, so\n core's sync knowledge builder must not import it. The Vite plugin already\n imports the scanner lazily on the Node side and is the only caller that writes\n this artifact.\n\nThe field is **omitted entirely** when a package declares nothing, which is what\nmakes it additive in practice rather than only on paper: every existing\npackage's checked-in artifact stays byte-identical.\n\nEach declaring module gets a `sourceHashes` entry under the\n`agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so\nEDITING an intent sidecar marks the artifact stale exactly like editing\n`AGENTS.md` does (`stale-domain-knowledge`).\n\nHashes alone cannot see an **added** declaration, though: a brand-new sidecar\nhas no recorded hash to mismatch, the runtime manifest never carries intents,\nand `AGENTS.md` is untouched — so every other signal stays green while the\nartifact omits a real operation. `dev:knowledge-check` therefore also re-derives\nthe declaration SET from source and compares it to the artifact by identity,\nreporting either direction as `stale-agent-surface`. The scan is bounded like\nthe numeric-precision lint: `src` only, behind the scanner's token pre-filter.\n\nThat re-derivation must model what the EMITTER sees, not merely what is on\ndisk, or it reports drift no rebuild can clear. Which files count is decided by\nthe scanner's exported `isAgentSurfaceSourcePath` — the same predicate the\nemitter itself uses, never a list copied into the checker — and the per-file\nresults run through `mergeAgentSurfaces` before comparing, because the merge is\nwhere a duplicate identity and a derived tool-name collision are resolved and\nthe artifact is the merged result.\nDiagnostics are compared alongside identities: a sidecar containing only a\ncomputed declaration adds no identity and has no prior hash, so without that,\n\"a diagnostic, never silence\" would quietly become \"a diagnostic, until the\nartifact goes stale\". The walk covers `<pkg>/src` while the emitter globs the\nwhole project root, so an emitted entry from outside `src` is not reported as\nmissing — this check did not look there, and claiming otherwise would be an\nerror nothing could clear.\n\nBoth `stale-*` codes are warnings by default and errors under `--strict`, which\nis what CI runs. Alongside them: `agent-surface-missing-identity`,\n`agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are\nerrors, and `agent-surface-not-static` is a warning. A cross-file duplicate\narrives as a *diagnostic* rather than two entries — the scanner's merge already\ndropped the loser — so that diagnostic maps to the duplicate error rather than\nthe not-static warning; otherwise the error would be unreachable for the case it\nexists to catch.\n\n`smrt doctor` prints the whole surface — model tools, intents, playbooks — from\nthese artifacts alone, with no application running.\n\n## Custom-action contract\n\n`resolveCustomActionMetadata()` is the common discovery and invocation contract\nfor generated REST routes and API clients, MCP, CLI, WebMCP, and simple\nREST-resource discovery. Receiver scope comes from the executable method, never a\nconfiguration-only `api.routes[name].scope` override: instance model methods\nare item-scoped and require `id`; static model methods and recognized\n`SmrtCollection` methods are collection-scoped and do not accept `id`. Route\nconfiguration may still choose its path and HTTP verb, but it cannot turn an\ninstance call into `ClassRef.action` or vice versa.\n\nWhen scanner method metadata exists, discovery projects each named parameter\nand its JSON-schema type, and invokers pass the values positionally in declared\norder. The legacy single `options` bag remains compatible when metadata is\nabsent (or the declared method takes `options`). Do not infer this from runtime\nfunction arity. An omitted typed `options` parameter remains `undefined`, so a\nmethod's JavaScript default initializer continues to apply; an explicit `null`\nremains `null`. Flat tool and CLI inputs reserve `id` for receiver parsing. If\nan action declares an `id` parameter, its flat MCP/WebMCP field is `actionId`\n(and CLI uses `--action-id`); REST keeps its independent path/body\nnamespaces. Typed CLI actions may use standard flag names such as `limit`,\n`offset`, `where`, and `format` without those values being stripped as CRUD\nflags.\n\nCustom actions may return an explicit, domain-neutral failure object with\n`ok: false`, `code`, and `message` plus optional `status`, `details`,\n`retryable`, and `correlationId`. `normalizeCustomActionFailure()` redacts it;\ngenerated REST returns `{ error: failure }` with the non-2xx status, while MCP\nreturns `isError: true` and `_meta['io.happyvertical/smrt']`. Opaque successful\nobjects (including `{ code, message }`) remain untouched; thrown exceptions are\nnot reclassified as domain failures.\n\nCustom route metadata also classifies browser-tool effects. Set `effect` to\n`read`, `write`, or `destructive`, with truthful `idempotent` and `openWorld`\nflags. CRUD classification is fixed: list/get are read, create/update are write,\nand delete is destructive. An undeclared custom action deliberately defaults to\ndestructive, non-idempotent, and open-world so a browser capability policy never\nfails open.\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n"
997
+ "content": "# smrt-core/code generators\n\nModule semantics for `src/generators/` + `src/vite-plugin/`. Package orientation, the cross-module\ninvariants, and the traps that apply before editing anything live in\n[../AGENTS.md](../AGENTS.md) — read that first.\n\n## Code Generators\n\n| Generator | Location | Output |\n|-----------|----------|--------|\n| REST API | `src/generators/rest.ts` | OpenAPI-compliant CRUD endpoints |\n| CLI | `src/generators/cli.ts` | `objectname:action` admin commands — writable allowlist, exhaustive-include, `--from-file`, fail-closed tenant context |\n| MCP Server | `src/generators/mcp.ts` | Model Context Protocol tools |\n| Web collections | `src/vite-plugin/web-collections.ts` (selectors) + `generateWebModule` | `@happyvertical/smrt-virt-web` — one typed collection definition per API-exposed REST collection (#1761), consumed by `@happyvertical/smrt-web` |\n\nThe same web virtual module exports `webMcpToolDefinitions` (#2518), a\ncanonical per-tool array selected independently of list materialization. Every\nnon-empty canonical API action set contributes tools, so get-only and\ncustom-action-only models are discoverable; custom actions declared on a\n`SmrtCollection` merge into the owning row collection. Each definition carries\ncomplete route and invalidation metadata. `collectionDefinitions` and its\nembedded descriptor copy remain unchanged for existing cache-backed consumers.\n\nGenerated API clients share `selectApiClientEntries()` across the runtime Vite\nmodule, its ambient declaration, and physical prebuild declarations. When a\ncollection class and its populated model share an endpoint, the model owns the\ncanonical collection key and row payload schema; the collection class remains\navailable under a deterministic class-derived secondary key. Selection and\ncollision suffixes must not depend on manifest insertion order (#2027).\nFor aggregated manifests, inheritance and item-type references resolve exact\nqualified names first, then package-local simple names, then a stable identity\nfallback so duplicate class names across packages cannot reintroduce ordering.\n\nThe web module also emits a build-time **`manifestHash`** constant (#1764): `computeWebManifestHash(manifest)` is a deterministic, replica-stable digest of the emitted web-collection SHAPE (name/className/endpoint/idField/actions/fields/relationships), canonicalized (recursive key sort) before `sha256 → base64url`, truncated to 16 chars — so the same schema always hashes the same, and a field add/remove/type-change/edge-change changes it. A change means old persisted client rows may mis-hydrate, so smrt-web keys its durable persistence namespace on it and its `updateAvailable` contract signal compares against it. Four co-managed emission sites must not drift: the runtime value (`generateWebModule`), the `@happyvertical/smrt-virt-web` ambient d.ts (`vite-plugin/index.ts`), the physical `@smrt/web` d.ts (`prebuild/index.ts`), and the hand-written type mirror in `@happyvertical/smrt-web` (`packages/smrt-web/src/index.ts` — dependency-free, so textual sync only).\n\n`webMcpToolDefinitions` is deliberately outside that digest: tool-only route,\nidentifier, or annotation changes cannot alter persisted row hydration.\n\nPer-field web emission (#2046): `buildWebFieldDefinitions` carries `description` (from `@field({ description })`) and sanitized `ui` hints (from `@field({ ui: { basic, group, order, locked } })`, read off the manifest `_meta.ui` bag through per-key type guards) into each emitted field definition, and `buildWebToolDescriptors` threads the same `description` into browser MCP tool schemas. `sensitive`/`transient` fields are excluded from emission entirely, so their descriptions never ship. Both keys are conditional, so hint-less schemas emit byte-identical definitions (and hashes) as before; adding a description/ui hint changes the manifest hash — deliberate over-invalidation, harmless per the #1764 contract.\n\n## Generated MCP server output language\n\n`MCPGenerator` builds every file as TypeScript, so the requested `outputPath`\nextension decides what is written (#2279). `.ts`/`.mts` targets keep the source\nverbatim for `tsx` or Node type stripping — which is why the generated source\nmust stay erasable-syntax-only (no parameter properties, enums, or namespaces).\nEvery other target (`.smrt/mcp-server/index.js` by default) is transpiled to\nJavaScript with lazily loaded `oxc-transform` before writing, because the\nprinted run script and the generated `claude-config.example.json` both invoke\nit with plain `node`. Ordinary core imports and `.ts`/`.mts` output therefore\ndo not load OXC's native bindings.\nThis keeps `typescript` dev-only in `@happyvertical/smrt-core`; generated MCP\nsource must remain erasable-syntax-only. A `.cjs`/`.cts` target is rejected\noutright: generated servers are ES modules. `src/generators/mcp-emit.ts` owns\nthose decisions — do not reintroduce a bare `writeFile` of generated source.\n\nModular output writes `config`, `tools/index`, and `handlers/index` with the\nentry point's own extension, and emits the entry's relative import specifiers\nwith that same extension, so the files it imports both exist and load with the\nsame module semantics — an `.mjs` entry gets `.mjs` siblings, not `.js` ones a\nCommonJS package would then parse as CommonJS. The entry is written at the\nrequested path rather than a hardcoded `index.js`.\nGenerated code also has to be valid in an ES module: `arguments` is not a legal\nbinding name there, however convenient it reads.\n\n## Browser-plane playbook preflight route (#2590)\n\n`GET {basePath}/_preflight?key=<playbook key>` (`src/generators/preflight-route.ts`)\nis an advisory, read-effect, idempotent report of what a caller's playbook would\nbe allowed to do — capability *selection*, never authorization. Resolution and\nverdict shaping live in `@happyvertical/smrt-playbooks`, which depends on this\npackage, so core takes the evaluator as the `APIConfig.playbookPreflight` seam and\nthe dependency stays one-way. Without a provider the route 404s.\n\n**`authMiddleware` is never invoked by preflight**, and that is enforced\nstructurally rather than by discipline: `PlaybookPreflightRouteOptions` has no\nauth member of any kind, and `rest.ts` passes the boolean `appAuthConfigured`\ninstead — so there is no handle in the module to invoke by mistake. A synthetic-\n`Request` dry run is explicitly not an option: the middleware is request-bound,\nreturns a `Response` rather than a boolean, and may consult session stores,\nrate-limit, or audit. The app-auth layer therefore reports `unknown`, which is the\nhonest answer, and a future `authPredicate` seam can fill it in without changing\nthe contract.\n\nThe static layers preflight predicts against are exported from the same module —\n`isApiActionEnabledForObject`, `isRestActionRoutable`, `isRestRoutePublic`,\n`restFieldReadPermissions`, `restMethodForApiAction`,\n`resolveRegisteredObjectName` — and `APIGenerator`'s own\n`isApiActionEnabled` / `isRoutePublic` now delegate to them, so the route and the\nprediction of the route cannot drift. Exposure and existence are separate\nquestions: `include`/`exclude` gate a route, they do not conjure one, so\n`isRestActionRoutable` additionally requires a custom action to be declared in\n`api.routes` — the only map `dispatchCustomCollectionAction` iterates. A custom\naction is predicted against the verb its own route config declares, so a\n`public: 'read'` opt-out neither silently covers a `POST` action nor falsely\ndenies a declared `GET` one. Every unresolvable key returns the provider's single uniform\n\"unavailable\" body with an unconditional 200: unknown and unauthorized keys are\nindistinguishable at the HTTP layer too.\n\n## Emitted agent surface (#2591)\n\nGenerated model tools have always been build-time artifacts — virtual module,\nmanifest, knowledge graph. View intents (#2588) and playbooks (#2589) existed\nonly once something mounted, so \"what can an agent do in this app\" had no answer\nshort of enumerating every route. This closes that.\n\nThe same OXC scan that builds the manifest also runs the scanner's\nagent-surface matcher (`ScanResults.agentSurface`). `smrtPlugin()` captures it\nin `scanWithOxc`, projects it with `toKnowledgeAgentSurface`, and passes it to\n`buildDomainKnowledgeManifest` as `agentSurface`. Note that declaration\ndiscovery is NOT bound to the plugin's `include` glob — an app that scans\n`src/lib/objects/**` for models still has its `src/lib/agent/*.intents.ts`\nsidecars found (see `packages/scanner/AGENTS.md`). Two more consequences worth\nholding onto:\n\n- **It never touches `manifest.json`.** The runtime manifest stays\n runtime-focused; the agent-addressable surface is an agent/developer contract,\n so it lands in `.smrt/smrt-knowledge.json` and `dist/smrt-knowledge.json`\n only, under `agentSurface: { intents, playbooks, diagnostics }`.\n- **It is passed in, not scanned in `knowledge.ts`.** The scanner carries a\n native parser binary and `smrt-core`'s main entry is browser-reachable, so\n core's sync knowledge builder must not import it. The Vite plugin already\n imports the scanner lazily on the Node side and is the only caller that writes\n this artifact.\n\nThe field is **omitted entirely** when a package declares nothing, which is what\nmakes it additive in practice rather than only on paper: every existing\npackage's checked-in artifact stays byte-identical.\n\nEach declaring module gets a `sourceHashes` entry under the\n`agentSurface:<package-relative path>` prefix (`AGENT_SURFACE_HASH_PREFIX`), so\nEDITING an intent sidecar marks the artifact stale exactly like editing\n`AGENTS.md` does (`stale-domain-knowledge`).\n\nHashes alone cannot see an **added** declaration, though: a brand-new sidecar\nhas no recorded hash to mismatch, the runtime manifest never carries intents,\nand `AGENTS.md` is untouched — so every other signal stays green while the\nartifact omits a real operation. `dev:knowledge-check` therefore also re-derives\nthe declaration SET from source and compares it to the artifact by identity,\nreporting either direction as `stale-agent-surface`. The scan is bounded like\nthe numeric-precision lint: `src` only, behind the scanner's token pre-filter.\n\nThat re-derivation must model what the EMITTER sees, not merely what is on\ndisk, or it reports drift no rebuild can clear. Which files count is decided by\nthe scanner's exported `isAgentSurfaceSourcePath` — the same predicate the\nemitter itself uses, never a list copied into the checker — and the per-file\nresults run through `mergeAgentSurfaces` before comparing, because the merge is\nwhere a duplicate identity and a derived tool-name collision are resolved and\nthe artifact is the merged result.\nDiagnostics are compared alongside identities: a sidecar containing only a\ncomputed declaration adds no identity and has no prior hash, so without that,\n\"a diagnostic, never silence\" would quietly become \"a diagnostic, until the\nartifact goes stale\". The walk covers `<pkg>/src` while the emitter globs the\nwhole project root, so an emitted entry from outside `src` is not reported as\nmissing — this check did not look there, and claiming otherwise would be an\nerror nothing could clear.\n\nBoth `stale-*` codes are warnings by default and errors under `--strict`, which\nis what CI runs. Alongside them: `agent-surface-missing-identity`,\n`agent-surface-duplicate-identity`, and `agent-surface-empty-playbook` are\nerrors, and `agent-surface-not-static` is a warning. A cross-file duplicate\narrives as a *diagnostic* rather than two entries — the scanner's merge already\ndropped the loser — so that diagnostic maps to the duplicate error rather than\nthe not-static warning; otherwise the error would be unreachable for the case it\nexists to catch.\n\n`smrt doctor` prints the whole surface — model tools, intents, playbooks — from\nthese artifacts alone, with no application running.\n\n## Custom-action contract\n\n`resolveCustomActionMetadata()` is the common discovery and invocation contract\nfor generated REST routes and API clients, MCP, CLI, WebMCP, and simple\nREST-resource discovery. Receiver scope comes from the executable method, never a\nconfiguration-only `api.routes[name].scope` override: instance model methods\nare item-scoped and require `id`; static model methods and recognized\n`SmrtCollection` methods are collection-scoped and do not accept `id`. Route\nconfiguration may still choose its path and HTTP verb, but it cannot turn an\ninstance call into `ClassRef.action` or vice versa.\n\nWhen scanner method metadata exists, discovery projects each named parameter\nand its JSON-schema type, and invokers pass the values positionally in declared\norder. The legacy single `options` bag remains compatible when metadata is\nabsent (or the declared method takes `options`). Do not infer this from runtime\nfunction arity. An omitted typed `options` parameter remains `undefined`, so a\nmethod's JavaScript default initializer continues to apply; an explicit `null`\nremains `null`. Flat tool and CLI inputs reserve `id` for receiver parsing. If\nan action declares an `id` parameter, its flat MCP/WebMCP field is `actionId`\n(and CLI uses `--action-id`); REST keeps its independent path/body\nnamespaces. Typed CLI actions may use standard flag names such as `limit`,\n`offset`, `where`, and `format` without those values being stripped as CRUD\nflags.\n\nCustom actions may return an explicit, domain-neutral failure object with\n`ok: false`, `code`, and `message` plus optional `status`, `details`,\n`retryable`, and `correlationId`. `normalizeCustomActionFailure()` redacts it;\ngenerated REST returns `{ error: failure }` with the non-2xx status, while MCP\nreturns `isError: true` and `_meta['io.happyvertical/smrt']`. Opaque successful\nobjects (including `{ code, message }`) remain untouched; thrown exceptions are\nnot reclassified as domain failures.\n\nCustom route metadata also classifies browser-tool effects. Set `effect` to\n`read`, `write`, or `destructive`, with truthful `idempotent` and `openWorld`\nflags. CRUD classification is fixed: list/get are read, create/update are write,\nand delete is destructive. An undeclared custom action deliberately defaults to\ndestructive, non-idempotent, and open-world so a browser capability policy never\nfails open.\n\nGenerated reads (`list`/`get`) on the REST and SvelteKit generators support conditional GET (helpers in `src/generators/conditional-get.ts`). ETag v2 (#1765): the validator is the table's change-feed version (`getTableVersion`) keyed by the request representation, so a **concrete** `If-None-Match` short-circuits into a 304 with an empty body **before** the collection query runs — an unchanged table revalidates with zero table scan. A wildcard `If-None-Match: *` is deferred until the payload builds (existence confirmed), so a missing item still returns 404, not a false 304. Tenant-scoped reads fold the active tenant into the representation (`resolveTenantEtagDiscriminator`) so one tenant's cached validator never satisfies another's read of the same URL. Routes whose GET renders via a **custom serializer** (which can load related tables the base-table version can't observe) keep the v1 body-hash ETag (`#1757`, query-first but correct); the default `toPublicJSON` path — all REST reads and non-serializer SvelteKit reads — uses v2. v2 is weakly consistent by design (the cost of not reading the data): a revalidation in the sub-statement window between a committed write and its feed append can return a stale 304 that self-heals on the next revalidation. The other v2 window — a deploy that changes the response shape WITHOUT a table write — is closed by the **#1764 ETag salt**: `computeTableVersionEtag(version, representation, manifestHash?)` folds the build's web-collection shape digest into the digest, so a shape-only redeploy busts every read validator (`undefined` reproduces the pre-#1764 unsalted value byte-for-byte for direct helper callers). The generated SvelteKit route bakes the digest in as a `MANIFEST_HASH` constant (via `generateConditionalGetRouteHelper`'s `manifestHash` option, sourced from `computeWebManifestHash(manifest)`) — automatic for the SvelteKit transport. The runtime `APIGenerator` auto-populates the same salt from the runtime registry with `computeRuntimeWebManifestHash()` when `APIConfig.manifestHash` is omitted; explicit `APIConfig.manifestHash` still wins for custom setups. The digest scope is get-OR-list (`selectWebEtagSaltEntries`), so **get-only** routes are salted too. Strong consistency still requires the v1 body-hash path. Cache-Control policy (unchanged from #1757): `private, no-cache` by default; public models may opt into shared caching via `@smrt({ api: { public: true | 'read', cache: { sMaxage } } })` → `public, max-age=0, s-maxage=<n>`; non-public models never emit shared-cache headers. Tenant-scoped models (any mode) never emit them either — bodies vary with session-cookie tenant context that URL-keyed shared caches cannot see; `sMaxage` is neutralized to `private, no-cache` with a one-time warning.\n"
994
998
  },
995
999
  {
996
1000
  "path": "agents/schema-paths.md",
@@ -1007,6 +1011,11 @@
1007
1011
  "module": "collection-reads",
1008
1012
  "content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Collection reads\n\nThis module covers bounded collection reads beyond the basic query contract in\n`packages/core/AGENTS.md`.\n\n## Projections and related rows\n\n`list({ select })` uses SMRT field names, maps them to database columns, and\nreturns plain rows without hydrating objects. It composes with `where`,\n`orderBy`, `limit`, and `offset`, runs normal `beforeList`/tenant interceptors,\nand is limited to column-backed fields; it cannot combine with `include`.\n\n## Bounded STI discriminator scopes\n\nAn STI child collection remains scoped to its own qualified `_meta_type` by\ndefault. A migration that must read registered sibling types may opt into an\nexplicit allowlist:\n\n```typescript\nawait impressionEvents.list({\n stiScope: {\n types: [\n '@anytown/advertising:AdImpression',\n '@anytown/advertising:LegacyAdImpression',\n ],\n },\n orderBy: 'created_at ASC',\n limit: 100,\n});\n```\n\n`stiScope.types` accepts 1–50 unique, qualified, registered types, all sharing\nthe child collection's STI root. Empty, simple-name, unknown, duplicate,\nunrelated, and non-child scopes fail at the collection boundary. The option is\nsupported by `list()`, `count()`, `counts()`, `facets()`, and\n`listWithLatestRelated()`. These methods retain their normal field validation,\nprojection or polymorphic hydration, pagination and cache-key construction;\nnormal read and tenant interceptors still run and are ANDed with the allowlist.\nPoint reads through `get()` remain child-only; use a bounded\n`list({ where, limit: 1, stiScope })` migration read when sibling hydration is\nrequired.\n\nFor one child per parent, use\n[`latest-related.md`](latest-related.md). It uses a portable ranked CTE,\ndeclared primary keys, adapter-specific offset-only syntax, explicit aliases,\nand hydrates only the visible parent page.\n\n## Facets, counts, and read plans\n\n`collection.facets({ fields, where })` runs one bounded `GROUP BY` per requested\nfield and returns `{ field, values: [{ value, count }] }`. It accepts at most 20\nfields, clamps value limits to 1,000 and the collection ceiling, never hydrates\nobjects, and applies the same read/tenant/sensitive-field rails as `select`.\nStored array/string-list values are grouped as stored; they are not unnested.\n`collection.counts({ where })` returns `{ total, filtered }` through two scoped\n`COUNT(*)` queries. Local coverage is SQLite/DuckDB; optional scalar PostgreSQL\ncoverage requires `SMRT_TEST_POSTGRES_URL`.\n\n`executeCollectionReadPlan()` bounds concurrent reads across independent\ncollections while preserving the normal registry and collection options. The\ncaller supplies a positive `maxConcurrency`; the executor does not compose SQL,\ncache, or alter pool defaults, and drains already-started work before returning\nthe first error.\n\n`where` operators must remain aligned with `@happyvertical/sql`'s `buildWhere`:\n`=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, and `like`. Arrays imply `IN`,\nand null values render `IS NULL`/`IS NOT NULL`. `contains` and dot-notation JSON\npaths are intentionally rejected until the SQL layer supports them.\n"
1009
1013
  },
1014
+ {
1015
+ "path": "agents/revision-guard.md",
1016
+ "module": "revision-guard",
1017
+ "content": "# Revision compare-and-swap guard (`src/revision-guard.ts`)\n\nEvery persisted `save()` pins its `UPDATE` to the revision the writer loaded;\n`claimRevision()` does the same without running domain hooks, and\n`delete({ expectedUpdatedAt })` binds the same predicate into its final\n`DELETE`. Zero affected rows raises `RUNTIME_REVISION_CONFLICT` rather than\noverwriting or removing a newer row.\n\n## Why the predicate is not an equality (#2620)\n\nThe guard used to compare `updated_at` to `loadedRevision.toISOString()`. On\nPostgreSQL a JavaScript `Date` is two lossy conversions away from the stored\nvalue, so that predicate matched no row at all in two common situations — and\nthe object then conflicted on *every* later save, permanently, rather than\nlosing a race:\n\n- **Precision.** `updated_at` is a microsecond column. Any row last written by\n raw SQL — `updated_at = CURRENT_TIMESTAMP` / `now()`, including SMRT's own\n migration backfills — carries a sub-millisecond tail a `Date` cannot hold.\n- **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping still\n hold `updated_at` as `timestamp WITHOUT time zone`, and `pg` hydrates that\n type in the process zone, so on a non-UTC host `toISOString()` renders a wall\n clock the row never held. The same columns are written under three different\n conventions — `pg` serializes a bound `Date` in the process zone,\n `claimRevision()` writes a UTC ISO string, and raw `CURRENT_TIMESTAMP` writes\n in the *server* zone — so no single rendering can match every row.\n\n## What the predicate does instead\n\n`postgresRevisionCondition()` builds\n`date_trunc('milliseconds', updated_at) IN (…)` over both wall-clock renderings\nof the revision, the process-zone one and the UTC one, each tagged `+00` so a\n`timestamptz` comparison honours it and a `timestamp` comparison discards it —\nthe predicate therefore does not depend on the *session* TimeZone either. On a\nUTC process the two renderings coincide and the condition is single-valued.\n\nLost-race semantics are preserved: a concurrent writer advances `updated_at` to\nroughly \"now\", so it must land on the loaded revision — or, on a non-UTC\nprocess only, on that revision shifted by the whole UTC offset — to the\nmillisecond before it could slip past. Collapsing that second rendering so the\npredicate is single-valued on every process is tracked as #2623.\n\n## Rules\n\n- Never rebuild this predicate by hand; call `postgresRevisionCondition()`.\n Every guarded write — `save()`, `claimRevision()`, and the guarded `DELETE` —\n goes through `SmrtObject.revisionPredicate()` so no path is left on the exact\n equality.\n- The condition is PostgreSQL-only. Embedded engines take the process-local\n compare/upsert fallback (`usesEmbeddedRevisionFallback`), and remote LibSQL\n stores ISO text whose exact equality round-trips losslessly.\n- Custom write paths must go through `save()`, `save({ expectedUpdatedAt })`,\n or `claimRevision()` rather than bypassing the CAS ordering contract.\n- The driver-layer half — `pg` hydrating and serializing `timestamp` columns in\n the process zone — is tracked as happyvertical/sdk#1223. The guard\n deliberately assumes neither hydration convention, so a UTC-hydration fix\n there cannot break it.\n\n## Coverage\n\n`src/__tests__/issue-2620-revision-guard-precision-postgres.optional.test.ts`\nruns the whole battery — guarded save, `save({ expectedUpdatedAt })`,\n`claimRevision()`, guarded delete, and their still-conflicts counterparts —\nagainst both `updated_at` column shapes in the registered PostgreSQL suite (`pnpm --filter @happyvertical/smrt-core\ntest:postgres`). `src/__tests__/revision-guard.test.ts` covers the rendering\nitself in the default suite.\n"
1018
+ },
1010
1019
  {
1011
1020
  "path": "agents/memory.md",
1012
1021
  "module": "memory",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
4
4
  "description": "Core AI agent framework with standardized collections, object-relational mapping, and code generators",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -164,9 +164,9 @@
164
164
  "pluralize": "^8.0.0",
165
165
  "tsx": "^4.23.0",
166
166
  "yaml": "^2.9.0",
167
- "@happyvertical/smrt-config": "0.44.0",
168
- "@happyvertical/smrt-types": "0.44.0",
169
- "@happyvertical/smrt-scanner": "0.44.0"
167
+ "@happyvertical/smrt-scanner": "0.44.1",
168
+ "@happyvertical/smrt-types": "0.44.1",
169
+ "@happyvertical/smrt-config": "0.44.1"
170
170
  },
171
171
  "peerDependencies": {
172
172
  "@huggingface/transformers": ">=3.0.0 <4.0.0",