Skip to content

feat(fields): add FieldPolicy model, layered resolver, and batch resolve action - #2230

Merged
willgriffin merged 6 commits into
mainfrom
feat/issue-2047-smrt-fields
Aug 6, 2026
Merged

feat(fields): add FieldPolicy model, layered resolver, and batch resolve action#2230
willgriffin merged 6 commits into
mainfrom
feat/issue-2047-smrt-fields

Conversation

@willgriffin

@willgriffin willgriffin commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

First slice of the smrt-fields epic (#2045): the FieldPolicy model, the layered
app → tenant → user resolver over the #2046 code seed, and the resolveBatch
collection action that client bootstrapping (#2048) consumes.

  • FieldPolicy (_smrt_field_policies) — sparse override rows keyed
    (objectRef, fieldName, scopeType, scopeKey). A NULL column means "inherit
    from the lower layer", so resetting a customization is a row DELETE and later
    lower-layer changes flow through. Writes validate against the live
    ObjectRegistry: unknown objects/fields rejected, defaults type-checked,
    and the security rail (sensitive / readPermission / transient) refuses
    stored defaults outright.
  • resolveFieldPolicy / resolveFieldPolicyExplained — code seed → app
    rows → tenant chain (hierarchy walk root → leaf, permission-inheritance
    breaks discard earlier ancestors) → user rows, with a 30s TTL cache. The
    explained variant returns ordered per-layer contributions that replay to the
    merged result, so smrt-fields: ObjectForm generator + form-settings gear (org/user policy editor) #2049/smrt-fields: defaults control panel — SettingsCatalog roll-up + AdminShell wiring #2050 UIs never re-derive precedence.
  • Required-field invariant — demoting a required field to advanced/hidden
    needs a usable resolved default, enforced at write time AND re-enforced at
    resolution (a different row's later deletion can invalidate what held).
  • Closed read surfaces — no generated list/get anywhere: this model is
    deliberately not @TenantScoped, so a generated read would enumerate every
    tenant's and user's rows. Reads go through the context-scoped resolveBatch
    action or the server-side resolver.
  • Core's runtime APIGenerator gains decorator-route dispatch for
    single-segment collection-scoped custom actions, so POST /<collection>/resolve
    works on the runtime transport as it already did on generated SvelteKit routes.

Parent: #2045 (epic stays open).

Review fixes in this cycle

A read-only review of the slice returned needs-fixes with two P1s. Both are
fixed here, each with a regression test that fails without the fix (verified by
reverting the fix and re-running).

P1-1 — userless-context user-scope ownership bypass.
assertScopeOwnedByAmbientContext guarded the user tier with
context.userId !== undefined && scope.userId !== context.userId, so the check
VANISHED for any ambient context without a user id. Tenancy adapters produce
exactly that shape whenever no resolveUserId hook is configured (API-key auth,
service principals, background jobs, a bare withTenant({ tenantId })), and
user rows are tenantId: null by design, so nothing else contained the write:
such a caller could create, re-scope, or delete ANY user's rows in ANY tenant,
with the generated PUT echoing the row back as a read primitive. The package
rule is now a missing identity component DENIES, it never skips, applied on
the write side and mirrored on the read side in assertResolutionAllowedInContext
(the public resolveFieldPolicy export). Context-LESS reads stay allowed —
that is the trusted server-side path, and resolveBatch never lets a request
body select a user.

P1-2 — the tenant tier could not be created through any generated write surface.
Core's mass-assignment guard treats tenantId as server-managed and strips it
from every create/update body, while FieldPolicy is deliberately not
@TenantScoped so the tenancy interceptor never repopulates it. A
POST {scopeType:'tenant', tenantId} therefore always reached scope-shape
validation with tenantId === null and returned 500 — #2050's entire
org-admin tier was unreachable from REST/SvelteKit, and it went unnoticed
because the model-level tests call policies.create() directly.

Fixed model-side, not in core: save() now derives a missing tenant/user
owner from the ambient context. This grants nothing — the ownership guard
already pinned the value to the ambient one, so the derived value is the only
value that could ever have been accepted — and it keeps the model as the single
validation authority instead of adding an escape hatch to a guard every package
in the monorepo depends on. packages/core is unchanged by this fix.

Also in this cycle:

  • Server-side updatedBy stamping pulled forward from the smrt-fields: defaults control panel — SettingsCatalog roll-up + AdminShell wiring #2050 branch, so
    audit attribution is not forgeable through the open write routes before smrt-fields: usage capture + promotion suggestions (learning loop) #2051
    consumes it. A client-supplied value is overwritten inside any ambient
    context; context-less/system flows keep what they set.
  • The persisted-scope guard now falls back to a natural-key lookup. Every
    generated create mints a fresh UUID, so the primary-key lookup always missed
    while the conflictColumns upsert still replaced the occupant.
  • isRequiredField reads nullable from the top level as well as _meta,
    matching how required — and sensitive/readPermission/transient
    were already read from both. Defensive only: on the scanner-manifest path
    nullable always lands in _meta (verified), so this covers the
    runtime-decorator registration path used by consumer packages without a
    baked manifest. No behaviour change is observable in-repo, so it carries no
    dedicated test.
  • The resolver cache key includes tenant-hierarchy loader identity — an
    injected loader was being served the default loader's ancestor chain.
  • AGENTS.md records the deny-on-missing-identity rule, scope attribution, that
    withSystemContext() does not unlock org/user writes (seeds need
    superAdminBypass), and that the model's cli/mcp config is dead at
    runtime because the registry re-registers the item slot with the
    collection's config.

Five test groups from the review brief were added. The P1-2 regression drives
one row per scope tier through APIGenerator, never through policies.create()
— model-level tests are exactly what hid the defect.

Accepted design residuals

Recorded rather than fixed here, deliberately:

  • resolveBatch is authorized under the post verb, identical to create.
    An app cannot grant bootstrap-read without also granting policy writes. It is
    a property of how core authorizes collection-scoped custom actions, not of
    this package; changing it is a core-wide auth decision.
  • Custom-action validation errors surface as 500. resolveBatch's input
    validation and TenantIsolationError both become generic 500s because the
    resolveErrorHttpStatus opt-in rail does not exist at this landing point.
    smrt-fields: ObjectForm generator + form-settings gear (org/user policy editor) #2049 adds it; the 400/403 mapping belongs in that cycle, not this one.
  • Core has no opt-in for models that carry tenancy as explicit domain data.
    applyWritablePolicy's serverManaged set is unconditional and duplicated in
    sveltekit-generator.ts. Solving P1-2 model-side avoids touching either copy;
    if a second model ever needs this, the escape hatch belongs in core.
  • Bypass callers cannot write another tenant's row over REST. The transport
    still strips tenantId, and derivation only fills from the ambient context.
    Cross-tenant admin writes go through a server-side model call.

Independent review cycle

A codex review (gpt-5.6-sol, xhigh) of the full diff returned four findings,
all in the slice's pre-existing code rather than the fixes above. All four were
verified against the source and fixed in 123e14398:

  • [P1] Stray unique index on the policy table. A decorated collection emits
    its OWN manifest schema for the item's table, and FieldPolicyCollection did
    not repeat FieldPolicy's conflictColumns — so its schema fell back to
    SmrtObject's default unique (slug, context) index. Manifest-driven consumer
    migrations aggregate both onto _smrt_field_policies, where that index
    rejects legitimate layered rows: every policy row has a NULL slug and
    context, and the app/tenant/user rows for one field differ only by the real
    natural key. Confirmed in the generated manifest before the fix. The runtime
    registry cannot surface it — getAllSchemas() is keyed by TABLE name, so the
    two schemas collapse into one entry — so the new test asserts against the
    generated manifest, and fails without the fix.
  • [P1] Scope was read off the route alone. ApiCustomRouteConfig.scope is
    OPTIONAL and documented to default to collection for statics / item for
    instance methods, but the dispatcher required a literal scope: 'collection'.
    A valid static action that omitted it was skipped, so
    POST /<collection>/<action> fell through to CRUD and created an object
    wherever create is exposed. Scope now resolves through the shared
    resolveCustomActionMetadata, with the receiver deciding.
  • [P2] Arguments ignored declared parameters. Every action was invoked with
    one options object; buildCustomActionInvocationArgs now projects them as
    the SvelteKit/CLI/MCP transports do. Unchanged wherever parameter metadata is
    absent, which is currently every collection-hosted action.
  • [P2] Returned failures were served as 200. An action returning the shared
    { ok: false, code, message, status } convention had the raw object wrapped
    as a success; normalizeCustomActionFailure now applies first.

Also folded in: an ABSENT request body is no longer conflated with a malformed
one, so a zero-parameter action can be called with no body while a present-but-
unparseable body stays a 400. The dispatcher had no core-side coverage at all;
rest-custom-actions.spec.ts adds it.

Review threads

  • Copilot, types.ts:52defaultValue carried two meanings. The option
    was documented and implemented as "JSON-encoded string, or a plain value to
    serialize", so the natural call { defaultValue: 'Net 30' } stored
    unparseable text. The ambiguity is inherent — '"TBD"' and 'TBD' are
    indistinguishable — so the channel had to become explicit. Which channel
    keeps which meaning was settled by the callers, not by taste: every existing
    caller passes PRE-ENCODED values, decisively including smrt-fields: ObjectForm generator + form-settings gear (org/user policy editor) #2049/smrt-fields: defaults control panel — SettingsCatalog roll-up + AdminShell wiring #2050's gear,
    whose planFieldSettingsWrite posts JSON.stringify(draft.defaultValue)
    into a FieldPolicyRowPatch typed defaultValue: string | null straight
    through the generated write routes. Auto-serializing defaultValue would
    have silently double-encoded every gear write across the rest of the epic.
    So defaultValue keeps the encoded meaning (now typed string | null
    instead of unknown), and the plain value gets the new explicit
    defaultValueRaw — the option twin of the existing setDefaultValue().
    Supplying both throws; the parse failure now names the fix. Storage and every
    read site are unchanged and stay symmetric. Fixed in c1043544f.
  • Both codex P2s (typed custom-action arguments; failures served as 200) were
    already fixed by 123e14398.

Validation

  • @happyvertical/smrt-fields: 4 files, 55 tests passed (43 before this
    cycle, 12 added); tsc --noEmit green
  • @happyvertical/smrt-core FULL suite: 255 files passed / 5 skipped, 3,121
    tests passed
    (45 skipped); tsc --noEmit green
  • tsc --noEmit also green for smrt-tenancy, smrt-users, smrt-cli
  • turbo typecheck: 104/109 tasks successful. The 5 non-successes are all the
    documented nested-worktree Tsconfig not found .../packages/accounts artifact
    (a vite-8 oxc-transform / pnpm-symlink issue that does not reproduce in CI's
    isolated checkout — see the note in
    packages/products/scripts/check-web-engine-code-split.mjs), hit by build
    tasks in packages this branch does not touch, plus the typechecks blocked
    behind them.
  • biome check clean on every touched file
  • pnpm smrt dev:knowledge-check: fresh, 0 errors, 0 warnings
  • pnpm check:agents-chain: 65 chains under the 32,768-byte cap. This slice's
    chain (root + packages/fields) is 19,616 bytes — 13,152 headroom. The
    largest chain repo-wide is packages/users at 29,997 (2,771 headroom),
    untouched here.
  • Regression tests were verified by reverting each fix and re-running:
    the userless-context write guard (P1-1), both scope-tier REST tests (P1-2,
    which returned 500 with "Tenant-scope field policy rows must set tenantId"),
    the resolver read-side mirror, and the hierarchy-loader cache key all fail
    without their fix. The updatedBy test cannot pass without the stamping by
    construction (the body supplies a different id than the assertion expects).
    The natural-key authorization test is defence-in-depth and is noted as such
    in the test itself: the new-scope check also refuses that write today,
    because the scope key is derived from the caller's own owner id.

Rebased onto origin/main, dropping the duplicate #2046 commit already merged
as PR #2153; packages/core/agents/generators.md and
packages/core/src/vite-plugin/web-collections.test.ts are byte-identical to
main.

Closes #2047

{"schema":"hv-agent-run:v1","runtime":"claude","session":"53e2c43c-41e2-4f9f-a63a-996baed10afd","issue":"2047","policy_revision":"1.0.0","status":"complete","validation":["smrt-fields full suite (55 tests) and smrt-core full suite (3121 tests)","tsc --noEmit for core, fields, tenancy, users, cli","biome check on all touched files","pnpm smrt dev:knowledge-check fresh (0 errors, 0 warnings)","pnpm check:agents-chain under cap (fields chain 19616 bytes, 13152 headroom)","codex full-diff review: 4 findings, all verified and fixed","all PR review threads addressed and resolved"]}

…lve action (#2047)

New package @happyvertical/smrt-fields — the field-policy store and
resolution engine for epic #2045, following the prompts/features/languages
architecture family:

- FieldPolicy (_smrt_field_policies): sparse override rows keyed
  (objectRef, fieldName, scopeType, scopeKey) with nullable
  defaultValue/visibility/help/label/displayOrder/locked and an updatedBy
  audit ref. scopeKey (userId ?? tenantId ?? '__app__') exists only to keep
  the conflict-column unique index total (PromptOverride.context trick);
  tenantId/userId are typed UUID refs (@TenantID / @crossPackageRef).
- Write-time validation against the live ObjectRegistry: unknown
  objectRef/fieldName rejected, defaults type-checked, security rail
  (transient/sensitive/readPermission, top-level AND _meta) refuses stored
  defaults, scope-shape and org-lock enforcement, required-field invariant
  on visibility demotion, and a fail-closed tenant-context write boundary.
- resolveFieldPolicy / resolveFieldPolicyExplained: code seed (manifest
  defaults, description help, _meta.ui incl. cold-start basic rule) → app
  rows → tenant hierarchy walk (injected loader mirroring smrt-features;
  default loader dynamic-imports smrt-users, flat fallback without it) →
  user tier (defaults AND visibility). Resolver-side required-field safety
  net forces basic visibility when no usable default resolves; effective
  org lock skips the user layer. Per-(db, objectRef, tenant, user) TTL
  cache invalidated coarsely per objectRef on save/delete.
- FieldPolicyCollection.resolveBatch: custom collection-scoped POST action
  (path 'resolve', explicit api include; mcp/cli closed) resolving a set
  of objectRefs for the ambient tenant-context identity only, with
  sensitive/readPermission-gated/transient fields absent from responses.

The sort-order column is displayOrder (an unquoted `order` column is an
SQL keyword hazard in the runtime INSERT path); resolved output exposes
`order`. Note: _smrt_field_policies rows do NOT ride the client change
feed — core's change-feed writer skips _smrt_-prefixed tables and its emit
side is private, so live client invalidation remains a core-side decision.

New-package wiring: .changeset fixed group, root tsconfig references, root
AGENTS.md orientation, package AGENTS.md/CLAUDE.md, __smrt-register__
self-registration, vite/vitest configs with smrtVitestPlugin.

Closes #2047
…2047)

Address all 8 codex review findings on the initial smrt-fields commit:

- P1-1: no generated surface exposes read verbs. API include drops
  list/get (they would enumerate every tenant's and user's rows on this
  non-@TenantScoped model); the model CLI is writes-only (the generated
  CLI invokes over HTTP and the cli-api coherence gate rejects entries
  without routes); the runtime CLI/MCP surfaces are closed via the
  collection config, which core makes the runtime registry authority for
  the item class (ContentContributions precedent). The collection api
  include mirrors the model posture plus resolveBatch. Reads flow through
  the context-scoped batch resolver and server-side resolver APIs. Pinned
  by a new generated-surfaces test driving APIGenerator (list/get 405,
  create/update/delete open), MCPGenerator, and CLIGenerator.
- P1-2: delete() authorizes the caller against the PERSISTED row scope
  before deleting, with the same fail-closed rules as saves (tenant rows
  only for the context tenant, user rows only for the context user, app
  rows never inside a non-bypass tenant context).
- P1-3: save() authorizes against the persisted identity BEFORE accepting
  any mutation, so a foreign row cannot be re-scoped into the caller's
  tenant/user (the identity-change path would have deleted the original).
  getPersistedIdentity now carries the persisted tenantId/userId.
- P1-4: the missing-smrt-users predicate now walks the full error cause
  chain and matches ERR_MODULE_NOT_FOUND plus Node's "Cannot find
  package" message, so standalone consumers get the flat-tenant fallback;
  exported for direct testing with fabricated error shapes.
- P2-5/P2-6: write-time org checks (user-write lock enforcement and the
  required-field demotion default) are computed by the RESOLVER over the
  org tiers, so cascading ancestor-tenant locks and defaults are honored
  at save time instead of only direct-tenant rows; the resolver-side
  safety net stays authoritative on updates (no self-exclusion).
- P2-7: tenant chain nodes discarded by a permission-inheritance break
  are now excluded from merging AND the explained layer contributions
  (break points are chain-structural), so sequentially replaying the
  listed deltas reproduces the merged policy — pinned by a replay helper
  across all fields.
- P2-8: STI meta storage fields are rejected at the write boundary (the
  resolver excludes them, so rows would silently never apply).

Regression tests for every finding (32 -> 40 package tests).

Refs #2047
…e batch action (#2047)

Address the four round-2 codex findings:

- P1 (context-absent bypass): the scope-ownership boundary no longer
  returns early when no tenant context exists. Without ANY ambient
  identity (tenancy ALS never entered — e.g. an APIGenerator deployment
  whose auth middleware only populates request locals), tenant- and
  user-scope writes/deletes are rejected outright as unattributable;
  app-scope rows remain the server-side/ops path. In-context enforcement
  is unchanged. Residual documented in AGENTS.md: ALS-less deployments
  can still write app rows until #2049 adds fields:policy:manage. Test
  setup now seeds tenant/user rows under bypass contexts like real ops
  flows.
- P2-1 (runtime transport): core's APIGenerator gains a narrow
  decorator-metadata dispatch for custom COLLECTION-scoped actions
  (single-segment paths, api.include-gated, method-matched, 501 when a
  declared route has no implementation, results sanitized through
  toPublicJSON like the SvelteKit transport's toPublicResult). Without
  it, POST /<collection>/resolve degraded into handleCreate with the
  action segment discarded. Exercised end-to-end in the fields
  generated-surfaces test: 200 + gated payload, wrong-method 405,
  invalid-JSON 400.
- P2-2 (UUID reference defaults): foreignKey/crossPackageRef defaults
  must be UUID strings unless the field declares idType 'text' — the
  columns are native UUID on PostgreSQL/DuckDB and a non-UUID default
  would only fail at insert time.
- P2-3 (over-broad fallback predicate): isMissingUsersDependency now
  parses the missing-module TARGET from Node's quoted specifier (full
  cause chain) and falls back only when the target IS smrt-users or a
  subpath; a transitive failure inside an installed smrt-users (users
  path as importer) or a missing dist file rethrows instead of silently
  losing ancestor locks/defaults.

Regression tests for every finding (43 package tests, was 40).

Refs #2047
…2047)

Review of the #2047 slice surfaced two blocking defects plus a set of
smaller hardening items. All are fixed here with regression coverage.

P1-1 userless-context ownership bypass. `assertScopeOwnedByAmbientContext`
guarded the user tier with `context.userId !== undefined && ...`, so the
check VANISHED for any ambient context without a user id. Tenancy adapters
produce exactly that shape whenever no `resolveUserId` hook is configured
(API-key auth, service principals, background jobs, a bare
`withTenant({ tenantId })`), and user rows are `tenantId: null` by design,
so nothing else contained the write: such a caller could create, re-scope,
or delete ANY user's rows in ANY tenant, with the generated PUT echoing the
row back as a read primitive. The package rule is now that a missing
identity component DENIES rather than skips, applied on the write side and
mirrored on the read side in `assertResolutionAllowedInContext` (public
`resolveFieldPolicy` export). Context-LESS reads stay allowed: that is the
trusted server-side path, and `resolveBatch` never lets a request body
select a user.

P1-2 tenant tier write-dead over every generated surface. Core's
mass-assignment guard treats `tenantId` as server-managed and strips it
from every create/update body, while `FieldPolicy` is deliberately not
`@TenantScoped` so the tenancy interceptor never repopulates it. A
`POST {scopeType:'tenant', tenantId}` therefore always reached scope-shape
validation with `tenantId === null` and threw a 500, making the org-admin
tier unreachable from REST/SvelteKit. Fixed model-side rather than in core:
`save()` now derives a missing tenant/user owner from the ambient context.
That grants nothing, because the ownership guard already pinned the value
to the ambient one, and it keeps the model as the single validation
authority instead of adding an escape hatch to a guard every package
depends on. A bypass caller writing another tenant's row still needs a
server-side model call.

Also in this cycle:

- Server-side `updatedBy` stamping pulled forward from the #2050 branch, so
  audit attribution is not forgeable through the open write routes before
  #2051 consumes it.
- The persisted-scope guard now falls back to a NATURAL-key lookup. Every
  generated create mints a fresh UUID, so the primary-key lookup always
  missed while the `conflictColumns` upsert still replaced the occupant.
- `isRequiredField` reads `nullable` from the top level as well as `_meta`,
  matching how `required` was already read from both.
- The resolver cache key includes tenant-hierarchy loader identity; an
  injected loader was being served the default loader's ancestor chain.
- AGENTS.md records the deny-on-missing-identity rule, scope attribution,
  that `withSystemContext()` does not unlock org/user writes, and that the
  model's cli/mcp config is dead at runtime.

Tests: five groups from the review brief. The tenant-tier regression drives
one row per scope tier through `APIGenerator`, not `policies.create()` —
model-level tests are exactly what hid this.

Refs #2047
Copilot AI review requested due to automatic review settings August 5, 2026 15:47

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb323ec2a0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/core/src/generators/rest.ts Outdated
Comment thread packages/core/src/generators/rest.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the first slice of the smrt-fields epic by adding a new @happyvertical/smrt-fields package that persists sparse per-field policy overrides (FieldPolicy), resolves effective policy via a layered resolver (code seed → app → tenant chain → user), and exposes a gated resolveBatch custom collection action for client bootstrapping. It also updates core’s runtime APIGenerator to dispatch single-segment, decorator-declared collection-scoped custom actions (e.g. POST /<collection>/resolve) before CRUD routing so the runtime transport matches generated SvelteKit behavior.

Changes:

  • Added @happyvertical/smrt-fields package with FieldPolicy model, validation/ownership guards, resolver (+ explained variant), TTL cache, and resolveBatch action.
  • Added extensive regression/unit tests covering isolation rules, required-field invariant safety net, lock semantics, cache invalidation, and runtime surface exposure.
  • Updated core REST runtime generator to support decorator-declared custom collection actions dispatch.

Reviewed changes

Copilot reviewed 22 out of 24 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tsconfig.json Adds packages/fields to TS project references.
pnpm-lock.yaml Adds lockfile entries for the new packages/fields workspace importer.
packages/fields/vitest.config.ts Test runner configuration for the new package (SMRT vitest plugin, single fork).
packages/fields/vite.config.ts Library build config wiring for the new package.
packages/fields/tsconfig.json Package TS build configuration (composite build, dist outDir).
packages/fields/src/types.ts Public types for scope/visibility, resolved policy shapes, and resolver/batch contracts.
packages/fields/src/models/FieldPolicy.ts FieldPolicy model with write-time validation, ownership/isolation enforcement, upsert/identity-change handling, and cache invalidation.
packages/fields/src/index.ts Package entrypoint exports and manifest self-registration.
packages/fields/src/generated-surfaces.test.ts Ensures generated REST/CLI/MCP surfaces are closed appropriately and runtime action dispatch works.
packages/fields/src/field-policy.test.ts Model-level validation and security/isolation regression coverage.
packages/fields/src/field-policy-resolver.ts Layered resolver + explained variant, tenant-hierarchy loading, lock and required-field safety net, caching.
packages/fields/src/field-policy-resolver.test.ts Resolver behavior tests including hierarchy, cache keying, isolation, and explain replay contract.
packages/fields/src/field-policy-batch.test.ts Tests for resolveBatch input validation, identity sourcing, and public field gating.
packages/fields/src/field-definitions.ts Live-ObjectRegistry helpers for type/security checks and code-seed derivation.
packages/fields/src/collections/FieldPolicyCollection.ts Collection wrapper providing row fetch helpers and the resolveBatch custom action with fail-closed gating.
packages/fields/src/cache.ts 30s TTL cache keyed by db identity + loader identity, with prefix invalidation.
packages/fields/src/smrt-register.ts Package manifest self-registration bootstrap (issue #1132 pattern).
packages/fields/README.md Package-level usage overview and basic examples.
packages/fields/package.json New package manifest (exports, scripts, deps).
packages/fields/CLAUDE.md Adapter pointing to AGENTS.md.
packages/fields/AGENTS.md Canonical architecture and invariants documentation for the new package.
packages/core/src/generators/rest.ts Adds runtime dispatch for decorator-declared collection-scoped custom actions and result sanitization.
AGENTS.md Adds fields to the root orientation list.
.changeset/config.json Adds @happyvertical/smrt-fields to the fixed version group.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

Comment thread packages/fields/src/types.ts Outdated
…act (#2047)

An independent review of the slice found one schema defect and three ways the
new runtime REST custom-action dispatcher diverged from the contract every
other transport already shares through `generators/custom-action.ts`.

Stray unique index on the policy table. A decorated collection emits its OWN
manifest schema for the item's table, and `FieldPolicyCollection` did not
repeat FieldPolicy's `conflictColumns` — so its schema fell back to
SmrtObject's default unique `(slug, context)` index. Manifest-driven consumer
migrations aggregate both schemas onto `_smrt_field_policies`, where that
index rejects legitimate layered rows: every policy row has a NULL slug and
context, and the app, tenant, and user rows for one field are distinct only by
the real natural key. The runtime registry cannot surface this because
`getAllSchemas()` is keyed by table name, so the two schemas collapse into a
single entry — the new test asserts against the generated manifest instead.

Scope was read off the route alone. `ApiCustomRouteConfig.scope` is OPTIONAL
and documented to default to `collection` for statics and `item` for instance
methods, but the dispatcher required a literal `scope: 'collection'`. A valid
static action that omitted it was skipped, so `POST /<collection>/<action>`
fell through to CRUD and created an object wherever `create` is exposed.
Scope now resolves through `resolveCustomActionMetadata`, with the receiver
deciding — a route-only scope cannot manufacture one. An action with no
collection-hosted receiver falls through as before unless the route explicitly
declared `scope: 'collection'`, which stays a 501 so an action request never
degrades into a write.

Arguments ignored declared parameters. Every action was invoked with exactly
one options object. Now `buildCustomActionInvocationArgs` projects them as the
generated SvelteKit, CLI, and MCP transports do, so `action(a, b)` receives
two arguments instead of `{a, b}` and `undefined`. Behaviour is unchanged
wherever parameter metadata is absent, which is currently every
collection-hosted action.

Returned failures were served as 200. An action returning the shared
`{ ok: false, code, message, status }` convention had the raw object wrapped
as a success. `normalizeCustomActionFailure` now applies first, yielding the
requested non-2xx status and the redacted payload.

Relatedly, an ABSENT request body is no longer conflated with a malformed one:
a zero-parameter action is legitimately called with no body, while a body that
is present but unparseable stays a 400.

Adds `rest-custom-actions.spec.ts` — the dispatcher had no core-side coverage.

Refs #2047
`FieldPolicyOptions.defaultValue` was documented as "JSON-encoded default
value (string), or a plain value to be serialized" and implemented to match:
a string was assigned as-is on the assumption it was already encoded, and
anything else was serialized. Strings are the most common default type for
text fields and are the one plain value that branch never reached, so the
natural call — `{ defaultValue: 'Net 30' }` — stored unparseable text and only
failed later, at the JSON.parse in validation.

The ambiguity is inherent to one option carrying both meanings: `'"TBD"'` and
`'TBD'` are indistinguishable, so no sniffing rule can be correct. The channel
has to be explicit.

Which channel keeps which meaning is settled by the callers, not by taste.
Every existing caller passes PRE-ENCODED values: the eleven in-package test
sites, and — decisively — #2049/#2050's gear, whose `planFieldSettingsWrite`
posts `JSON.stringify(draft.defaultValue)` into a `FieldPolicyRowPatch`
typed `defaultValue: string | null`, straight through the generated write
routes to this constructor. Auto-serializing `defaultValue` would therefore
double-encode every gear write across the rest of the epic. So `defaultValue`
keeps the encoded meaning (now typed `string | null` rather than `unknown`,
so the type states it) and the PLAIN value gets the new explicit path:

- `defaultValueRaw` — any value, always serialized, strings included; the
  constructor-option twin of the existing `setDefaultValue()` method.
- Supplying both throws rather than silently resolving a precedence.
- The parse failure now names the fix instead of only reporting bad JSON, so
  the natural-looking call corrects itself at the point of use.

Storage and every read site are unchanged and stay symmetric: the column
remains JSON-encoded, `getDefaultValue()` and the resolver's `rowToDelta`
still decode it, and `normalizeDefaultValueForPersistence` still treats a
directly-assigned string as already-encoded.

Adds a regression test round-tripping a plain string through save → resolve,
covering the literal-looking-like-JSON case ('null'), the encoded channel
staying encoded, and both rejection paths.

Refs #2047
@willgriffin
willgriffin added this pull request to the merge queue Aug 5, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Removed from the merge queue — reason: CI_FAILURE

A required status check went red on the merge-group head. Open the linked run and read the failing leaf job (shard), not the rollup — the rollup only mirrors it.

Latest merge-group validation run for this PR: https://github.com/happyvertical/smrt/actions/runs/31031899003

merge-queue-watchdog: dequeue events are otherwise invisible; this comment makes queue ejections diagnosable (#2197).

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Removed from the merge queue — reason: MANUAL

Someone removed this entry. Removal also restarts validation for every entry that was queued behind it.

Latest merge-group validation run for this PR: https://github.com/happyvertical/smrt/actions/runs/31046291621

merge-queue-watchdog: dequeue events are otherwise invisible; this comment makes queue ejections diagnosable (#2197).

@willgriffin

Copy link
Copy Markdown
Contributor Author

Removed from the merge queue so the CI-lane fix (#2242) can go first — not a judgement on this PR.

Two reasons: its queue validation was consuming the metal fleet that #2242's own validation needs, and it was ahead of #2242 in line while carrying the exact defect #2242 fixes. This PR was already ejected once at 19:30Z by Validate Changes / test-packages (2/3) failing on mcp-conformance-fixtureHook timed out in 10000ms, both tests reported skipped. That is #2238, fixed in #2242, and has nothing to do with the code here.

Re-queue once #2242 lands and this should get a clean run.

@willgriffin
willgriffin added this pull request to the merge queue Aug 6, 2026
@willgriffin
willgriffin removed this pull request from the merge queue due to a manual request Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Removed from the merge queue — reason: MANUAL

Someone removed this entry. Removal also restarts validation for every entry that was queued behind it.

Latest merge-group validation run for this PR: https://github.com/happyvertical/smrt/actions/runs/31066045819

merge-queue-watchdog: dequeue events are otherwise invisible; this comment makes queue ejections diagnosable (#2197).

@willgriffin
willgriffin merged commit d5dbe93 into main Aug 6, 2026
30 checks passed
@willgriffin
willgriffin deleted the feat/issue-2047-smrt-fields branch August 6, 2026 02:38
willgriffin added a commit that referenced this pull request Aug 6, 2026
Updating this branch to current main brought in packages/fields from
#2230, which the lockfile predates:

  [ERR_PNPM_OUTDATED_LOCKFILE] pnpm-lock.yaml is not up to date with
  <ROOT>/packages/fields/package.json

Regenerated with pnpm install --lockfile-only.
willgriffin added a commit that referenced this pull request Aug 6, 2026
… app-cli timeouts (#2245)

* fix(app-cli): give the suite explicit test and hook timeouts

bridge.test.ts spawns tsx on a fixture through StdioClientTransport and
completes an MCP handshake before asserting. The package declared no
testTimeout, so that ran against vitest's 5s default.

It passed locally and on the affected lane, then timed out three times in
the merge queue -- where mode: full actually runs this package -- and
ejected an unrelated CI-only PR. Set both budgets explicitly at 30000, the
value the rest of the workspace uses; hooks do not inherit testTimeout.

Closes #2243

* fix(deps): stop Renovate bumping androidx past the compileSdk 36 pin

androidx.core 1.18+ and androidx.lifecycle 2.11+ require compileSdk 37,
and both seed apps are pinned to compileSdk 36. The catalogs recorded
that coupling only in comments, which Renovate cannot read, so every
weekly update bumped core-ktx and lifecycle straight past it and left
:sample:checkDebugAarMetadata failing the whole PR.

Encode the constraint as two allowedVersions rules so the pin survives
the weekly run, matching how renovate.json already expresses the
template-package pin.

Closes #2234

* fix(deps): restore brace-expansion's lower advisory bound

GHSA-rgw5-rvv9-x895 starts at brace-expansion 4.0.0, but #2203 widened the
selector to a bare `@<5.0.9`, which also matches every 1.x, 2.x, and 3.x
dependency. A consumer of an older major would be forced onto 5.0.9 — an
unrelated breaking upgrade for a release outside the advisory.

Both reviewers raised this against the identical selectors in #2205; the
undici and fast-uri cases landed separately in #2210 with their advisory
lower bounds intact. This applies the same correction to the third one.

Fixing the upper bound is what keeps the override from going stale as the
advisory widens; keeping the lower bound is what stops it reaching past
the advisory. The two are independent and both are required.

Verified neutral: `pnpm audit` reports `1 low | 6 moderate` before and
after, brace-expansion appears in neither report, and the only lockfile
change is the mirrored override key — no package resolution moves.

Closes #2204

* chore(deps): sync sdk packages to v0.85.5

* chore(deps): refresh the lockfile for packages/fields

Updating this branch to current main brought in packages/fields from
#2230, which the lockfile predates:

  [ERR_PNPM_OUTDATED_LOCKFILE] pnpm-lock.yaml is not up to date with
  <ROOT>/packages/fields/package.json

Regenerated with pnpm install --lockfile-only.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

smrt-fields: FieldPolicy model + layered resolver (app → tenant → user) + batch resolve endpoint

2 participants