@happyvertical/smrt-fields 0.40.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,179 @@
1
+ # smrt-fields
2
+
3
+ Layered field policy store and resolution engine (epic #2045). Personalizes
4
+ per-field `{defaultValue, visibility, help, label, order, locked}` for any
5
+ `@smrt()` object at app, tenant, and user scope over the code seed.
6
+
7
+ ## Core pieces
8
+
9
+ - `FieldPolicy` (`_smrt_field_policies` table) — sparse override rows keyed
10
+ `(objectRef, fieldName, scopeType, scopeKey)`; NULL column = inherit from
11
+ the lower layer, reset = row DELETE (later lower-layer changes flow through)
12
+ - `FieldPolicyCollection` — write surface plus the `resolveBatch` custom
13
+ collection-scoped action (`POST /<collection>/resolve`). NO generated
14
+ surface exposes read verbs: API `list`/`get` on this non-tenant-scoped
15
+ model would enumerate every tenant's and user's rows; the model's CLI is
16
+ writes-only (the generated CLI invokes over HTTP, and the cli↔api
17
+ coherence gate rejects CLI entries without API routes); the runtime
18
+ CLI/MCP surfaces are closed by the collection config (ContentContributions
19
+ precedent). Reads go through `resolveBatch` (context-scoped) or the
20
+ server-side resolver. Keep the api include lists in lockstep: a decorated
21
+ collection's config is the RUNTIME registry authority for its item class,
22
+ while build-time generation reads each manifest object's own config. Both
23
+ transports dispatch the action: generated SvelteKit routes natively, and
24
+ core's runtime `APIGenerator` via its decorator-route dispatch
25
+ (single-segment collection-scoped paths; multi-segment custom paths remain
26
+ SvelteKit-only).
27
+ - `resolveFieldPolicy(objectRef, { tenantId, userId, db })` — merged policy
28
+ - `resolveFieldPolicyExplained(...)` — merged policy plus ordered per-layer
29
+ contributions (code/app/tenant/user) for gear/admin UIs (#2049/#2050)
30
+
31
+ ## Resolution layers (priority low → high)
32
+
33
+ 1. Code seed — manifest field defaults, `description` as help, `_meta.ui`
34
+ hints (#2046: `basic`/`group`/`order`/`locked`; cold-start rule: no
35
+ `basic` markers ⇒ everything basic, any marker ⇒ unmarked fields advanced)
36
+ 2. App rows — `scopeType: 'app'`, `tenantId`/`userId` null
37
+ 3. Tenant rows — hierarchy walk root → leaf via an injected
38
+ `tenantHierarchyLoader` (smrt-features shape); the default loader
39
+ dynamic-imports `@happyvertical/smrt-users` (missing-package failures —
40
+ `ERR_MODULE_NOT_FOUND` / "Cannot find package" across the cause chain —
41
+ fall back to a flat single-tenant chain). A node that breaks permission
42
+ inheritance discards every earlier tenant contribution (chain-structural),
43
+ so only the suffix from the LAST break participates — in merging AND in
44
+ the explained layers, which therefore replay to the merged result
45
+ 4. User rows — keyed by `userId` alone (preferences follow the user); both
46
+ defaults AND visibility resolve through this tier
47
+
48
+ ## Invariants
49
+
50
+ - **Scope shape**: app ⇒ `tenantId`+`userId` null; tenant ⇒ only `tenantId`;
51
+ user ⇒ only `userId`. `scopeKey` (`userId ?? tenantId ?? '__app__'`) exists
52
+ ONLY to keep the `conflictColumns` unique index total — never read it for
53
+ scoping logic.
54
+ - **Manifest as definition registry**: writes validate against the live
55
+ `ObjectRegistry` (never checked-in manifest.json artifacts): unknown
56
+ objectRef/fieldName rejected; defaults type-checked against the field type;
57
+ system fields, relationship pseudo-fields, and STI meta storage fields are
58
+ not policy-addressable (the resolver excludes them, so rows would silently
59
+ never apply).
60
+ - **Security rail**: defaults are refused on `transient`, `sensitive`, and
61
+ `readPermission`-gated fields (both top-level and `_meta` flags checked).
62
+ Reference-field (`foreignKey`/`crossPackageRef`) defaults must be UUID
63
+ strings unless the field declares `idType: 'text'` — the columns are
64
+ native UUID on PostgreSQL/DuckDB and a non-UUID default would fail at
65
+ insert time. `resolveBatch` responses omit sensitive/read-permission-gated
66
+ fields for every caller (fail closed) and transient fields
67
+ (client-emission parity).
68
+ - **Required-field invariant**: demoting a required field to
69
+ advanced/hidden requires a usable resolved default (not null/empty) —
70
+ enforced at write time AND re-enforced at resolution (a required field with
71
+ no usable default always resolves `basic`, flagged `visibilityForced`).
72
+ - **Org lock**: `locked` may be set on app/tenant rows only; while the
73
+ code/app/tenant tiers resolve locked, user-scope writes are rejected and
74
+ existing user rows are skipped at resolution.
75
+ - **Write-time org checks reuse the resolver**: the required-demotion default
76
+ and the user-write lock are computed by `resolveFieldPolicy` over the org
77
+ tiers (default hierarchy loader), so cascading ancestor-tenant defaults and
78
+ locks are honored at save time too. On updates the resolver sees the row's
79
+ persisted version (no self-exclusion), so the resolver-side safety net
80
+ remains the authoritative enforcement at read time.
81
+ - **Isolation — a MISSING identity component DENIES, it never skips.** This
82
+ is the package rule; both the write guard
83
+ (`FieldPolicy.assertScopeOwnedByAmbientContext`) and the read guard
84
+ (`assertResolutionAllowedInContext`) obey it. A non-bypass tenant context
85
+ may only resolve/write its own tenant, and the user tier only for its own
86
+ user id — a context carrying permissions but NO `userId` (no `resolveUserId`
87
+ hook configured: API-key auth, service principals, background jobs, a bare
88
+ `withTenant({ tenantId })`) may not touch the user tier at all. Skipping
89
+ that check instead of denying it was a live ownership bypass: user rows are
90
+ `tenantId: null` by design, so nothing else contains such a write. App-scope
91
+ writes inside a tenant context require super-admin bypass. With NO ambient
92
+ identity at all (tenancy ALS never entered), only app-scope writes/deletes
93
+ are accepted; context-LESS *reads* stay allowed because
94
+ `resolveFieldPolicy` is a trusted server-side API. Residual: ALS-less
95
+ deployments can still write APP rows with any authenticated principal until
96
+ the #2049 permission layer adds `fields:policy:manage`. `save()`/`delete()`
97
+ on an existing row additionally authorize against the row's PERSISTED
98
+ scope, looked up by primary key AND — because a generated create always
99
+ mints a fresh UUID while the `conflictColumns` upsert still replaces the
100
+ occupant — by NATURAL key. `resolveBatch` takes identity exclusively from
101
+ the ambient context; the request body cannot select another tenant or user.
102
+ - **Scope attribution**: inside an ambient context the model DERIVES a
103
+ missing `tenantId` (tenant rows) or `userId` (user rows) from that context,
104
+ and always stamps `updatedBy` from it. Core's mass-assignment guard treats
105
+ `tenantId` as server-managed and strips it from every generated write body
106
+ while `FieldPolicy` is deliberately not `@TenantScoped`, so without this the
107
+ org tier is write-dead over REST/SvelteKit (scope-shape validation throws).
108
+ Deriving grants nothing — the ownership guard already pinned the value to
109
+ the ambient one. An explicit value is never overwritten, so a super-admin
110
+ bypass caller writing ANOTHER tenant's row must go through a server-side
111
+ model call; the generated routes still strip it.
112
+
113
+ ## Caching and invalidation
114
+
115
+ - Resolver results cached per
116
+ `(dbNamespace, objectRef, tenantId, userId, hierarchyLoader)` with a 30s TTL
117
+ (`cache.ts` mirrors smrt-prompts' `getDbNamespace`). The loader identity is
118
+ part of the key because an injected `tenantHierarchyLoader` yields a
119
+ different ancestor chain — and so different defaults/locks — for the same
120
+ `(db, objectRef, tenant, user)`; it goes LAST so the `(db, objectRef)`
121
+ prefix scan still invalidates every loader's entries.
122
+ - `FieldPolicy.save()`/`.delete()` invalidate ALL entries for the row's
123
+ `(db, objectRef)` — coarser than prompts because tenant hierarchy makes a
124
+ parent-tenant row affect every descendant's resolution.
125
+ - `_smrt_field_policies` rows do NOT ride the client change feed: core's
126
+ change-feed writer deliberately skips `_smrt_`-prefixed system tables, and
127
+ the emit side is private. Live client invalidation is a core-side decision;
128
+ do not add custom push/emit paths here.
129
+
130
+ ## Gotchas
131
+
132
+ - Defaults have TWO explicit constructor channels, never sniffed:
133
+ `defaultValue` is ALREADY JSON-encoded (the wire contract — generated write
134
+ routes hand the request body straight to the constructor, and #2049's gear
135
+ posts `JSON.stringify(draft.defaultValue)`), while `defaultValueRaw` is a
136
+ plain value that is always serialized, strings included. Passing both throws.
137
+ One option cannot carry both meanings: `'"TBD"'` and `'TBD'` are
138
+ indistinguishable, so `{ defaultValue: 'Net 30' }` is a parse error that
139
+ names `defaultValueRaw` in its message. `setDefaultValue()` is the
140
+ method-level plain channel.
141
+ - The sort-order column is `displayOrder` (resolved output exposes `order`):
142
+ a column literally named `order` is an SQL keyword the runtime INSERT path
143
+ does not quote.
144
+ - `FieldPolicy` deliberately has NO class-level `@TenantScoped` (the
145
+ prompts/features precedent): resolution legitimately reads app rows and
146
+ ancestor-tenant rows, which the tenancy interceptor would block. Isolation
147
+ is enforced at the resolver/save boundaries instead.
148
+ - Identity changes (objectRef/fieldName/scope) on a persisted row go through
149
+ delete-then-insert (transactional when the driver supports it) because the
150
+ natural-key upsert would otherwise collide with the primary key.
151
+ - `FieldPolicyCollection` MUST repeat FieldPolicy's `conflictColumns`. A
152
+ decorated collection emits its OWN manifest schema for the item's table, and
153
+ without the natural key that schema falls back to SmrtObject's default unique
154
+ `(slug, context)` index; manifest-driven migrations aggregate both onto
155
+ `_smrt_field_policies`, where the stray index rejects legitimate layered rows
156
+ (all policy rows have NULL slug/context). The runtime registry cannot catch
157
+ this — `getAllSchemas()` is keyed by TABLE name, so the two schemas collapse
158
+ into one entry. Pinned against the generated manifest in
159
+ `generated-surfaces.test.ts`.
160
+ - `withSystemContext()` does NOT unlock tenant/user-scope writes:
161
+ `getCurrentTenant()` returns undefined inside it, so the context-absent rule
162
+ rejects those tiers before any bypass check. Seeds and migrations that must
163
+ write org/user rows use a `superAdminBypass` context instead.
164
+ - The MODEL's `cli`/`mcp` decorator config is dead at runtime: the registry
165
+ re-registers the item slot with the COLLECTION's config wholesale, so
166
+ `FieldPolicyCollection`'s `cli: false, mcp: false` is what actually closes
167
+ those surfaces. Keep both in lockstep anyway — build-time generation reads
168
+ the model's own config. Related: `ObjectRegistry.getTableName(
169
+ 'FieldPolicyCollection')` resolves to the UNPREFIXED collection fallback
170
+ `field_policies`; persistence uses the item class
171
+ (`_smrt_field_policies`), and both are pinned in
172
+ `generated-surfaces.test.ts`.
173
+
174
+ ## Related
175
+
176
+ - `@happyvertical/smrt-prompts` / `smrt-languages` / `smrt-features` — the
177
+ same architecture family (override rows + layered resolver + TTL cache)
178
+ - Core `FieldUIHints` (#2046) — the `@field({ ui })` code seed this package
179
+ resolves over
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @happyvertical/smrt-fields
2
+
3
+ Layered field policy for SMRT objects: personalize per-field defaults,
4
+ visibility tiers (basic/advanced/hidden), help text, labels, ordering, and
5
+ org locks at app, tenant, and user scope — over the code-authored
6
+ `@field({ ui })` seed.
7
+
8
+ ```typescript
9
+ import {
10
+ FieldPolicy,
11
+ FieldPolicyCollection,
12
+ resolveFieldPolicy,
13
+ resolveFieldPolicyExplained,
14
+ } from '@happyvertical/smrt-fields';
15
+
16
+ // An org (tenant) demotes an optional field and sets a default
17
+ const policies = await FieldPolicyCollection.create({ db });
18
+ await policies.create({
19
+ objectRef: '@happyvertical/smrt-content:Article',
20
+ fieldName: 'summary',
21
+ scopeType: 'tenant',
22
+ tenantId,
23
+ visibility: 'advanced',
24
+ defaultValue: JSON.stringify('TBD'),
25
+ });
26
+
27
+ // Resolve the effective policy for a user in that tenant
28
+ const resolved = await resolveFieldPolicy(
29
+ '@happyvertical/smrt-content:Article',
30
+ { tenantId, userId, db },
31
+ );
32
+ resolved.fields.summary.visibility; // 'advanced'
33
+
34
+ // Explain variant: per-layer contributions for admin/gear UIs
35
+ const explained = await resolveFieldPolicyExplained(
36
+ '@happyvertical/smrt-content:Article',
37
+ { tenantId, userId, db },
38
+ );
39
+ ```
40
+
41
+ Resolution layers (low → high): code seed → app rows → tenant rows
42
+ (hierarchy walk via an optional `tenantHierarchyLoader`; flat fallback
43
+ without `@happyvertical/smrt-users`) → user rows. A NULL column inherits
44
+ from the lower layer; resetting a customization is a row delete.
45
+
46
+ Writes are validated against the live `ObjectRegistry`: unknown
47
+ objects/fields are rejected, defaults are type-checked, and defaults on
48
+ `transient`/`sensitive`/`readPermission`-gated fields are refused. Required
49
+ fields can only be demoted from `basic` when a usable default resolves —
50
+ and the resolver re-enforces that invariant at read time.
51
+
52
+ See `AGENTS.md` for the full architecture notes.