ObjectStackObjectStack

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.

DeclarationWhat it isThis page?
ExecutionContext.isSystempackages/spec/src/kernel/execution-context.zod.ts#isSystemThe elevation flag on an operation's context
Object.isSystempackages/spec/src/data/object.zod.ts#isSystemMarks a system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set)
EmailTemplate.isSystempackages/spec/src/system/email-template.zod.ts#isSystemBuilt-in template; tenants may override but should not delete
Environment.isSystempackages/spec/src/cloud/environment.zod.ts#isSystemPlatform-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 isSystemPackageWhat you get / what you loseAnchor
1The whole security middleware short-circuits before any gate runsplugin-securityGet: 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 pagepackages/plugins/plugin-security/src/security-plugin.ts#start
2owner_id is not auto-stamped on INSERT (the step 3.5 anchor guard is inside the block row 1 skips)plugin-securityLose: 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 capabilitythe step 3.5 guard block and the short-circuit that skips it are both inside packages/plugins/plugin-security/src/security-plugin.ts#start
3Row-level read filter resolves to "no filter"plugin-securityGet: unscoped reads. Lose: row-level scoping entirelypackages/plugins/plugin-security/src/security-plugin.ts#getReadFilter
4Field-level security returns all fieldsplugin-securityGet: every column readable. Lose: field maskingpackages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields
5Export permission granted unconditionallyplugin-securityGet: canExport is truepackages/plugins/plugin-security/src/security-plugin.ts#canExport
6Object-level read admission granted unconditionallyplugin-securityGet: 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 interchangeablepackages/plugins/plugin-security/src/security-plugin.ts#canReadObject
7Write bypass = true, effective write scope = orgplugin-securityGet: widest write scope without holding any capabilitypackages/plugins/plugin-security/src/security-plugin.ts#start
8Metadata-plane schema masking exempt (ADR-0106 D4)metadata-coreGet: unmasked object schema. Note: the exemption is a caller property — it short-circuits before the security service is consultedpackages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt
9explain() may target a principal other than the callerplugin-securityGet: no manage_users / delegated-admin checkpackages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller
10Anonymous-deny treats the caller as authenticatedcoreGet: passes the 401 seam with no userIdpackages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous
11Permission-set projection middleware skippedplugin-securityLose: projection of permission-set-derived columnspackages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough
12Session-resolution middleware skippedplugin-authGet: no session lookup attemptedpackages/plugins/plugin-auth/src/auth-plugin.ts#start
13Per-request performance timings disclosedobservabilityGet: timing headers a normal caller cannot pullpackages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal
14Permission-set overlay discard skips the tenant-admin assertionplugin-securityGet: an overlay can be discarded with no authenticated tenant administratorpackages/plugins/plugin-security/src/permission-set-overlay-discard.ts#assertTenantAdmin
15MCP stdio bridge skips the object API-exposure gatemcpGet: the bridge reaches objects whose apiEnabled / apiMethods would refuse an external callerpackages/mcp/src/stdio-data-bridge.ts#enforceApiExposure
16Read-audit rows are not writtenplugin-auditLose: 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 onepackages/plugins/plugin-audit/src/read-audit.ts#installReadAuditWriter
17Approval snapshot payload redaction skippedplugin-approvalsGet: the whole snapshot on find / findOne — the audit/replay channel. Lose: field-visibility redaction over approval payloadspackages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware
18REST anonymous-deny seam satisfiedrestGet: enforceAuth passes with no userId. Not reachable from the wire — isSystem is never set on an inbound requestpackages/rest/src/rest-server.ts#enforceAuth

2. Write pipeline and data integrity

#Behaviour when isSystemPackageWhat you get / what you loseAnchor
19readonly strip bypassed — UPDATE, single rowobjectqlGet: a readonly field CAN be written. Lose: the protection that stops a caller seeding e.g. approval_statuspackages/objectql/src/engine.ts#update
20readonly strip bypassed — UPDATE, bulk/predicateobjectqlSame, on the multi-row pathpackages/objectql/src/engine.ts#update
21readonly strip bypassed — INSERTobjectqlSame, 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 strippedpackages/objectql/src/engine.ts#insert
22Strict-drop refusal never firesobjectqlLose: a caller that opted into loud refusal gets silence — strict refuses exactly what the strip would have taken, and the strip took nothingpackages/objectql/src/engine.ts#insert, packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS
23Referential-integrity check skippedobjectqlGet: writes proceed against unreachable/unresolvable targets. Lose: an isSystem caller can write a dangling referencepackages/objectql/src/engine.ts#assertReferencesResolve
24Tenant-audit warning silenced; bypassTenantAudit threaded to the driverobjectqlGet: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bugpackages/objectql/src/engine.ts#buildDriverOptions
25Engine-owned / append-only write guard bypassedplugin-securityGet: generic writes to managedBy engine-owned objectspackages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite, #assertEngineOwnedWriteAllowed
26Identity write guard bypassed (ADR-0092)plugin-authGet: direct writes to identity tables through the generic data pathpackages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite
27Search-companion column kept in a read's rows when it was explicitly requestedobjectqlGet: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own indexpackages/objectql/src/engine.ts#stripSearchCompanionFromRead
28Dependent-count disclosure on a blocked deleteobjectqlGet: the count of blocking children. Nothing was elevated past the caller, so nothing is withheldpackages/objectql/src/engine.ts#dependentCountIsDisclosable
29Reference-cleanup log attributes the write to 'system'objectqlGet: an honest actor label instead of anonymous when the context carries neither userId nor actorpackages/objectql/src/engine.ts#recordReferenceCheckElevation
30Bulk data event organizationId OMITTED — the batch is published "not asserted"plugin-securityGet: 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 isSystemWhat you get / what you loseAnchor
31Sharing-rule REVOCATION is skipped on the record-afterDelete hook — and on that hook onlyLose: 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 doespackages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleHooks
32Sharing write verdict short-circuits to allowGet: writes pass the sharing gate unconditionallypackages/plugins/plugin-sharing/src/sharing-service.ts#bypassVerdict
33Record visibility / manage-shares checks return trueGet: no ownership or Modify-All requirementpackages/plugins/plugin-sharing/src/sharing-service.ts#canManageShares, #assertCanManageShares, #shouldBypass
34grant() skips the enforcement + manage-shares assertionsGet: 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
35revoke() deletes directly, before the non-manual-source guardGet: 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 reconcilepackages/plugins/plugin-sharing/src/sharing-service.ts#revoke (the guard it deletes in front of is in the same function)
36listShares() skips the management gateGet: full enumeration of who can see a recordpackages/plugins/plugin-sharing/src/sharing-service.ts#listShares
37sys_record_share reads are not self-scopedGet: tenant-wide share listing without manage_sharingpackages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware
38Share-link policy enabled check bypassed; system callers re-enter under a system contextGet: 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 enabledpackages/plugins/plugin-sharing/src/share-link-service.ts#createLink, #revokeLink, #listLinks
39Sharing-rule provenance stamp skippedLose: 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
40Sharing-rule service write + delete paths return earlyLose: the manage-rules gate on the service surface, and the platform-global-rule delete guardpackages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules, #assertCanDeletePlatformGlobalRule

4. Approvals, reports, attachments, comments, knowledge

#Behaviour when isSystemPackageWhat you get / what you loseAnchor
41Approval record lock released — a locked record is writableplugin-approvalsGet: 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 isSystempackages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindApprovalLockHook
42Delegation write guard bypassedplugin-approvalsGet: service / seed / import may write delegation rows naming another delegatorpackages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard
43Approval actor / submitter / pending-approver checks bypassed (8 sites)plugin-approvalsGet: approve, reject, recall, reassign without being a pending approver or the submitterpackages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor, #resolveActor, #sendBack, #resubmit, #reassign, #remind, #requestInfo, #comment
44Saved-report ownership is assignable, and an update may reassign itplugin-reportsGet: ownerId from input is honoured. A non-system caller always owns what it creates and can never reassignpackages/plugins/plugin-reports/src/report-service.ts#saveReport
45Saved-report access / export / mutation gates bypassedplugin-reportsGet: read, bulk-export and overwrite any reportpackages/plugins/plugin-reports/src/report-service.ts#assertExportAllowed, #canAccessReport, #listReports, #listSchedules
46Attachment access hooks return early (insert + update + delete, and the read AST)service-storageLose: attachment visibility scopingpackages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks, #installAttachmentReadVisibility
47Comment access hooks return early (insert + update + delete, and the read AST)plugin-auditLose: comment visibility scopingpackages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks, #installCommentReadVisibility
48Knowledge search returns hits unfilteredservice-knowledgeLose: the permission filter over search resultspackages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter

5. Actions, metadata plane, provenance, the organization wall

#Behaviour when isSystemPackageWhat you get / what you loseAnchor
49Object API-exposure gate bypassed (apiEnabled / apiMethods)runtimeGet: internal self-writes ignore exposure declarations — these govern external exposure, not engine self-writespackages/runtime/src/action-execution.ts#callData
50Action requiredPermissions bypassedruntimeGet: engine self-invocation runs any actionpackages/runtime/src/action-execution.ts#actionPermissionError
51manage_metadata bypassed on metadata writesruntime, restGet: schema writes without the capabilitypackages/runtime/src/domains/meta.ts#handleMetadataRequest, packages/rest/src/rest-server.ts#registerMetadataEndpointsInner
52The shared metadata-write verdict itself returns allowedmetadata-coreGet: the one function all of row 51's doors consult answers yes before any capability is examinedpackages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict
53Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routesruntime, restGet: passes with no userIdpackages/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
54MCP principal check satisfiedruntimeGet: MCP surface reachable with no userpackages/runtime/src/domains/mcp.ts#handleMcpRequest
55Package REST route capability gate bypassedrestGet: 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 servedpackages/rest/src/package-routes.ts#refusePackageRequest
56Package domain capability gates bypassedruntimeGet: package management and package-inventory reads without the capabilitypackages/runtime/src/domains/packages.ts#requireManageMetadata, #requireReadCapability
57Activation write / authoring refusals do not fireruntimeGet: activation artifacts writable and authorable without the activation-authoring capabilitypackages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite, #refuseUngrantedActivationAuthoring
58Automation run-state read, flow-authoring write, unrelated-screen read and the two operator run-lifecycle writes all passruntimeGet: 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 recordedpackages/runtime/src/domains/automation.ts#mayReadRunState, #refuseUngrantedFlowWrite, #refuseUnrelatedScreenRead, #refuseUngrantedRunLifecycleWrite
59Audience-binding suggestion recording skippedplugin-securityLose: install-time suggestions are not recorded for system callerspackages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin
60Email-template / webhook provenance stamps skippedplugin-email, plugin-webhooksLose: the row is not marked as an admin customizationpackages/plugins/plugin-email/src/email-template-provenance.ts#bindEmailTemplateProvenanceStamp, packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp
61Automation flow data nodes re-add the owner_id stamp (the one place row 2's gap is compensated inline)service-automationGet: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values winpackages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner, called from packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes
62Inbox caller refusal names isSystem as what was carriedservice-messagingGet: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subjectpackages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient
63organization_id is not auto-stamped on INSERT — the organization-axis twin of the owner_id gap aboveorganizationsGet: 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 usepackages/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.

#SitePackageWhat it does
64packages/objectql/src/engine.ts#buildSessionobjectqlPropagates isSystem into the hook session so hooks can tell engine self-writes from user writes
65packages/objectql/src/engine.ts#isSystemobjectqlScopedContext.isSystem getter — re-exposes the underlying execution context's flag
66packages/plugins/plugin-reports/src/report-service.ts#executeReportplugin-reportsThreads the flag into the engine call that runs a report
67packages/runtime/src/sandbox/body-runner.ts#executionContextFromHookruntimeRebuilds 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.

AssumptionRealityAnchor
"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 bootpackages/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 importspackages/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 judgedpackages/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 onlypackages/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 errorpackages/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 onepackages/plugins/plugin-auth/src/last-admin-guard.ts
"A client can request it"No. Never settable from inbound HTTP or from an action bodypackages/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.

  1. The owner_id gap 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 on isSystem — 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.

  2. 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_share empty, 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: true means 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 rule status == "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 and isSystem names the operator, never a consequence that need not happen. Both materialisation skips and the notice that announced them are gone; row 31 is now the afterDelete skip 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.

  3. 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.

  4. 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.

  5. applySystemFields does not read this flag. It is named as if it did. packages/objectql/src/registry.ts#applySystemFields is schema-side column provisioning — which columns an object carries — and consumes ExecutionContext.isSystem zero times. The write-time ownership behaviour people attribute to it is row 2, in plugin-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. isSystem is 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 isSystemskipTriggers, 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 package

Every 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:

MeasurementCountCI
Lines carrying isSystem in the corpus1811
— in tests1013
— in non-test sources798
Appearances of the bare identifier isSystem in non-test sources813
— parsed as a declaration22
— parsed as an object-literal / type key (producers and option objects)310
— parsed as a property read114
— parsed in some other syntactic position (a local, a cast, a conditional)9
— the remainder: text inside comments and string literals358
Of those reads: reads of one of the unrelated metadata fields6
Of those reads: reads of ExecutionContext.isSystem108
— behaviour-bearing (rows 1–63 above)104
— carry the flag onward only (rows 64–67 above)4
Packages containing at least one elevation read20
Files containing at least one elevation read45
— the distinct symbols those reads live in — what this page anchors91
— of those files, the ones holding more than one read in one symbol9

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:

CheckWhat fails
Populationa file holding an elevation read with no anchor here, or a symbol holding one that no anchor here names
Resolutionan 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
Countsany 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
Classificationan 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.

On this page