ZuloOne
English
Sign in
Menu

👤 Who wrote this row: `ICurrentUser` and the author columns

Updated

← Developer wiki

Every generated business table carries CreatedDateTime / ModifiedDateTime and answers by whom: ICurrentUser is the one place the platform knows who is writing, and the CreatedByMetaId / ModifiedByMetaId columns record it.

Why the audit log is not the author column#

The reasonable objection is "we have MetaAuditLog, read it from there". Four facts rule that out:

MetaAuditLog.RecordId is int? A business row is addressed by Guid. The table structurally cannot point at a dictionary record.
DataController never calls the audit Ordinary dictionary/document CRUD is not logged at all. Only posting, metadata, security, licensing, accounting periods, AI and data-wipe write entries.
PostingAuditService.WriteAsync swallows exceptions By design — an audit failure must not fail the business operation. So the log is not guaranteed either.
Row-level security RowSecurityService already substitutes @currentUserId into predicates, and without an author column there is nothing to compare it to. "See only your own records" is inexpressible.

MetaRecordHistory journals field-level diffs only for tables and fields marked isLogged, configured on Record logging (/record-logging, Administrator). The writer still runs on the DataController HTTP path only, and the table is purgeable — a retention sweep takes the answer with it.

Part 1 — ICurrentUser#

ZuloOne.Shared/Services/ICurrentUser.cs:

public interface ICurrentUser
{
    Guid? UserMetaId { get; }
    string? UserName { get; }
    bool IsAuthenticated { get; }
}

It lives in ZuloOne.Shared, not ZuloOne.Core, because ZuloOne.Runtime (which hosts scripts) references Shared and cannot see Core.

It is registered as a singleton, and that is not a habit. ScriptServices.Get<T>() resolves from the root provider. A scoped registration would throw there — the same trap as script service scope lifetime. CurrentUser therefore depends only on IHttpContextAccessor, itself a singleton, and reads the principal per call.

Resolution order, in exactly this order:

  1. the ambient CurrentUserScope override;
  2. IHttpContextAccessor.HttpContext?.User claims;
  3. null.

CurrentUserScope — the ambient override#

AsyncLocal-based, modelled on DocumentWriteScope:

using (CurrentUserScope.Begin(userMetaId, userName))
{
    // everything written in here is attributed to that user
}

MetaJob.RunAsUserMetaId means the same on both paths: the remote agent (AgentJobDispatcher.ResolvePrincipalAsync) and in-process jobs (JobSchedulerService wraps the run in CurrentUserScope.Begin(job.RunAsUserMetaId)).

What deliberately did NOT move to ICurrentUser#

  • RowSecurityService / WorkingModelGuard. There, "no caller ⇒ no row restriction" is a deliberate security decision. Routing it through ICurrentUser would silently switch RLS on for background jobs that run unrestricted today. ICurrentUser is a pure accessor and carries no policy.
  • AccountingPeriodService keeps IHttpContextAccessor as well — it reads roles from it. ICurrentUser answers who, not what they may do.
  • AssistantAuditService takes its principal as an explicit parameter by design, documented on the class: the caller already holds one, and threading it keeps the service honest outside a request. Converting it would be a regression, not a cleanup.

From a script#

EventContext and CommandContext expose it directly:

var who = context.CurrentUserId;   // Guid?

ICurrentUser is not in ScriptServices.Denied (which holds only IOutboundTransport and ICredentialResolver): it hands out neither a secret nor a wire.

Part 2 — the columns#

CreatedByMetaId / ModifiedByMetaId, UNIQUEIDENTIFIER, nullable, with no physical FK to MetaUsers — otherwise deleting a user would collide with business data years later.

NULL is an honest value: that is what a row written by a job, an import, an inbound integration or a test looks like, and what every pre-existing row looks like. Writing "system" there is not an option — afterwards you cannot tell it from a real user.

Coverage#

The invariant: where there is CreatedDateTime, there is CreatedByMetaId; where there is ModifiedDateTime, there is ModifiedByMetaId.

Table shape CreatedByMetaId ModifiedByMetaId Filled by
Dictionary ✅ ✅ DataService
Document header ✅ ✅ DataService
TP_ (table part) ✅ ✅ DataService
LT_ (link table) ✅ ✅ DataService
TB_ (register balance) ✅ ✅ engine
TR_ (register movements) ✅ — (no ModifiedDateTime) nobody — always NULL
TL_ / TC_ (FIFO engine) — — —
Meta*, DocumentLinks (EF-owned) — — —

TR_ carries the column unfilled on purpose: movements are written and deleted but never updated, the author of the posting already sits in PostingAuditService, and the header of the document that produced the row now carries an author. The column exists so the invariant has no exceptions on a table shape that has an alteration path — see below.

Two documented exceptions#

TL_ / TC_ (FIFO layers and consumption links). These are built whole when missing and have no alteration path — DetectRegisterTableAlterationsAsync constructs TableToAlter for TR_ and TB_ only. Adding the column at the creation site would give a new stand a column an existing stand never receives, for a value nobody writes: those rows are INSERTed by raw SQL in RegisterMovementService, never through DataService. So they stay out.

EF-owned tables. /api/data refuses them (404): IsPlatformTable is built from the EF model, so GET/PUT /api/data/MetaUsers never reaches UpdateAsync. HasAuthorColumns uses the same set — a write that somehow got through would still not stamp columns those tables do not have:

foreach (var entity in db.Model.GetEntityTypes())
    owned.Add(entity.GetTableName());

cached once in a static volatile HashSet<string>. Note what it does not do: test for a "Meta" name prefix. A business dictionary may legitimately be called MetaSomething, and DataWipePlan documents exactly that case as a conflict it must report rather than guess.

The trap: detectors do not add missing standard columns#

This is the half that is easy to get wrong. Detect*AlterationsAsync adds columns that came from metadata and FKs. A standard column that the synchronizer started emitting after a table was born is never added — the table simply stays old forever.

The same file already has the pattern: AnalyticSetMetaId ("Engine columns that appeared AFTER a register table was born must be added on sync") and ensureHeaderColumns. The author columns follow them: one helper,

EnsureAuthorColumns(TableToAlter alteration, IEnumerable<string> existing, bool withModified = true)

called from each Detect*AlterationsAsync. Without that call, an existing table never receives the columns.

A separate INFORMATION_SCHEMA sweep in the style of RenameLegacyAuditColumnsAsync is deliberately not used: it would reach the EF-owned Meta* tables and desynchronise them from the model snapshot.

New columns must also be added to every "standard column" set used by the drop pass — there are ten independent hardcoded sets — or the next sync will decide the columns are foreign and drop them.

Ordering is safe without any probing: SchemaSynchronizer.SyncAsync() runs at startup, before the app serves a request, so by the first insert the columns exist and the stamp is written unconditionally, exactly as the timestamps are. DataService must not probe the catalogue at write time — that is what the comment above AuditColumns is for (MSDTC promotion, and blocking on the caller's own uncommitted DDL).

Write rules#

Insert both columns ← ICurrentUser.UserMetaId
Update ModifiedByMetaId only
Update CreatedByMetaId is never touched — an author is set once, at birth, and someone else's edit must not rewrite it

The columns are the platform's, and must not ride a manager's write#

RuntimeTypeGenerator puts CreatedByMetaId and ModifiedByMetaId on the generated document header class, so a script can read them. Nothing ever reads them back onto a loaded entity, so in memory they are always null. That makes them exactly like Number and the timestamps: present on the type, owned by the platform, and poison if a manager includes them in the row it writes. DocumentManager.NonHeaderMembers therefore lists both. Leave them out, and two things break at once:

  • every update writes that null over the stored author, so the author survives birth and dies on the first edit;
  • doc.Subtype = x; SaveDocumentAsync(doc) — the standard posting idiom — grows from a one-column diff to a three-column one, so the save is no longer "only the subtype changed". The before-save handler then runs against a header with no DocumentDate, CustomerPaymentEventHandler falls back to today, finds no rate window and stamps the line rate 0, and posting refuses — a failure that reads nothing like "author tracking".

Dictionaries and table parts need no equivalent entry: those generated types do not carry the properties, so DictionaryManager.SystemMembers and the table-part set are already right.

In the UI#

Both columns are addable system columns, off by default — adding the group changes no open grid.

They are in LABELLED_SYSTEM alongside DocumentMetaId / OwnerMetaId, so the cell shows a name, not a GUID. DocumentsController.ResolveLabels looks up ids left over after documents → dictionaries → metadata in MetaUsers and returns them as kind: "User" with the display name. formatDocumentLabel already branches on kind !== 'Document' and takes text.

Ids reach the resolver as follows. StandardGrid derives the ids to resolve from LABELLED_SYSTEM itself, but a grid that builds its own system columns — the dictionary list (DictionaryDataWindow), the document card's table parts (RecordWindows) — passes its own id list to useDocumentLabelText. A hand-written list [r.DocumentMetaId, r.OwnerMetaId] therefore resolves some grids and renders a raw GUID in the rest, with no request even leaving the page. labelledSystemValues(row) in kit/systemColumns.ts derives every such list from the same set the columns come from. If you add a labelled system column, add it to LABELLED_SYSTEM and nothing else.

Only the display name is returned there, never email, roles or lock status: /api/security/users is gated behind System Read, and this path must not be a way around it.

buildSystemColumn puts the same projection into exportText, so filter, quick search, copy and export all read the name — see Grid columns.

Captions live in cols.sys.createdBy / cols.sys.modifiedBy in all four locales.

Out of scope#

  • RLS policy and RowSecurityService behaviour when there is no caller.
  • Backfilling the author of existing rows — there is nowhere to take it from.
  • Showing the author on the record card (a separate UI change).
  • Filling TR_.CreatedByMetaId.
  • MetaAuditLog retention and auditing ordinary CRUD in DataController.

Where next#

  • Record change history (MetaRecordHistory) — opt-in field diffs, /record-logging and /record-history, and how that differs from author columns and Audit.
  • Forms system — how a record card writes, and why a field hook's answer is read as a diff rather than applied whole.
  • Grid columns — the system-column group the two columns join.
  • Chain of command — the handler layering the stamp runs underneath.
Was this page helpful?