OptionalProvider: IEntityDataProviderStatic ReadonlyMAX_Maximum number of BaseEntityResult entries retained in _resultHistory per entity
instance. Set to 50 — enough for diagnostic context while bounding worst-case
memory for entities that survive thousands of Save/Delete cycles.
ProtectedActiveInternal helper method for the class and sub-classes - used to easily get the Active User which is either the ContextCurrentUser, if defined, or the Metadata.Provider.CurrentUser if not.
The ContextCurrentUser is a property used to manually set the "current" user for scenarios, primarily on the server side, where the user changes per request. For situations where there is no global CurrentUser in the Metadata.Provider, you MUST set this property to the user you want to use for the current operation. If you used Metadata.GetEntityObject() to get the entity object, this property will be set automatically for you as that method has a parameter that can be provided for the ContextCurrentUser.
ProtectedDefault value for whether async validation should be skipped. Subclasses can override this property to enable async validation by default. When the options object is passed to Save(), and it includes a value for the SkipAsyncValidation property, that value will take precedence over this default.
Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
Access to the underlying metadata for the entity object.
Helper method to return just the first Primary Key
Returns the child entity in the IS-A composition chain, or null if this
entity has no child record, hasn't been loaded yet, or is an overlapping
subtype parent (use ISAChildren instead for overlapping parents).
Example: For a MeetingEntity where a Webinar record exists with the same PK,
ISAChild returns the WebinarEntity instance.
For overlapping subtype parents (AllowMultipleSubtypes = true), returns
the list of child entity type names that have records for this PK.
For disjoint parents or non-parent entities, returns null (use ISAChild instead).
Example: For a PersonEntity with AllowMultipleSubtypes=true, might return [{entityName: 'Members'}, {entityName: 'Volunteers'}, {entityName: 'Speakers'}].
Returns the parent entity in the IS-A composition chain, or null if this entity is not an IS-A child type.
Example: For a MeetingEntity that IS-A ProductEntity, ISAParent returns
the ProductEntity instance.
Named with ISA prefix to avoid collision with generated entity properties
(many entities have a Parent string column in the database).
Returns true if any operation (Save, Delete, or Load) is currently in progress. This is a convenience property that combines IsSaving, IsDeleting, and IsLoading. Useful for disabling UI elements when any database operation is happening.
Returns true if a Delete operation is currently in progress. This is useful for UI components to show loading indicators or disable buttons while deleting.
Returns true if a Load operation is currently in progress. This is useful for UI components to show loading indicators while data is being fetched.
Returns true if the record has been saved to the database, false otherwise. This is a useful property to check to determine if the record is a "New Record" or an existing one.
Returns true if a Save operation is currently in progress. This is useful for UI components to show loading indicators or disable buttons while saving.
Returns the most recent result from the result history. If there are no results in the history, this method will return null.
Returns the leaf (most-derived) entity in the IS-A chain, walking
downward through child references. Returns this if no child exists.
For overlapping subtype parents (AllowMultipleSubtypes = true), returns
this because there is no single child chain to follow — the parent
is the leaf from its own perspective.
Returns the primary key for the record. The CompositeKey class is a multi-valued key that can have any number of key/value pairs within it. Always traverse the full set of key/value pairs to get the full primary key for the record.
Returns an array of all primary key fields for the entity. If the entity has a composite primary key, this method will return an array of all primary key fields. If the entity has a single primary key, this method will return an array with a single field in it.
Returns this provider to be used for a given instance of a BaseEntity derived subclass. If the provider is not set, the BaseEntity.Provider is returned.
Gets the provider transaction handle for IS-A chain orchestration.
Sets the provider transaction handle. Used during IS-A save/delete to share a single database transaction across the entire parent chain.
Returns a list of changes made to this record, over time. Only works if TrackRecordChanges bit set to 1 on the entity you're working with.
Returns true if the record has been loaded from the database, false otherwise. This is useful to check to see if the record is in a "New Record" state or not.
Returns the active restore context for the next save, if any.
Read by the data provider when generating the RecordChange SQL: when
non-null, the resulting RecordChange row is written with
Source='Restore', RestoredFromID = SourceChangeID, and
RestoreReason = Reason. Returns null for ordinary saves.
The result history shows the history of the attempted transactions (Save and Delete) for this particular entity object. This is useful for tracking the results of operations on the entity object.
Returns the root (least-derived) entity in the IS-A chain, walking
upward through parent references. Returns this if no parent exists.
Returns the RunQueryProvider to be used for a given instance of a BaseEntity derived subclass.
Returns the RunReportProvider to be used for a given instance of a BaseEntity derived subclass.
Returns the RunViewProvider to be used for a given instance of a BaseEntity derived subclass.
Transaction Groups are used to group multiple transactions into a single ATOMic transaction in a database. They are also useful even in situations with ATOMicity is less important but you want to submit a group of changes to the API server in a single network call.
Utility storage for vector embeddings that represent the active record. Each string in the Map can be any unique key relative to the object so you can use this to track vectors associated with
StaticBaseWhen a BaseEntity class raises an event with MJGlobal, the eventCode property is set to this value. This is used to identify events that are raised by BaseEntity objects. Any MJGlobal event that is raised by a BaseEntity class will use a BaseEntityEvent type as the args parameter
StaticProviderStatic property to get/set the IEntityDataProvider that is used by all BaseEntity objects. This is a global setting that is used by all BaseEntity objects. It can be overriden for a given BaseEntity object instance by passing in a provider to the constructor of the BaseEntity object. Typically, a provider will pass itself into BaseEntity objects it creates to create a tight coupling between the provider and the BaseEntity objects it creates. This allows multiple concurrent connections to exist in the same process space without interfering with each other.
Called after an Action is executed by the AI Engine
Called before an Action is executed by the AI Engine This is intended to be overriden by subclass as needed, these methods called at the right time by the execution context
ProtectedCascadeCascade-deletes an IS-A child record when the parent entity has CascadeDeletes enabled. Loads the child entity, then deletes it through the normal IS-A chain. The child's delete will cascade further down if it also has children and CascadeDeletes.
ProtectedCheckChecks if this entity has any child records in IS-A child entity tables. Used for parent delete protection — a parent record cannot be deleted while child type records referencing it still exist.
Object with HasChildren flag and the name of the child entity found
Utility method that returns true if the given permission being checked is enabled for the current user, and false if not.
Clears any pending restore context. Safe to call when no context is set. Recommended after Save() returns so a subsequent ordinary save isn't accidentally tagged as a restore.
This method MUST be called right after the class is instantiated to provide an async/await pair for any asynchronous operations a given entity needs to do when it is first created/configured. When you call Metadata/Provider GetEntityObject() this is done automatically for you. In nearly all cases you should go through GetEntityObject() anyway and not ever directly instantiate a BaseEntity derived class.
This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record. This method will ONLY copy values for fields that exist in the current entity object. If the other object has fields that don't exist in the current object, they will be ignored.
the other entity object to copy values from
OptionalincludePrimaryKeys: booleanif true, the primary keys will be copied as well, if false, they will be ignored, defaults to false and generally you want to leave it that way
OptionalreplaceOldValues: booleanif true, the old values of the fields will be reset to the values provided in the other parameter, if false, they will be left alone, defaults to false and generally you want to leave it that way
Refuses deletion of any MJ: Users row by a non-Owner caller. See invariant 5 above for why:
MJ deactivates users via IsActive rather than deleting them, and an unguarded delete would
let a non-Owner remove any account, including Owner accounts this class's other exemptions
depend on.
Reports the refusal the way MJListEntityServer.Delete does for its own row-level DELETE
authorization check — a BaseEntityResult on the result history so LatestResult.CompleteMessage
carries the reason (Delete() returns false on a logical rejection rather than throwing; see
the CLAUDE.md Save/Delete error-handling contract) — rather than MJUserRoutineEntityServer's
Delete() override, which is FK-cleanup ordering, not an authorization decision, and reports
nothing beyond a bare false.
Optionaloptions: EntityDeleteOptionsProtectedEmbedIn the BaseEntity class this method is not implemented. This method shoudl be implemented only in server-side sub-classes only by calling AIEngine or other methods to generate embeddings for a given piece of text provided. Subclasses that override this method to implement embedding support should also override
ProtectedEnforceEnforces disjoint subtype constraint during IS-A child entity creation. A parent record can only be ONE child type at a time. Checks all sibling child types (excluding self) for records with the same PK value. Throws if a sibling child record is found.
Only called when the parent entity has AllowMultipleSubtypes = false (default).
When AllowMultipleSubtypes = true, this check is skipped entirely, allowing
overlapping subtypes (e.g., a Person can be both a Member and a Volunteer).
Only runs on Database providers — client-side (Network/GraphQL) skips this because the server-side save will perform the check authoritatively.
Returns a promise that resolves when the current Delete operation completes. If no Delete operation is in progress, resolves immediately.
This is useful when you need to ensure a record is deleted before performing cleanup operations or navigating away from a view.
Returns a promise that resolves when the current Load operation completes. If no Load operation is in progress, resolves immediately.
This is useful when you need to ensure data is loaded before accessing entity properties or performing operations that depend on loaded data.
Returns a promise that resolves when the current Save operation completes. If no Save operation is in progress, resolves immediately.
This is useful when you need to ensure data is persisted before performing a dependent operation, or when coordinating between multiple components that might trigger saves.
ProtectedGenerateGenerates a vector embedding for a single text field using AI engine. Only generates embeddings for new records or when the source field has changed. Stores both the vector embedding and the model ID used to generate it.
The EntityField containing the text to embed
The EntityField to store the generated vector embedding (as JSON string)
The EntityField to store the ID of the AI model used
Promise that resolves to true if embedding was generated successfully, false otherwise
ProtectedGenerateGenerates a vector embedding for a single text field identified by field name. Retrieves the field objects and delegates to GenerateEmbedding method.
Name of the text field to generate embedding from
Name of the field to store the vector embedding
Name of the field to store the model ID used for embedding
Promise that resolves to true if embedding was generated successfully, false otherwise
ProtectedGenerateGenerates vector embeddings for multiple text fields using EntityField objects. Processes fields in parallel for better performance.
Array of field configurations with EntityField objects for source, vector, and model fields
Promise that resolves to true if all embeddings were generated successfully, false if any failed
ProtectedGenerateGenerates vector embeddings for multiple text fields by their field names. Processes fields in parallel for better performance.
Array of field configurations specifying source text field, target vector field, and model ID field names
Promise that resolves to true if all embeddings were generated successfully, false if any failed
Returns the value of the field with the given name. If the field is a date, and the value is a string, it will be converted to a date object.
For IS-A child entities, parent fields return the authoritative value from _parentEntity.Get()
(recursive for N-level chains), NOT the mirrored value on the child's own Fields array.
NOTE: Do not call this method directly. Use the To method instead
Utility method to create an object and return it with properties in the newly created and returned object for each field in the entity object. This is useful for scenarios where you need to be able to persist the data in a format to send to a network call, save to a file or database, etc. This method will return an object with properties that match the field names of the entity object.
OptionaloldValues: booleanWhen set to true, the old values of the fields will be returned instead of the current values.
OptionalonlyDirtyFields: booleanWhen set to true, only the fields that are dirty will be returned.
Returns a partial object that contains only the fields that have changed since the last time the record was saved. This is useful for scenarios where you want to send only the changes to the server or to a client. It is also helpful for quickly finding the fields that are "dirty".
This utility method generates a completely new object that has properties that map to the fields and values in the entity at the time it is called. It is a copy, NOT a link, so any changes made to the object after calling this method will NOT be reflected in the object that is returned. This is useful for things like sending data to a client, or for use in a view model.
Optionalparams: DataObjectParamsThis utility method calls GetDataObject() internally and formats the result as a JSON string. If you want to get the data as an object instead of a string, call GetDataObject() directly.
Optionalparams: DataObjectParamsOptionalminifyJSON: booleanConvenience method to access a field by code name. This method is case-insensitive and will return null if the field is not found.
Convenience method to access a field by name. This method is case-insensitive and will return null if the field is not found. You can do the same thing with more fine tune controlled by accessing the Fields property directly.
Utility method to return the Name of the record (the value of the column that comes back from EntityInfo.NameField) from the current object. This avoids needing a network round trip to get the record name whenever we have the object already loaded in memory.
Optionalfilter: stringOptionalmaxRecords: numberOptionalfilter: stringOptionalmaxRecords: numberResets this entity to a pristine state and populates it from the provided data object.
Unlike SetMany, which incrementally updates existing field values, Hydrate()
first resets ALL internal state — fields, composite key cache, loaded/saved flags —
then populates from the provided data as if loading a fresh record from the database.
This is critical for IS-A (table-per-type) inheritance: when a child entity loads
its record, parent entities in the chain must be fully reset and re-populated from
the child's view data, including the shared primary key. After init(), each
EntityField's _NeverSet flag is true, allowing even ReadOnly PK fields to be
set exactly once via SetMany.
After population, entities are automatically marked as saved/loaded when all PK values are present (via UpdateSavedStateFromPrimaryKeys).
The parent chain is handled recursively: if this entity has an IS-A parent, the parent is hydrated first (deepest ancestor first) via SetMany's built-in routing.
A plain object whose properties map to field names on this entity (and potentially parent entities in the IS-A chain).
ProtectedInitializeDiscovers and initializes the IS-A child entity for a loaded record.
After a record is loaded, this method checks whether a more-derived child entity record exists with the same primary key. If found, it creates the child entity instance, shares the current instance chain (so child._parentEntity === this), and recursively discovers further children down the hierarchy.
This ensures that Save/Delete operations always delegate to the leaf entity, running the full validation and event chain at every level.
Must be called AFTER a record is loaded (PK must be available). Skipped for entities that are not parent types or have already been discovered.
Initializes the IS-A parent entity composition chain. For child type entities, this creates the parent entity instance (and recursively its parent, etc.) and caches the parent field name set for routing.
Must be called AFTER EntityInfo is available but BEFORE any Load/NewRecord/Set/Get. This is called by Metadata.GetEntityObject() after constructing the entity.
Wrapper that holds an array of objects that contain the field name and value for the primary key of the record you want to load. For example, if you have a table called "Customers" with a primary key of "ID", you would pass in an array with a single object like this: {FieldName: "ID", Value: 1234}. *If you had a composite primary key, you would pass in an array with multiple objects, one for each field in the primary key. You may ONLY pass in the primary key fields, no other fields are allowed.
OptionalEntityRelationshipsToLoad: string[]Optional, you can specify the names of the relationships to load up. This is an expensive operation as it loads up an array of the related entity objects for the main record, so use it sparingly.
true if success, false otherwise
Loads entity data from a plain object, typically from database query results.
This method is meant to be used only in situations where you are sure that the data you are loading is current in the database. MAKE SURE YOU ARE PASSING IN ALL FIELDS. The Dirty flags and other internal state will assume what is loading from the data parameter you pass in is equivalent to what is in the database.
A simple object that has properties that match the field names of the entity object
Optional_replaceOldValues: booleanPromise
Generally speaking, you should use Load() instead of this method. The main use cases where this makes sense are:
Important for Subclasses: As of v2.53.0, this method is now async to support subclasses that need to perform additional asynchronous loading operations (e.g., loading related data, fetching additional metadata).
Subclasses that need to perform additional loading should override BOTH this method AND Load() to ensure consistent behavior regardless of how the entity is populated. This is because these two methods have different execution paths:
// Subclass implementation
public override async LoadFromData(data: any, replaceOldValues: boolean = false): Promise<boolean> {
const result = await super.LoadFromData(data, replaceOldValues);
if (result) {
// Perform additional async loading here
await this.LoadRelatedData();
await this.LoadMetadata();
}
return result;
}
// Don't forget to also override Load() for consistency, unless you INTEND to have different behavior
// for Load() vs LoadFromData()
public override async Load(ID: string, EntityRelationshipsToLoad: string[] = null): Promise<boolean> {
const result = await super.Load(ID, EntityRelationshipsToLoad);
if (result) {
// Same additional loading as in LoadFromData
await this.LoadRelatedData();
await this.LoadMetadata();
}
return result;
}
This method will create a new state for the object that is equivalent to a new record including default values.
OptionalnewValues: FieldValueCollectionoptional parameter to set the values of the fields to something other than the default values. The expected parameter is an object that has properties that map to field names in this entity. This is the same as creating a NewRecord and then using SetMany(), but it is a convenience/helper approach.
ProtectedPropagatePropagates the ProviderTransaction handle down the IS-A parent chain so all entities in the chain execute on the same database transaction.
ProtectedRaiseUsed for raising events within the BaseEntity and can be used by sub-classes to raise events that are specific to the entity.
OptionalsaveSubType: "create" | "update"Raises the transaction_ready event. This is used to indicate that the entity object is ready to be submitted for transaction processing. This is used by the TransactionGroup class to know when all async preprocessing is done and it can submit the transaction. This is an internal method and shouldn't be used by sub-classes or external callers in most cases. It is primarily used by Provider classes who are handling the tier-specific processing for the entity object.
Re-fetches the current record from the database using its existing primary key, replacing all in-memory field values with the latest data from the database. This is useful when you know (or suspect) the record has been modified externally (e.g., by a trigger, another user, or a background process) and you want to bring the entity object up to date.
true if the record was successfully reloaded, false if the provider returned no data.
Refresh() on a new, unsaved entity will throw because the primary key is not yet valid.Dirty === false.InnerLoad(this.PrimaryKey).This method can be used to register a callback for events that will be raised by the instance of the BaseEntity object. The callback will be called with a BaseEntityEvent object that contains the type of event and any payload that is associated with the event. Subclasses of the BaseEntity can define their own event types and payloads as needed.
Append a result to _resultHistory, trimming the oldest entries when over
MAX_RESULT_HISTORY. All Save/Delete code paths route through this — both inside
BaseEntity and in callers like databaseProviderBase and entity subclasses that
record their own results.
If the entity object has a TransactionGroup associated with it, the TransactionGroup will be notified that we are doing some transaction pre-processing so that the TransactionGroup can properly wait for those pre-processing steps to complete before submitting the transaction. This method should generally NOT be called by anyone other than a provider that is handling the tier-specific processing for the entity object.
Resets the vector embeddings for this entity to an empty state.
This method will revert the internal state of the object back to what it was when it was last saved, or if never saved, from when it was intially loaded from the database. This is useful if you want to offer a user an "undo" type of feature in a UI.
Refuses a ReplayOnly save by a non-Owner caller.
WHY THIS OVERRIDE EXISTS. Invariants 1-4 are enforced in Validate(), and Validate() is the
one enforcement point BaseEntity.Save() can be told to skip: under EntitySaveOptions.ReplayOnly
it force-passes validation WITHOUT calling Validate() at all (baseEntity.ts:3725), and
ReplayOnly does NOT suppress the write — the provider only uses it to bypass the
AllowUpdateAPI/AllowCreateAPI gates before building and executing the SQL
(databaseProviderBase.ts:1436-1443). So a ReplayOnly save skipped all four Save-side
invariants while invariant 5 stayed enforced, because Delete() is an override and an
override cannot be switched off. This restores the symmetry: now neither half depends on the
caller's options.
NOT CURRENTLY REACHABLE BY AN UNTRUSTED CALLER, and this is deliberately belt-and-braces
rather than a live hole. Every wire path that accepts ReplayOnly was enumerated: the
GraphQL options___/DeleteOptionsInput input exists only on the DELETE mutation (create and
update carry no options input, and ResolverBase.CreateRecord/UpdateRecord call Save()
with none); REST's EntityCRUDHandler does accept it, but calls entity.Validate()
explicitly before Save(), so the guard still runs there; and graphQLSystemUserClient
requires the system API key, i.e. a caller who is already superuser. The point is that the
class docstring's "holds on EVERY write path" is a promise a future wire path forwarding save
options would otherwise quietly break — a guard whose protection is one option away from off
is not the guard this file claims to be.
WHY REFUSE RATHER THAN RE-RUN THE INVARIANTS. Re-running invariants 1-4 here would duplicate
Validate()'s logic in a second place that must then be kept in step with it — the exact
duplicated-decision this repo's design rules call out. Refusing outright is smaller and
strictly safer: ReplayOnly is a replication/replay facility for trusted sync paths, and a
non-Owner has no legitimate reason to replay writes onto the user table. Owners are exempt,
so replication and admin paths that run as an Owner are unaffected.
Optionaloptions: EntitySaveOptionsSets the value of a given field. If the field doesn't exist, nothing happens. The field's type is used to convert the value to the appropriate type.
For IS-A child entities, parent fields are routed to _parentEntity.Set() (recursive
for N-level chains). The value is also mirrored on the child's own virtual EntityField
so that code iterating entity.Fields still sees it. The authoritative state for parent
fields lives on _parentEntity.
NOTE: Do not call this method directly. Use the From method instead
Sets any number of values on the entity object from the object passed in. The properties of the object being passed in must either match the field name (in most cases) or the CodeName (which is only different from field name if field name has spaces in it)
For IS-A child entities, all fields are first set on self (including parent fields as mirrors),
then parent fields are extracted and forwarded to _parentEntity.SetMany() for authoritative
state, including proper OldValue tracking via the replaceOldValues parameter.
OptionalignoreNonExistentFields: booleanif set to true, fields that don't exist on the entity object will be ignored, if false, an error will be thrown if a field doesn't exist
OptionalreplaceOldValues: booleanif set to true, the old values of the fields will be reset to the values provided in the object parameter, if false, they will be left alone
OptionalignoreActiveStatusAssertions: booleanif set to true, the active status assertions for the fields will be ignored, if false, an error will be thrown if a field is not active. Defaults to false.
Marks the next Save() as a restore from a historical RecordChange row.
The provider will write a new RecordChange entry with Source='Restore',
RestoredFromID pointing at sourceChangeId, and RestoreReason set to
reason (or NULL). This produces an auditable lineage chain that the
timeline UI can render via the RestoredFromID foreign key.
The context is consumed exactly once per Save() and persists on the
entity until either (a) overwritten by a subsequent SetRestoreContext()
call or (b) explicitly cleared via ClearRestoreContext(). It is NOT
auto-cleared inside Save() because TransactionGroup execution is
deferred — see the comment on _restoreContext for details.
The ID of the historical RecordChange row whose state is being restored. Required; throws if empty.
Optionalreason: stringOptional user-entered explanation captured at restore time. Persisted to RecordChange.RestoreReason for audit purposes.
Specifies if the current object supports the
ProtectedThrowThis method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can override or wrap this base class method to add other logic for validation.
ValidationResult The validation result
Asynchronous validation method that can be overridden by subclasses to add custom async validation logic. This method is automatically called by Save() AFTER the synchronous Validate() passes.
IMPORTANT:
Subclasses should override this to add complex validations that require database queries or other async operations that cannot be performed in the synchronous Validate() method.
Promise
StaticGetStatic Utility method to get RecordChanges for a given entityName/KeyValuePair combination
Optionalprovider: IEntityDataProviderStaticResolveResolves the leaf (most-derived) entity type for a given parent entity record. Walks down the IS-A child hierarchy to find which child type a record belongs to. Returns the child entity name, or the parent's own name if no children exist. Useful for polymorphic operations where you have a parent record and need to know its actual leaf type.
The parent entity name
The primary key to look up
OptionalcontextUser: UserInfoOptional context user for server-side operations
Optionalprovider: IMetadataProviderThe leaf entity name and whether it was resolved to a child type
Server-side
MJ: Usersentity enforcing MJ's privilege-elevation invariant (issue #4260).WHY THIS EXISTS AT ALL.
User.Typeis the column every Owner check in the platform reads —SqlLoggingConfigResolver, magic-linkcanIssueInvites, the backup-system-user fallback in MJServer's bootstrap. Writing it is therefore equivalent to granting yourself the platform's superuser level. Nothing below this class stops that: on the baseline seed theDeveloperandIntegrationroles hold unfilteredCanCreate/CanUpdate/CanDeleteonMJ: Users(verified against a live database, not just the seed),AllowUpdateAPI/AllowDeleteAPIare true on the entity,AllowUpdateAPIis also true on both theTypeandNamefields,Namehas no unique index, and CodeGen has already issuedGRANT EXECUTEon the relevant stored procedures to those roles' DB roles.WHY NOT ROW-LEVEL SECURITY. MJ's only field-adjacent access control is
RowLevelSecurityFilter, and an "own row only" update filter does NOT close this: scoping the update to the caller's own row still permits setting one's OWNTypeto'Owner'. There is no per-role FIELD permission in MJ, and settingEntityField.AllowUpdateAPI = 0onTypewould block Owners too, breaking every legitimate admin path. An invariant in the save/delete path is the only mechanism that expresses the actual rule.WHY HERE RATHER THAN IN A RESOLVER.
Validate()runs insideBaseEntity.Save()and this class also overridesSave()andDelete(), so all five hold on every write path — GraphQL resolvers, Remote Operations, the Create/Update/Delete Record actions, metadata sync, one-off scripts — and for any role a deployment invents, not just the two seeded ones. A resolver-level check would cover one door in a building with several.The
Save()override is what makes that sentence literally true rather than nearly true.Validate()alone does NOT cover every write path:BaseEntity.Save()force-passes validation without callingValidate()whenEntitySaveOptions.ReplayOnlyis set (baseEntity.ts:3725), andReplayOnlydoes not suppress the write. Invariants 1-4 were therefore skippable by an option, while invariant 5 was not —Delete()being an override. SeeSave()below for the reachability analysis and why the fix refuses rather than re-validates.THE INVARIANTS, for a caller whose
Typeis not'Owner':MJ: Usersrow at all is refused. This is stricter than "may not create an Owner": there are FOUR automated creators of this entity —NewUserBase.createNewUser(MJServer/src/auth/newUsers.ts),MagicLinkService's provisioning path (MJServer/src/auth/magicLink/MagicLinkService.ts), andCreateNewUserBase.createNewUser(CodeGenLib/src/Misc/createNewUser.ts:33, a CLI provisioning tool that setsType='Owner'unconditionally at:39— already refused for a non-Owner caller before this round, regardless of the analysis below). The first two run asResolveConfiguredPrincipal(...), whose ladder is: rung 1 matches the configured string againstUser.Name, rung 2 againstUser.Email— NEITHER rung filters byType(MJServer/src/auth/principals.ts:134,141) — and only rungs 3-4 (System-by-ID, then lowest-ID-among-ACTIVE-Owners) guarantee an Owner. So "no legitimate path creates a user row as a non-Owner" is NOT unconditional: it holds only while a deployment'scontextUserForNewUserCreation/contextUserForProvisioningnames an Owner-type user'sNameorEmail(the shipped default,not.set@nowhere.com, resolves by Email to the seeded Owner, so default installs are unaffected). A deployment that instead points either setting at a non-Owner user will have JWT auto-provisioning and magic-link provisioning fail CLOSED atSave()after this change — loudly, not silently — rather than continuing to create rows as that non-Owner; see the changeset for the upgrade note. Separately, Explorer's user-management UI has NO Owner gate today (no such guard exists inuser-management.component.tsor its module), so a Developer-role non-Owner reaches it in practice and will now receive this same create/delete refusal there — a real consequence for such deployments, not a hypothetical one. A FOURTH creator exists and is not config-driven:SyncRolesUsersResolver.AddNewUsers(MJServer/src/resolvers/SyncRolesUsersResolver.ts:343), whose siblingUpdateExistingUsersalso setsNameANDTypeunconditionally on every synced row and whoseDeleteSingleUserdeletes. All three carry@RequireSystemUser(), andgetSystemUser()resolves the seededType='Owner'system user, so the guard exempts them on a default install — but a deployment whose system user is NOT an Owner will see that sync path fail closed too, for the same reason as the config-driven pair above. Note also thatDeleteSingleUserreads afalsefromDelete()as an FK-constraint condition and downgrades to a soft delete; invariant 5 gives that samefalsea second meaning (non-Owner caller), which that call site does not distinguish. Refusing onlyType='Owner'on create is NOT enough on its own:Namehas no unique index and both seeded non-Owner roles holdCanCreate, so a non-Owner could otherwise repeatedlyCreaterows named to match the configured principal string until one sorts below the real system user by ID — the exact principal-redirection invariant 4 (below) exists to prevent, just reached through INSERT instead of UPDATE.Typemay not be changed on an EXISTING row. It is a two-value CHECK column ('User' | 'Owner'), so any change by a non-Owner is either self-promotion or demoting somebody else. (Creation is already covered by invariant 1, so this only needs to consider updates.)ID.IDis a primary-key field —EntityFieldInfo.ReadOnlyistrueforIsPrimaryKey, andEntityField.Value's setter silently ignores writes to aReadOnlyfield after the record's initial hydration — so on a genuinely LOADED rowthis.IDis not reassignable through ordinary means. The pre-save value still matters for a narrower reason: it ties this guard to the row identity established the last time the object was actually loaded/hydrated from the database, which is whatResolverBase.UpdateRecorddoes first for any entity withTrackRecordChanges=1(asMJ: Usershas) — rather than trusting whatever the in-memory object merely arrived holding. If that pre-save identity cannot be established at all, the guard fails CLOSED (refuses the save) instead of silently permitting it — seevalidateOwnRowOnly.Namemay not be changed on an existing row. Invariants 2-3 do NOT cover this — renaming yourself is editing your own row.resolvePrincipalFrom(MJServerauth/principals.ts) resolvescontextUserForNewUserCreation/contextUserForProvisioning/contextUserForLookupagainstUser.NameFIRST, breaking ties by lowest ID, so a user who can rename themselves to the configured string and who sorts below the real system user becomes the principal the server acts as. That ordering is deliberate (backward compatibility) and is only sound whileNameis not writable by untrusted parties; this invariant is what makes that true.Nameis the login identifier — auto-provisioning setsName = email— whileFirstName/LastName/Titleare the display fields and stay freely editable.Emailis the ladder's OTHER rung (principals.ts:141) with the IDENTICAL lack of aTypefilter, and is deliberately left mutable here — not overlooked. Two things narrow it: (a)MJ: Users.Emailcarries the database'sUQ_User_Emailunique constraint, so redirecting the Email rung to your own row requires the configured candidate to match NO active user at all — already the misconfiguration case this file's docs (andresolvePrincipalFrom's own per-redeem logging) already surface loudly, not a quiet success path; and (b) even granting that misconfiguration, invariants 1 and 3 mean the payoff is no longer elevation: whatever row a provisioning path resolves its principal to, THIS guard still checks that resolved principal's actualTypebefore permitting the write it is attempting, so a non-Owner who gets themselves matched by the Email rung still cannot create or promote anything through it — the provisioning operation that would have run as them instead fails CLOSED. FreezingEmailtoo would add friction to an already-narrow, already-loud misconfiguration path without closing any route that is still open.MJ: Usersrow at all is refused (see theDelete()override below). MJ deactivates users viaIsActive; it does not delete them. An unguarded delete would let a non-Owner remove ANY account — Owners included, which destroys the very accounts every exemption above depends on.Owner-type callers are exempt from all five — CONDITIONALLY on the deployment's own configuration keeping
contextUserForNewUserCreation/contextUserForProvisioningpointed at an Owner (see invariant 1 above for why that is not automatic). Provided it is, this keeps admin user management working, and it keeps auto-provisioning working —NewUserBase.createNewUserruns ascontextUserForNewUserCreation, which resolves to the seeded system user (Type='Owner') under the shipped default. A caller-less save (noActiveUserat all — e.g. a system/CLI path running under a bound provider default) is likewise treated as exempt, though forSave()this is effectively decorative:BaseEntity.CheckPermissionsalready throws on a falsyActiveUserand runs BEFOREValidate()insideSave(), so in production a caller-lessSave()never reaches this guard'sValidate()body at all — see the "no caller" test for the exact call ordering.Delete()'s sequencing is the OPPOSITE and the "no caller ⇒ exempt" default IS load-bearing there — seecallerIsOwner()'s docstring.Pure: reads only this record's own field state and the caller. No
RunView, no provider, no engine, no I/O — so it costs nothing per save/delete and is unit-testable without a database.