👤 Who wrote this row: `ICurrentUser` and the author columns
Updated
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:
- the ambient
CurrentUserScopeoverride; IHttpContextAccessor.HttpContext?.Userclaims;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 throughICurrentUserwould silently switch RLS on for background jobs that run unrestricted today.ICurrentUseris a pure accessor and carries no policy.AccountingPeriodServicekeepsIHttpContextAccessoras well — it reads roles from it.ICurrentUseranswers who, not what they may do.AssistantAuditServicetakes 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
nullover 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 noDocumentDate,CustomerPaymentEventHandlerfalls back to today, finds no rate window and stamps the line rate0, 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
RowSecurityServicebehaviour 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. MetaAuditLogretention and auditing ordinary CRUD inDataController.
Where next#
- Record change history (
MetaRecordHistory) — opt-in field diffs,/record-loggingand/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.