Skip to content

v6.1.0-edge.7: Field-Level Security, native tool calling, and AI Personas

Field-Level Security lands — per-field Read, Update and Create control by role, enforced server-side with no admin exemption — alongside a provider-neutral native tool-calling surface that ships switched off, an AI Persona catalog with modality inheritance, and GPT-Live realtime voice over WebRTC and SIP. Fixes across PostgreSQL conversion, Explorer search and CodeGen ride along.

Upgrade Notes apply: roughly 2,150 generated GraphQL fields lose non-nullability, so external consumers holding generated types must regenerate them.

Edge builds are prereleases. They publish under the edge dist-tag and never move latest.

  • Field-Level Security — per-field Read/Update/Create control by role (@memberjunction/core, @memberjunction/server, @memberjunction/core-entities, @memberjunction/codegen-lib, @memberjunction/generic-database-provider, @memberjunction/graphql-dataprovider, @memberjunction/sql-dialect). A new EntityFieldPermission table holds one row per (field, role) with three independent verbs, each Allow, Deny or No Access. No Access is the neutral default; Deny beats any number of Allows across a user’s roles. Field security is switched on per entity, explicitly, via Entity.EnableFieldLevelSecurity — nothing is inferred from whether permission rows happen to exist, so adding a rule can never change access on an entity that has not opted in. Turning the flag on snapshots existing entity-level permissions into per-field rows, so enabling changes nothing until an administrator tightens a specific field; turning it off keeps the rows inactive rather than discarding the configuration. Read is required for Update and Create, enforced both by a CHECK constraint within a row and by clamping again across roles, because two individually legal rows held by one user would otherwise aggregate to write-only access no constraint could see. Enforcement is server-side and authoritative: denied columns are stripped from RunView results on both cache paths and from single-record GraphQL responses; a request naming a denied field in ExtraFilter, OrderBy or an Aggregates expression is rejected, since MIN(Salary) reads the values back without the column ever appearing; saves that change an unreadable field are rejected and values for unreadable fields are ignored; a value supplied for a field the user may not create is dropped so the column takes its default, because an error naming the field would confirm it exists and is restricted. Nobody is exempt — no admin bypass, no Owner carve-out, and no special case for the MJ system user; what is protected instead is the configuration, which refuses a rule that denies anything to a role that account holds. Primary keys, __mj_ columns and the security entities can never be restricted. MJ: Record Changes rows are projected against the entity each row is about, resolved per row, so an audit payload cannot be used to read a denied field out the side; ChangesDescription is withheld entirely rather than redacted, because prose leaks on the first value that appears in an unexpected form. New guide: guides/FIELD_LEVEL_SECURITY_GUIDE.md.

  • NOT NULL columns can now be restricted (@memberjunction/core, @memberjunction/server, @memberjunction/codegen-lib, @memberjunction/graphql-dataprovider, @memberjunction/ng-entity-viewer). The guide previously said not to restrict a NOT NULL column, which put roughly 2,150 of 4,650 restrictable fields off-limits — including the ~400 foreign-key display columns that inherit non-nullability from the key they display, so “hide which client this contract belongs to” did not work. The cause was one wrong inference: a column’s NOT NULL constraint and a GraphQL ! say different things — “no row stores an empty value here” versus “every response, to every caller, carries a value here” — and the second does not follow from the first. They coincided only while every caller saw every column of every row they could read, which is exactly what field security ends. Generated object types are now non-nullable only where field security is structurally incapable of stripping a value. This removes three symptoms, all of which required a denied NOT NULL column: single-record loads nulling the entire record, typed list queries nulling the entire query, and a mutation whose write landed in the database while its response failed to serialize, so the client reported a failed save for an edit that had actually succeeded. ReadableFields___ is added to every generated object type, because GraphQL emits every field the client selected — so a denied field that was asked for arrives as an explicit null indistinguishable from a genuine one, and the client’s own metadata is stale in the window after a permission change. It lists readable fields rather than denied ones deliberately: naming denied fields would hand back precisely what metadata filtering exists to withhold.

  • A provider-neutral native tool-calling surface on BaseLLM, shipped switched off (@memberjunction/ai, @memberjunction/ai-anthropic, @memberjunction/ai-openai, @memberjunction/ai-gemini, @memberjunction/ai-cerebras, @memberjunction/ai-groq, @memberjunction/ai-prompts, @memberjunction/ai-agents). MJ’s providers have described agent actions as prose in the system prompt and parsed a JSON envelope back; newer agentically-trained models increasingly fight that, and the prose action catalog is most of a 56–104KB Loop system prompt. ChatParams gains tools, toolChoice and parallelToolCalls; responses normalize to ChatCompletionMessage.toolCalls with parsed arguments plus a 'tool_calls' finish reason, and multi-turn tool use round-trips through a new tool message role. Nothing in the tool-calling path changes behavior on merge. The catalog now records which models and vendor servings can do native tool calling — 139 model rows and 83 vendor-level overrides — but every one carries DefaultToNativeToolCalling: false, no prompt sets UseNativeToolCalling, and no MJ caller passes tools, so the gate resolves false on every existing path. The OpenAI-compatible mapping lives once in @memberjunction/ai and is shared by OpenAI, Groq and Cerebras, which matters because GPT-OSS-120B on Cerebras is the most-deployed model across MJ’s shipped agents and both drivers previously reported SupportsTools: false, making the one configuration the feature most needed to reach the one it could not. A full-corpus comparison ran both arms four times at N=3 and returned NO-GO on all three providers: native tool calling delivers the gain it should — given that an action is the right answer, GPT 5.6-luna acts 76% of the time through the envelope and 96% with tools declared — but spends it on worse tool selection and on calling a tool on 5–20% of turns whose right answer was chat or delegation. The prompt-size premise did not hold on any run. Capability is a statement of fact and is safe to ship; policy is the switch, and it is off.

  • AI Personas: a metadata catalog and resolution across the engine (@memberjunction/core-entities, @memberjunction/ai-engine-base, @memberjunction/aiengine, @memberjunction/ai-agents, @memberjunction/server). Four new tables — AIPersona, AIPersonaVendor, AIModelPersona, AIAgentPersona — carry a curated cross-modality persona catalog, concrete vendor and modality bindings with typed vendor settings, model availability and priority sequences, and per-agent assignments with a filtered unique index enforcing one default per agent. BaseAIEngine caches and resolves them (GetModelPersonas, GetAgentPersonas, ResolveAgentPersona), incorporating agent style overrides and sequence ordering, and GetRealtimeModelVoices consults metadata personas before falling back to a driver’s SupportedVoices. Alongside it, modality inheritance resolves Agent → Model → System → Default, honouring InheritTypeModalities, the model type’s default input and output modalities, and junction-level IsSupported = 0 / IsAllowed = 0 vetoes.

  • GPT-Live realtime voice, on the server and in the browser (@memberjunction/ai-openai, @memberjunction/ai-realtime-client, @memberjunction/ai-bridge-base, @memberjunction/ai-bridge-server, @memberjunction/ai). OpenAILiveRealtime connects to OpenAI’s Live sessions endpoint with a remote reasoning plane that delegates to Responses, a task-revision counter giving supersede-and-discard cancellation so a stale in-flight result is discarded rather than spoken, and direct audio/pcmu G.711 μ-law carrier passthrough that removes PCM conversion and its odd-byte truncation. OpenAILiveClient is the browser half: it creates the events data channel before the offer, uses non-trickle ICE, exchanges SDP through a server broker, and accounts for the provider’s 15-second session pre-bill in its duration and usage reporting. OpenAISipBridge and BaseDetachedMediaBridge support carriers whose media never transits MJ — audio in/out requirements become conditional on a detached media plane, and the transport seam skips relay loops while still flushing on barge-in. Realtime capability metadata expands to describe reasoning planes, delegation reconfiguration, interruption and cutoff signals, and usage bases; Inworld and ElevenLabs migrate to resolving their underlying model and managed agent through Reasoning.Remote.Ref.

  • Realtime co-agents can invoke actions directly, gated by driver capability (@memberjunction/ai-agents, @memberjunction/ng-conversations). A realtime agent could previously only delegate; it now projects a configured allowlist of actions into the session’s tool set and executes them through the Actions API with a timeout. The configuration is closed by default and names actions explicitly (or ['*']), and the tools are only offered to drivers declaring SupportsDynamicToolSet — OpenAI, GPT-Live and xAI receive them; ElevenLabs and Gemini declare nothing and receive none. Direct action turns persist and render as review cards.

  • Server Extension lifecycle: pre-auth and post-auth phases, and a typed service registry (@memberjunction/server-extensions-core, @memberjunction/server, @memberjunction/messaging-adapters, @memberjunction/telephony-adapters). Extensions declare a phase and mount either before the authentication middleware — which is what a webhook needs in order to verify a signature — or after it. Extensions are refused a route that would shadow a reserved system root (/graphql, /auth, /health, /media, /schema, /mcp), an OnAllExtensionsMounted hook is awaited across every loaded extension once all phases have mounted, and a ServerExtensionServiceRegistry lets extensions discover one another’s services without importing each other. The Twilio, Vonage, RingCentral and Teams adapters move out of MJServer into @memberjunction/telephony-adapters as extensions on that lifecycle, leaving MJServer vendor-generic; legacy configInfo.telephony settings are synthesized into extension config, so existing deployments need no change. New guide: guides/SERVER_EXTENSIONS_GUIDE.md.

  • A skill can bundle an action without putting it into the agent’s run (@memberjunction/ai-agents, @memberjunction/ai-engine-base, @memberjunction/aiengine, @memberjunction/core-entities, @memberjunction/server). Bundling an action into a skill made it visible and executable to the model for the rest of the run. A skill whose reply carries a menu — buttons the application wires to an action, pressed by the person on the next turn — wants the association without the model calling the action on its own mid-conversation (#4226). AISkillAction.ExposeToModel defaults to 1, today’s behaviour; 0 keeps the action bundled for SKILL.md export and tooling but out of the run. SKILL.md round-trips the flag through an optional codeOnlyActions frontmatter list.

  • PostgreSQL identifier auto-quoting: 45 of 53 common constructs were corrupted (@memberjunction/sql-dialect). A generated query against a PostgreSQL client used CURRENT_DATE in a date predicate and came back as column "CURRENT_DATE" does not exist — these functions are spelled without parentheses, so the word-before-( rule never classified them as functions. Auditing the class found the gap was broad, and every instance hinges on a word not followed by (. Added: the niladic datetime and identity functions; ordered-set aggregate and window-frame words, of which WITHIN is sharpest because PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) is how every median is written; the rest of PostgreSQL’s reserved words, which are unconditionally safe because PostgreSQL will not resolve a same-named column bare either; type names in cast position, where DOUBLE PRECISION worked only because both halves happened to be listed and CHARACTER VARYING did not; and the utility statement verbs. Seventeen non-reserved words are deliberately excluded, since they are legal bare column names in a customer schema this repo’s baseline cannot see. The reverse guard in the baseline test was itself sampling its reserved-word oracle, which is why the gap went undetected; it is now transcribed in full.

  • Applying a MaxRows cap no longer rewrites the caller’s SQL on PostgreSQL (@memberjunction/generic-database-provider). The cap was injected by re-emitting the entire statement through the AST, and node-sql-parser normalizes as it generates — so ORDER BY x ASC nulls last came back upper-cased, the identifier auto-quoter then quoted the now-capitalised keywords it did not recognise, and PostgreSQL rejected ORDER BY x ASC "NULLS" "LAST". The round-trip also rewrote cp."recordKey" and ::integer. Where a trailing LIMIT N is provably equivalent to the AST injection, the cap is appended as text and the statement is left byte-identical; every other shape falls through unchanged. Deliberately not switched to the existing wrapping fallback, which would put the cap above a subquery’s ORDER BY where PostgreSQL does not guarantee the inner ordering survives — a “top 100 by rank” query could return an arbitrary 100.

  • Three PostgreSQL conversion defects, each producing a file the converter called clean and PostgreSQL then rejected on apply (@memberjunction/sql-converter, @memberjunction/sqlglot-ts). BIT literals were coerced inside INSERT … VALUES but not in UPDATE … SET or WHERE, so a CodeGen update against a core-metadata table failed with operator does not exist: boolean = integer. A comma inside a CodeGen comment shifted every subsequent column, because the value splitter tracked quoted strings and nested parens but not SQL comments — observed as a boolean landing in the integer Scale column. And PostgreSQL refuses to index a table carrying pending trigger events, which a migration hits whenever it seeds FK-bearing rows and then indexes the referenced table; the dialect now emits SET CONSTRAINTS ALL IMMEDIATE ahead of a guarded CREATE INDEX, at statement level, since issuing it inside the DO block does not clear events queued by earlier statements.

  • In-process CodeGen guards (@memberjunction/codegen-lib). An AI-generated entity name is validated before use; a failed entity INSERT propagates instead of being swallowed, so the surrounding transaction can roll back rather than commit a partial set of new entities; a rolled-back batch no longer leaves its names in the process-static new-entity list; and in-process runs keep advanced generation off unless explicitly enabled (#4421).

  • mj dev workspace sees Open App client packages and their shell peers (@memberjunction/cli). The generated parent manifest now registers every packages.client[] and packages.shared[] entry from each member’s committed mj-app.json at workspace:*, unmet shell-provided peers are reported per shell with the version the parent already pins, and doctor gains a check that a member’s declared client packages are linked at the parent. Fixes a page-load resolve failure that presented with a completely green build (#4364).

  • Knowledge Hub and universal search fixes, found wiring a Dropbox team-space vault into Pinecone (@memberjunction/storage, @memberjunction/content-autotagging, @memberjunction/search-engine). The Dropbox refresh-token constructor path now marks the driver configured, so callers that only construct it are no longer rejected with “Missing: Access Token”, and an optional path root lets Business team-space paths resolve. Cloud-storage autotagging walks sub-folders under its prefix instead of one level. Both the vectorizer and the search provider address third-party indexes by VectorIndex.ExternalID rather than the MJ display name — the semantic lane had been 404ing against Pinecone on every query whenever the two differed. Invalid-content deletions and failed content-item saves are logged rather than silent.

  • Weekly AI model and vendor refresh (2026-09-13) (@memberjunction/ai, @memberjunction/aiengine, @memberjunction/core-entities). The GLM-5.3-Flash launch promo expired on schedule, so its cost row is now Expired with an EndedAt and a new row records the list rate from that same instant — preserving the historical rate rather than overwriting it. GPT-6 Astra gains an Amazon Bedrock inference row and its short-context cost record. DeepSeek V4.1 Flash is added at its off-peak tier, recording a real tier rather than a blend. No OpenRouter cost row was written for it: the route exists but its rate was not confirmed, and an invented price is worse than an absent one.

  • Explorer view and grid search returned 0 rows — or refused outright — for ordinary terms (@memberjunction/server, @memberjunction/generic-database-provider). UserSearchString is the free text a person typed into a search box, not a SQL clause, and it never reaches SQL as a fragment. Two screens intended for genuine fragments were applied to it anyway. The GraphQL-boundary AST screen wraps its argument as a WHERE clause and fails closed when that does not parse, so Marcus Chen parsed as nothing and O'Leary as an unterminated literal — which made essentially every person-name search return nothing. The provider’s keyword denylist, word-boundary-matched against free text, refused Union Pacific, Update Request and drop shipment, and the error reached the grid with a null message so the search box simply looked broken. Both screens are removed from this one input; every genuine clause fragment is screened exactly as before. One exception is retained: a field carrying a custom search format may place the term outside quotes, so the denylist is re-applied there, and only when such a field will actually participate (#4392).

  • An RLS row filter was collected from permission rows that did not grant the operation (@memberjunction/core). The filters of a user’s roles are OR’d together, so a filter read off a row whose Can* flag was false widened the clause: a user granted Create by role A bound to filter F1 created against F1 OR F2 whenever role B kept a leftover CreateRLSFilterID = F2 beside CanCreate = false. Nothing clears the filter column when the flag is cleared, and the flags are OR’d across roles, so such a user passed the permission gate on role A alone. Granting rows are unchanged.

  • A save refused by a server-side ValidateAsync() now highlights the offending fields (@memberjunction/global, @memberjunction/core, @memberjunction/server, @memberjunction/graphql-dataprovider, @memberjunction/ng-base-forms). An asynchronous refusal only raised a toast, while a synchronous Validate() refusal drew the red border and inline message. Write refusals now carry structured validation errors that the provider rehydrates and the form publishes through one path, and a server-reported error stays visible on a field the user had already edited until they edit it again. A browser-side ValidateAsync() benefits too — its refusal previously surfaced as a bare “Error saving record”. Errors with no field source stay toast-only.

  • Every integration rig that imported the harness shim died before running a single assertion (@memberjunction/integration-test-suite, @memberjunction/testing-integration). The shim re-exported two symbols the framework package does not export — they live in the suite package, since the framework ships content-free by design — and a named re-export of a binding the source module does not provide is an ESM link-time error. It therefore failed at module load rather than where it was written, taking down the nightly cross-server invalidation rig plus five hand-run rigs, and tsc never saw it because the rigs directory sits outside the package’s tsconfig. Nothing consumed either symbol, so they are removed rather than re-pointed, and a test now checks each forwarded name against the package’s real exports.

  • Deny rows can no longer be read as grants by the RLS readers (@memberjunction/core, @memberjunction/materialization, @memberjunction/codegen-lib). EntityPermissionInfo.IsDeny is now one predicate — case- and whitespace-insensitive, with a blank type treated as Allow — used by GetUserPermisions and by both RLS readers, so a set Can* flag on a Deny row is never mistaken for a grant. Unreachable in practice, since a user carrying a Deny row fails the permission gate first, but the methods now implement the invariant their documentation states.

  • The input dialog’s body no longer sits flush against the dialog edges (@memberjunction/ng-conversations). The dialog container pads only string content, so the message, labels and inputs of a component body went unpadded while the header and footer did not — visible in rename-conversation and every other dialogService.input() prompt.

  • Deleting the last codeOnlyActions entry from a SKILL.md now takes effect (@memberjunction/ai-agents). A known list key in block form with no items parsed as an absent key rather than an explicit empty list, so removing the final name and re-importing silently carried the old code-only flag instead of putting the action back into the agent’s run. Warnings for that key now quote the name the author typed rather than the resolved GUID.

  • Generated GraphQL object types lose non-nullability on roughly 2,150 of 4,650 restrictable fields, across all 384 generated object types. String! becomes String, and likewise for the other scalars. Any external consumer holding GraphQL types generated against the previous schema will fail to compile against this one until those types are regenerated; a consumer that reads the fields without regenerating sees no runtime change. Input types are not affected, so no write contract changes. Non-nullability is retained only on primary keys and __mj_ system columns, where field security is structurally incapable of stripping a value.

  • Full-text search joins multi-word terms differently. For entities with full-text search enabled, boolean-operator detection is now word-boundary based rather than substring based. Previously OR matched inside “corporate” and AND inside “standard”, so ordinary two-word searches were emitted with % joins — Corporate%Office, which is not valid full-text syntax. They are now joined as Corporate AND Office. Deployments relying on the previous %-joined output should re-check their full-text searches.

  • Eight prompts change model once this release’s metadata is pushed. Query Builder and the Research Agent family — 7 agents, 8 prompts — move from Gemini 3.5 Flash to Gemini 3.8 Flash. The Flash-Lite rows that outranked them are set Status: "Inactive" rather than deleted, because mj sync push never deletes and removing the rows from JSON would have left those agents on Flash-Lite in every existing database.

  • Seven schema migrations ship in this release, covering the EntityPermission uniqueness constraint and its duplicate merge, identity-claim foreign-key index healing, EntityFieldPermission, AISkillAction.ExposeToModel, the AI Persona schema, native tool calling, and the consolidated metadata sync. The EntityPermission merge refuses to run where duplicate rows name different row-level-security filters — two different filters have no defensible union, and choosing one would silently change which rows a role can see. It names the offending groups and asks for them to be resolved by hand.