System Context (isSystem)
The authoritative table of every platform behaviour keyed off `ExecutionContext.isSystem` — what an elevated write gets, what it loses, and what the flag deliberately does NOT do. Built by census over the whole repo, not by recall.
ExecutionContext.isSystem is the platform's one elevation flag. Setting it on a
write or read means "this operation is the engine acting on its own behalf" —
the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.
This page is the authority for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at 108
distinct sites across 20 packages, and knowing three of those behaviours gives
no hint that the other hundred-and-four exist. Every documented app-side bug
traced to isSystem had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.
Elevation is total, and it is not granular. isSystem is not "skip the
permission check". It short-circuits authorization, ownership stamping,
read-only protection, referential-integrity checks, sharing materialisation,
approval locks and provenance stamping — in twenty packages that do not know
about each other. Read the table before you set it; prefer a scoped user
context whenever one exists.
Every anchor, and every count about the census population, is checked by CI.
scripts/check-system-context-census.mjs re-runs the AST census over the whole
repo on each PR and refuses the page when an anchor names a symbol its file no
longer declares, when a stated count about the population disagrees with the
census, or — the check that matters most — when the code holds an elevation read
that no row here anchors. Six raw text counts in
Maintaining this table are deliberately not
enforced, and say there when they were measured.
⚠️ What these anchors buy, and what they cost — read this before trusting a
row. Every anchor here is a path#symbol citation. Nothing encodes a
position, so an unrelated edit above a site cannot rot one, and a repair is
never mechanical: there are no numbers to renumber. The price is granularity.
Several reads inside one function collapse onto one anchor, so deleting a
whole symbol reds this page, and deleting one of several reads inside a symbol
that keeps at least one may not. The gap is real, it is measured below, and it
is left open deliberately rather than hidden — see
Maintaining this table.
Which isSystem this page is about
Four unrelated declarations share the identifier. This page documents only the first. The others are ordinary metadata fields on a stored document and have nothing to do with elevation.
| Declaration | What it is | This page? |
|---|---|---|
ExecutionContext.isSystem — packages/spec/src/kernel/execution-context.zod.ts#isSystem | The elevation flag on an operation's context | ✅ |
Object.isSystem — packages/spec/src/data/object.zod.ts#isSystem | Marks a system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set) | ❌ |
EmailTemplate.isSystem — packages/spec/src/system/email-template.zod.ts#isSystem | Built-in template; tenants may override but should not delete | ❌ |
Environment.isSystem — packages/spec/src/cloud/environment.zod.ts#isSystem | Platform-infrastructure environment, not user data | ❌ |
The collision is a genuine hazard rather than a naming nit: Object.isSystem
changes an object's default sharing, and ExecutionContext.isSystem changes
whether sharing grants are materialised — so a search for "isSystem sharing"
returns both, and they are unrelated decisions.
A fifth, closely-spelled family — isSystemObjectName() /
isSystemObject() in packages/runtime/src/action-execution.ts#isSystemObjectName,
packages/mcp/src/mcp-http-tools.ts#isSystemObject — keys on the sys_ name prefix,
not on any flag.
How the flag is set
isSystem is server-constructed and never client-supplied. Inbound HTTP
cannot set it (packages/rest/src/rest-server.ts#enforceAuth), and neither
can an action body (packages/runtime/src/domains/actions.ts#handleActionsRequest). It is
written by internal callers only, as an option on the engine call:
await engine.insert('crm_account', row, { context: { isSystem: true } });Its parse-time default is false (packages/spec/src/kernel/execution-context.zod.ts#isSystem), so an absent
context is never elevated.
The table
Grouped by lane. Every row cites the site that reads the flag. "What you lose" is the part that costs app-side bugs — it is the protection or the side effect that silently does not happen.
1. Authorization and scoping
| # | Behaviour when isSystem | Package | What you get / what you lose | Anchor |
|---|---|---|---|---|
| 1 | The whole security middleware short-circuits before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–7 at once — this is the single largest behaviour on the page | packages/plugins/plugin-security/src/security-plugin.ts#start |
| 2 | owner_id is not auto-stamped on INSERT (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands owner_id = NULL, so the default owner_only_writes policy hides it from its own creator. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside packages/plugins/plugin-security/src/security-plugin.ts#start |
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter |
| 4 | Field-level security returns all fields | plugin-security | Get: every column readable. Lose: field masking | packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields |
| 5 | Export permission granted unconditionally | plugin-security | Get: canExport is true | packages/plugins/plugin-security/src/security-plugin.ts#canExport |
| 6 | Object-level read admission granted unconditionally | plugin-security | Get: canReadObject is true. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; getReadFilter is its row-level half, and the two are not interchangeable | packages/plugins/plugin-security/src/security-plugin.ts#canReadObject |
| 7 | Write bypass = true, effective write scope = org | plugin-security | Get: widest write scope without holding any capability | packages/plugins/plugin-security/src/security-plugin.ts#start |
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a caller property — it short-circuits before the security service is consulted | packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt |
| 9 | explain() may target a principal other than the caller | plugin-security | Get: no manage_users / delegated-admin check | packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller |
| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no userId | packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous |
| 11 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough |
| 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | packages/plugins/plugin-auth/src/auth-plugin.ts#start |
| 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal |
| 14 | Permission-set overlay discard skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | packages/plugins/plugin-security/src/permission-set-overlay-discard.ts#assertTenantAdmin |
| 15 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose apiEnabled / apiMethods would refuse an external caller | packages/mcp/src/stdio-data-bridge.ts#enforceApiExposure |
| 16 | Read-audit rows are not written | plugin-audit | Lose: the "a person opened this record" trail. sudo() keeps the caller's userId, so this flag is the only thing separating a human read from a platform one | packages/plugins/plugin-audit/src/read-audit.ts#installReadAuditWriter |
| 17 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on find / findOne — the audit/replay channel. Lose: field-visibility redaction over approval payloads | packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware |
| 18 | REST anonymous-deny seam satisfied | rest | Get: enforceAuth passes with no userId. Not reachable from the wire — isSystem is never set on an inbound request | packages/rest/src/rest-server.ts#enforceAuth |
2. Write pipeline and data integrity
| # | Behaviour when isSystem | Package | What you get / what you lose | Anchor |
|---|---|---|---|---|
| 19 | readonly strip bypassed — UPDATE, single row | objectql | Get: a readonly field CAN be written. Lose: the protection that stops a caller seeding e.g. approval_status | packages/objectql/src/engine.ts#update |
| 20 | readonly strip bypassed — UPDATE, bulk/predicate | objectql | Same, on the multi-row path | packages/objectql/src/engine.ts#update |
| 21 | readonly strip bypassed — INSERT | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-readonly strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. isSystem is the only exemption on this path: preserveAudit is deliberately not read on create, so a non-system historical import is still stripped | packages/objectql/src/engine.ts#insert |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets silence — strict refuses exactly what the strip would have taken, and the strip took nothing | packages/objectql/src/engine.ts#insert, packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS |
| 23 | Referential-integrity check skipped | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an isSystem caller can write a dangling reference | packages/objectql/src/engine.ts#assertReferencesResolve |
| 24 | Tenant-audit warning silenced; bypassTenantAudit threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | packages/objectql/src/engine.ts#buildDriverOptions |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to managedBy engine-owned objects | packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite, #assertEngineOwnedWriteAllowed |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite |
| 27 | Search-companion column kept in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | packages/objectql/src/engine.ts#stripSearchCompanionFromRead |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | packages/objectql/src/engine.ts#dependentCountIsDisclosable |
| 29 | Reference-cleanup log attributes the write to 'system' | objectql | Get: an honest actor label instead of anonymous when the context carries neither userId nor actor | packages/objectql/src/engine.ts#recordReferenceCheckElevation |
| 30 | Bulk data event organizationId OMITTED — the batch is published "not asserted" | plugin-security | Get: nothing — the data.records.* event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (OperationContext.tenantLayer0Verdict, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's tenantId; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | packages/plugins/plugin-security/src/security-plugin.ts#start |
3. Sharing (plugin-sharing)
The largest single consumer — 17 of the 108 sites.
| # | Behaviour when isSystem | What you get / what you lose | Anchor |
|---|---|---|---|
| 31 | Sharing-rule REVOCATION is skipped on the record-afterDelete hook — and on that hook only | Lose: nothing permanently — the revoke is delivered, but deferred on the unbounded shape. The payload belongs to another subscriber: packages/plugins/plugin-sharing/src/record-share-cascade.ts binds on every sharing-capable object and stashes for system writes on its own account (#5103). When the deleted ids are enumerable it revokes inline; when they are not — a predicate delete whose row set the stash could not resolve — it hands the reclaim to a queued background orphan sweep instead, so the share rows outlive the deleted records until that sweep runs, with the boot orphan sweep behind it. No surviving record loses access either way, and a restart re-runs the same sweep. This is one subscriber declining work another owns, not elevation silencing a consequence. ⚠️ Grant MATERIALISATION no longer asks — the afterInsert / afterUpdate skips, and the before* stash skip that fed them, were removed by the 2026-08-31 ruling on #13533; a system write materialises exactly as a user write does | packages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleHooks |
| 32 | Sharing write verdict short-circuits to allow | Get: writes pass the sharing gate unconditionally | packages/plugins/plugin-sharing/src/sharing-service.ts#bypassVerdict |
| 33 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | packages/plugins/plugin-sharing/src/sharing-service.ts#canManageShares, #assertCanManageShares, #shouldBypass |
| 34 | grant() skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is not a bare skip: the system branch asserts the grant is not inert instead (a grant on an object no verdict can consult is refused) | packages/plugins/plugin-sharing/src/sharing-service.ts#grant |
| 35 | revoke() deletes directly, before the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the CONFLICT guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | packages/plugins/plugin-sharing/src/sharing-service.ts#revoke (the guard it deletes in front of is in the same function) |
| 36 | listShares() skips the management gate | Get: full enumeration of who can see a record | packages/plugins/plugin-sharing/src/sharing-service.ts#listShares |
| 37 | sys_record_share reads are not self-scoped | Get: tenant-wide share listing without manage_sharing | packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware |
| 38 | Share-link policy enabled check bypassed; system callers re-enter under a system context | Get: link creation while the policy is off — resolution is not bypassed since #14033 (publicSharing.enabled is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | packages/plugins/plugin-sharing/src/share-link-service.ts#createLink, #revokeLink, #listLinks |
| 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / defineRule / boot reconcilers are "the package door" | packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp |
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules, #assertCanDeletePlatformGlobalRule |
4. Approvals, reports, attachments, comments, knowledge
| # | Behaviour when isSystem | Package | What you get / what you lose | Anchor |
|---|---|---|---|---|
| 41 | Approval record lock released — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately no admin exemption here — only isSystem | packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindApprovalLockHook |
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard |
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor, #resolveActor, #sendBack, #resubmit, #reassign, #remind, #requestInfo, #comment |
| 44 | Saved-report ownership is assignable, and an update may reassign it | plugin-reports | Get: ownerId from input is honoured. A non-system caller always owns what it creates and can never reassign | packages/plugins/plugin-reports/src/report-service.ts#saveReport |
| 45 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | packages/plugins/plugin-reports/src/report-service.ts#assertExportAllowed, #canAccessReport, #listReports, #listSchedules |
| 46 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks, #installAttachmentReadVisibility |
| 47 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks, #installCommentReadVisibility |
| 48 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter |
5. Actions, metadata plane, provenance, the organization wall
| # | Behaviour when isSystem | Package | What you get / what you lose | Anchor |
|---|---|---|---|---|
| 49 | Object API-exposure gate bypassed (apiEnabled / apiMethods) | runtime | Get: internal self-writes ignore exposure declarations — these govern external exposure, not engine self-writes | packages/runtime/src/action-execution.ts#callData |
| 50 | Action requiredPermissions bypassed | runtime | Get: engine self-invocation runs any action | packages/runtime/src/action-execution.ts#actionPermissionError |
| 51 | manage_metadata bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | packages/runtime/src/domains/meta.ts#handleMetadataRequest, packages/rest/src/rest-server.ts#registerMetadataEndpointsInner |
| 52 | The shared metadata-write verdict itself returns allowed | metadata-core | Get: the one function all of row 51's doors consult answers yes before any capability is examined | packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict |
| 53 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no userId | packages/runtime/src/domains/actions.ts#handleActionsRequest, packages/runtime/src/domains/ai.ts#handleAIRequest, packages/runtime/src/domains/automation.ts#handleAutomationRequest, packages/runtime/src/domains/meta.ts#handleMetadataRequest, packages/runtime/src/domains/security.ts#handleSecurityRequest, packages/runtime/src/domains/packages.ts#handlePackagesRequest, packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes, packages/rest/src/package-routes.ts#refusePackageRequest |
| 54 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | packages/runtime/src/domains/mcp.ts#handleMcpRequest |
| 55 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (POST /packages/publish, the one route the REST registrar mounts since #14503) without manage_metadata; the package read cohort (studio.access / setup.access) is enforced by the dispatcher /packages domain's own read gate, where the reads are served | packages/rest/src/package-routes.ts#refusePackageRequest |
| 56 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | packages/runtime/src/domains/packages.ts#requireManageMetadata, #requireReadCapability |
| 57 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite, #refuseUngrantedActivationAuthoring |
| 58 | Automation run-state read, flow-authoring write, unrelated-screen read and the two operator run-lifecycle writes all pass | runtime | Get: run state, flow writes, screen reads with no grant — and cancelling or restoring a suspension without the platform-operator rung. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded | packages/runtime/src/domains/automation.ts#mayReadRunState, #refuseUngrantedFlowWrite, #refuseUnrelatedScreenRead, #refuseUngrantedRunLifecycleWrite |
| 59 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin |
| 60 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | packages/plugins/plugin-email/src/email-template-provenance.ts#bindEmailTemplateProvenanceStamp, packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp |
| 61 | Automation flow data nodes re-add the owner_id stamp (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner, called from packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes |
| 62 | Inbox caller refusal names isSystem as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient |
| 63 | organization_id is not auto-stamped on INSERT — the organization-axis twin of the owner_id gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the authoritative stamp, so an elevated insert that names no organization lands organization_id = NULL and the wall hides it. ⛔ This is why a forged organization_id is overwritten on the non-elevated path and not here: elevation is the seam the legitimate cross-organization writers use | packages/plugins/organizations/src/organizations-plugin.ts#start |
6. Reads that only carry the flag onward
Not behaviours: these four sites read the flag solely to copy it into another context or envelope. They are listed because the census counts them, and because a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|---|---|---|---|
| 64 | packages/objectql/src/engine.ts#buildSession | objectql | Propagates isSystem into the hook session so hooks can tell engine self-writes from user writes |
| 65 | packages/objectql/src/engine.ts#isSystem | objectql | ScopedContext.isSystem getter — re-exposes the underlying execution context's flag |
| 66 | packages/plugins/plugin-reports/src/report-service.ts#executeReport | plugin-reports | Threads the flag into the engine call that runs a report |
| 67 | packages/runtime/src/sandbox/body-runner.ts#executionContextFromHook | runtime | Rebuilds an ExecutionContext from a hook session, carrying the flag across |
What isSystem does not do
Just as costly as the list above. Each of these is a separate switch, and
assuming isSystem covers it is a documented source of bugs.
| Assumption | Reality | Anchor |
|---|---|---|
| "It suppresses triggers / record-change automation" | No. Only skipTriggers does. A bare { isSystem: true } on a seed write re-fired automation on freshly seeded rows and wedged first boot | packages/metadata-protocol/src/seed-loader.ts#SEED_OPTIONS (rationale at #writeDeferredReference, #3760), packages/spec/src/automation/flow.zod.ts#runAs |
| "It skips the state machine" | No. That is skipStateMachine, carried by seed replay and by treatAsHistorical imports | packages/objectql/src/engine.ts FSM gate; see State Machine |
| "It skips validation rules" | No. Field shape, format, script and the rest still run. The readonly strip runs before validation precisely so a discarded value is not judged | packages/objectql/src/engine.ts#insert |
"It preserves a supplied updated_at / updated_by" | No. That is preserveAudit, a separate opt-in — and an UPDATE-path exemption only | packages/spec/src/data/field.zod.ts#readonly (#3493 / #6640) |
"It stamps created_by" | No. Audit stamping reads userId from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner |
| "It bypasses every guard" | No. The last-admin guard applies to every context, isSystem included — the deprovision path that actually locks an org out is the system one | packages/plugins/plugin-auth/src/last-admin-guard.ts |
| "A client can request it" | No. Never settable from inbound HTTP or from an action body | packages/rest/src/rest-server.ts#enforceAuth; packages/runtime/src/domains/actions.ts#handleActionsRequest |
Known rough edges
Recorded rather than smoothed over, because a reader who hits one of these should recognise it instead of re-deriving it.
-
The
owner_idgap has two independent compensations and no shared mechanism. Row 2 is a real gap; the platform repairs it twice, in unrelated places — inline for automation flow writes (packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner, whose own comment states the reason: "the security middleware that stamps it short-circuits onisSystem— so the writer fills it here"), and as a boot-time sweep for seeded rows (packages/plugins/plugin-security/src/claim-seed-ownership.ts). Any third system write path gets neither. If you add one, stamp ownership yourself. -
Sharing materialisation is no longer skipped — the rough edge is closed, and it is recorded here rather than deleted. This entry used to describe "configured but inert": nine installed sharing rules, matching records, correct positions — and
sys_record_shareempty, repaired only by a re-evaluation or a restart. The reading that made it a rough edge rather than a defect was that the boot backfill eventually fixes it, so the behaviour was correct and merely undiscoverable; #6783 shipped an INFO notice on that reasoning.That reading did not survive contact with a runtime write. An approval node's write-back is a system write —
lockRecord: truemeans only a platform write can land while the record is locked — and it happens long after boot, so no backfill was coming: a manager approved a request and the teammate who depended on the criteria rulestatus == "approved"could not see it at all. The maintainer ruled on 2026-08-31 (#13533) that the skip was a bug, because a sharing rule's declared semantics is a published promise andisSystemnames the operator, never a consequence that need not happen. Both materialisation skips and the notice that announced them are gone; row 31 is now theafterDeleteskip alone, which survives on the separate ground that another subscriber delivers that payload.⚠️ The observability half of that reading is worth keeping in mind independently: the only signal a builder ever had was one INFO line, and nothing in the docs or the tests said "after approval, when does the team see it?". A compensating path that exists but that nobody can be expected to know about is not a compensating path.
-
Strict write observability is inert under elevation. Row 22: a caller that asked to be told loudly about dropped fields is told nothing, because nothing was dropped. The two facts are indistinguishable from the outside.
-
revoke()skips its own conflict guard. Row 35 is correct for the rule evaluator and surprising for anything else: a system caller can delete a rule-materialised grant that the next reconcile silently restores. -
applySystemFieldsdoes not read this flag. It is named as if it did.packages/objectql/src/registry.ts#applySystemFieldsis schema-side column provisioning — which columns an object carries — and consumesExecutionContext.isSystemzero times. The write-time ownership behaviour people attribute to it is row 2, inplugin-security.
Decision on record: the flag is deliberately not being split
Maintainer ruling, #4707, 2026-08-06. Recorded here at the ruling's own request, so the proposal stops being re-opened.
Ownership injection, readonly bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless staying as one boolean:
- Shipped semantics.
isSystemis a published contract with 108 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) - No business pull. No app has asked for the combinations a split would enable; the observed need was to understand the flag, which is what this page serves.
- Combinatorics are worse for AI authors, not better. Three independent switches are eight states, most of them untested and several of them incoherent (grant materialisation without ownership). One flag plus this table is judged more mistake-proof at authoring time than a surface where a wrong combination is expressible and silently valid.
The trade-off accepted with that ruling is that elevation stays coarse: you
cannot ask for the ownership behaviour without also taking the sharing
behaviour. Where a narrower need exists, the platform answers it with a
separate, explicit option next to isSystem — skipTriggers,
preserveAudit, skipStateMachine, runAs — rather than by subdividing the
flag. That is the pattern to follow for any new narrow exemption.
Maintaining this table
The table's value is exhaustiveness, so it is built by census, not by recall — and the census is a committed instrument rather than someone's afternoon:
node scripts/isystem-census.mjs # the summary below
node scripts/isystem-census.mjs --json # every site, its kind and its packageEvery number below marked ✅, plus the four sentences elsewhere on this page
that restate the same figures (the opening headline, the plugin-sharing share,
the #4707 ruling quote), is generated, never hand-maintained. pnpm gen:system-context-census (node scripts/check-system-context-census.mjs --fix) re-derives the census and rewrites every declared count that has
drifted from it — the exact computation the check below already runs, applied
as a write instead of a comparison. ⛔ Never hand-retype one of these
digits. Two branches each independently — and correctly, for their own
tree — bumping the same sentence to the same number is exactly how this page
once went silently wrong: the edits are textually identical, git merges them
clean with no conflict, and the merged total is neither side's number
(#16919). Run the generator instead, against the tree you actually want
counted, and it fails loudly rather than writing a partial page if a sentence
no longer parses. --fix still cannot add or drop a row — a site that
arrived or vanished is a human's editorial call, same as always — it only
keeps the aggregate counts in lockstep with whatever rows exist.
⛔ Not a grep. A text scan is where a census starts and it cannot be where
one ends: it returns prose inside comments and strings, the three unrelated
metadata fields, and the isSystemObject / isSystemObjectName /
isSystemLedgerObject name helpers. Worse, it loses real sites — a regex pass
over this same corpus silently dropped 6 reads in
packages/plugins/plugin-reports/src/report-service.ts to a quoting desync and 11 more to
(ctx?.session as any)?.isSystem casts. So the census walks the TypeScript AST
and classifies each appearance of the identifier by where the parser puts it:
a read (a table row here), a declaration, an object-literal or type
key (a producer, not a behaviour), or something else.
Corpus: tracked .ts / .tsx / .mts / .cts under packages/ and
examples/, excluding dist/. A file counts as a test when its path carries
.test. / .spec. or a tests/ / __tests__/ / qa/ segment. Every number
below was produced by that run. The CI column says which of them a check
still holds equal to the census on every pull request:
| Measurement | Count | CI |
|---|---|---|
Lines carrying isSystem in the corpus | 1811 | — |
| — in tests | 1013 | — |
| — in non-test sources | 798 | — |
Appearances of the bare identifier isSystem in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property read | 114 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
Of those reads: reads of ExecutionContext.isSystem | 108 | ✅ |
| — behaviour-bearing (rows 1–63 above) | 104 | ✅ |
| — carry the flag onward only (rows 64–67 above) | 4 | ✅ |
| Packages containing at least one elevation read | 20 | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 91 | ✅ |
| — of those files, the ones holding more than one read in one symbol | 9 | ✅ |
The six rows marked — are a dated decomposition, not a live claim: they were
measured on 2026-08-29 at ca1965f2b5 and CI does not re-derive them. They count
raw text over the whole corpus — every line, identifier appearance,
object-literal key and prose mention of the string isSystem under packages/
and examples/, tests included — which is a different population from the one
this page certifies, and it churns for reasons that have nothing to do with
elevation: a test that mentions the flag, a seed object carrying
isSystem: true, a comment. Enforcing them made this page's own pull request
un-landable, because CI scores a merge with main and the merge queue
re-derives that merge against a newer main on every attempt: three unrelated
merges moved these six numbers eight times in one night, while the census
population — 109 sites in 20 packages across 45 files — did not move once. They
are kept because they are how you get from a raw grep to 109, which is the
whole argument of this section; they are unenforced because a count of lines in
test files certifies nothing. ⛔ Do not re-add them to DECLARED_COUNTS — a
self-test case in the gate refuses that by name. Re-measure them with
node scripts/isystem-census.mjs when you want them current, and move the date.
What the enforced declarations row counts. Not the four field declarations
above — those are four distinct fields that happen to share a name, and only
the first is elevation. This row counts every position where the parser puts the
identifier in a declaring slot: those four, plus the structural type literals
that restate ExecutionContext.isSystem's shape inline rather than importing it
({ isSystem: true; tenantId?: string }, context?: { isSystem?: boolean }, and
the get isSystem() accessor on the engine's context wrapper). A restatement is
a producer's declaration of the shape it will build, never a read, so a new one
moves this count and moves nothing else on this page — the census's read
population, the anchored rows above, and the packages and files totals all stay
where they are. The most recent arrival is the scoped
seed context threaded into the org-admin permission-set lookup in
packages/plugins/plugin-security/src/auto-org-admin-grant.ts, so that read resolves
against the granting organization's own catalog row rather than an
organization-less one (#11670). ⛔ Cited as a FILE deliberately, with no
#symbol: this page's symbol anchors name the enclosing declaration of an
elevation read, and a declaration of the shape is not one — so there is no
symbol here for a row to name, and inventing one would put a name on the page
that no rename could ever red.
Counting by hand is what made the previous edition wrong in two independent
ways, so both are worth naming. Its headline said "80 distinct sites across 18
packages" while its own tables anchored 77 — the number never matched the
page it described. And a grep -c for anchor-shaped text over the previous edition answered
64, because it counted lines carrying an anchor, not anchors: that page's
real anchor population was 111 once continuation anchors and range ends are
counted. Decompose a text count before comparing it to anything.
What CI holds, and why it is the population and not just the anchors
A line-number anchor rots on every unrelated edit to the same file, silently:
re-resolving all 111 anchors of the edition before last found 101 pointing at
a line that no longer held what the row named, while only 10 were still
correct — and 41 of them named a basename that matches two files, so they
could not be placed without reading the row's Package column. Neither failure is
reachable from this edition: it carries no line numbers at all, and it spells
every path in full from the repository root. Each anchor is
packages/…/file.ts#symbol, resolved by the shared symbol-anchor resolver
(scripts/symbol-anchors.mjs) against that file's own declaration sites — the
same resolver, and the same registration shape, that holds docs/adr/**.
Renaming a symbol is now a loud red instead of a silent misdirection.
⚠️ The precision that costs, priced here rather than buried. A symbol anchor cannot say WHICH read inside a function it means, and 9 of the 45 anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must equal the set of symbols the census finds reads in. Two consequences, and the second one is a hole:
- Delete a whole symbol and this page reds — the anchor stops resolving and the symbol set stops matching, in the same run.
- Delete one of several reads inside a symbol that keeps at least one, and the
symbol set does not move, so it may not red. The previous edition's line
numbers did close this one: both reads lost in the last drift lived inside
callerContext()helpers that still exist under the same names. Closing it again means a span-aware resolver and per-read disambiguation in those nine files; that is its own card, and it is deliberately not folded in here.
Resolving anchors is nevertheless the second check, not the first. ⭐ A gate that only checks what the page already says can never find what the page failed to say. Re-resolving every anchor of the edition before last would have passed while it was missing 32 sites and its headline was 29 too low. So the load-bearing direction runs census → page: every elevation read in the code must be anchored here, and a site that vanishes from the code takes the census total with it — the site, package and file counts above are census-derived — which is what makes a row describing a protection that no longer exists fail.
Four checks run, in scripts/check-system-context-census.mjs:
| Check | What fails |
|---|---|
| Population | a file holding an elevation read with no anchor here, or a symbol holding one that no anchor here names |
| Resolution | an anchor naming no tracked file, or naming a symbol that file does not declare — and any surviving line number, which is no longer an anchor form |
| Counts | any census-derived number above that disagrees with the census — including the count-sentence wording, so the check cannot go quietly vacuous. The six raw text counts are exempt by design, but their rows must still be present and dated |
| Classification | an anchor that is not a read site and is not a declared non-read citation |
A new read of ExecutionContext.isSystem belongs in this table in the same PR
that introduces it — CI will say so if it is not.
Related
- Authorization Architecture — the six-gate enforcement chain this flag short-circuits
- Security & Access Control — the
readonlywrite strip and its exemptions - State Machine —
skipStateMachine,preserveAudit,treatAsHistorical - Sharing Rules — what row 31 is skipping