Skip to content

Latest commit

 

History

History
494 lines (419 loc) · 60.3 KB

system-context.mdx

File metadata and controls

494 lines (419 loc) · 60.3 KB
title System context (isSystem) — bypass rules
navTitle System Context (isSystem)
description Every platform behaviour that ExecutionContext.isSystem changes — what an elevated write gets, what it loses, and what the flag deliberately does not do.

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 over a hundred distinct sites across some twenty packages, and knowing three of those behaviours gives no hint that the rest 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 the population behind it, 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, or — the check that matters most — when the code holds an elevation read that no row here anchors. The page states no totals: run `node scripts/isystem-census.mjs` for a live count. The six raw text counts in [Maintaining this table](#maintaining-this-table) are a dated snapshot, 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 adding or deleting one of several reads inside a symbol that keeps at least one does not. The gap is real, it is priced below, and it is left open deliberately rather than hidden — see Maintaining this table.

Which isSystem this page is about

Three unrelated declarations share the identifier. This page documents only the first. The others are metadata keys on a stored document (one of them now retired) and have nothing to do with elevation. (A fourth, Environment.isSystem — a platform-infrastructure environment, not user data — left this repo with the @objectstack/spec/cloud subpath in #16325 and is declared in the cloud repo.)

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 A retired key: a retiredKey() tombstone, which the parse refuses with a migration note ❌

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: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to org (row 7) — 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 (getReadableFields) and writable (getWritableFields). Lose: field masking packages/plugins/plugin-security/src/security-plugin.ts#resolveProjectionFieldMask
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
6b WRITE admission granted unconditionally plugin-security Get: canWriteObject is true. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (ObjectQL.validate()), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture and the D10 dangling delegator), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject
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
8b Authoring-trail reads admitted rest, runtime Get: a metadata item's version history, audit rows and stored-version diff (GET /meta/:type/:name/history, /audit, /diff) without studio.access or manage_metadata, and a package's commit timeline (GET /packages/:id/commits) without studio.access. Note: narrower than the schema-mask exemption the draft switches ask (isObjectSchemaMaskExempt): neither door admits setup.access, which organization admins hold packages/rest/src/rest-server.ts#mayReadAuthoringTrail, packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability
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
9b The master-detail write check the security service serves (checkControlledByParentWrite, ADR-0055) answers allow plugin-security Get: allow for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the sys_attachment and sys_comment parent gates, return before they ask for a system session packages/plugins/plugin-security/src/security-plugin.ts#checkControlledByParentWrite
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
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
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, and so is the snapshot query guard plugin-approvals Get: the whole snapshot on find / findOne — the audit/replay channel — and a filter, sort or grouping by it on any read. Lose: field-visibility redaction over approval payloads, and the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware, packages/plugins/plugin-approvals/src/payload-predicate-guard.ts#bindSnapshotPredicateGuard
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
18b Settings namespace read scope not applied service-settings Get: a read of sys_setting, sys_setting_audit or sys_platform_setting across every namespace, whatever each namespace's readPermission names — the settings service's own reads of its stores take this path. Lose: the per-namespace read gate the generic data API applies to every other caller, the same rule the settings routes enforce packages/services/service-settings/src/settings-read-door.ts#settingsReadDoorMiddleware

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. A second read in the same symbol gates the pass that withholds those caller values from beforeInsert before the strip, the same exemption, so a system caller's hook still sees what it sent packages/objectql/src/engine.ts#insert
21b readonly strip bypassed — the write PREVIEW (ObjectQL.validate) objectql Same as 19–21, in the preview: it runs the write's caller-write strips under the write's own gate, so a system preview reports no readonly drop and judges the value a system write would store. The computed strip of a formula value is NOT gated here or on either write verb — no driver has a column for it, so it runs for every caller packages/objectql/src/engine.ts#validate
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. One exception: a formula value, which the computed strip takes from every caller, so strict still refuses it 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
23b Position-catalog check skipped — a sys_user_position write whose position names no position in the security catalog is not refused plugin-security Get: a system writer can store an assignment naming no position — the seed loader writes stack.data from AppPlugin.start() under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes position, is refused 400 VALIDATION_FAILED / reference_not_found instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a sys_position row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow
23c Grant-name catalog check skipped — a sys_user_permission_set write whose permission_set names no permission set in the security catalog is not refused plugin-security Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes permission_set, is refused 400 VALIDATION_FAILED / reference_not_found instead when the security catalog holds no set by that name; a grant that names no set at all is refused required for every caller. The grant names its set by name alone, and nothing derives the name from permission_set_id (ADR-0131 D4, D10) packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert, #onUpdate
23d Grant-holder membership check skipped — a sys_user_position or sys_user_permission_set write naming a user with no sys_member row in the row's organization is not refused plugin-security Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves user_id or organization_id, is refused 400 VALIDATION_FAILED / reference_not_found at user_id instead, for every caller, a platform administrator included; the membership is read as { isSystem: true, tenantId } with tenantId the row's own organization, never another one. A row stored with no organization is a global grant and is not judged packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert, #onUpdate
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
26b API key restore guard does not refuse plugin-auth Get: a write setting revoked: false on a sys_api_key row an administrator's forced password reset revoked is admitted without the platform-administrator check a user-context restore must pass; the reason is still cleared. Lose: nothing a caller reaches: no system writer restores a key, and the mint, the last-used stamp and the forced reset itself never set revoked: false packages/plugins/plugin-auth/src/api-key-restore-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
29b A traversing validation rule's related read skips the caller's bounds objectql Get: no row bound from the caller's own read on a related object no organization wall scopes, no read door on a header the rule reads through a master_detail field, and a related read even with no organization under a walled posture (a tenantId on the context still scopes it). Every caller that is not system is held to all three: a header its read door does not return is unavailable to the rule exactly as a header that exists nowhere is, whatever it holds, and a header it can read is judged on its stored values. A plain lookup to an object an organization wall scopes asks no read door, system caller or not packages/objectql/src/engine.ts#resolveOneHopRelated
29c A detail write's master-detail header read asks no read door objectql Get: the write binds the header a parent-scoped requiredWhen / readonlyWhen reads as parent from the header's stored row whoever can read it, as before. Every caller that is not system meets its own read door first: a header that read does not return answers as a header that exists nowhere — an unbound parent, and reference_not_found on the reference check (which a system caller's write skips altogether) packages/objectql/src/engine.ts#masterDetailHeadersCallerReads
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
30b The import doors admit a control opt-out — runAutomations: false and treatAsHistorical: true on POST /data/:object/import and /import/jobs rest Get: an import whose rows are written with skipAutomations (no metadata-bound hook, no record-change trigger: an app's approvals and record freezes among them) or with skipStateMachine and preserveAudit. Every other caller, every signed-in user and administrator included, is refused 403 PERMISSION_DENIED before the body is parsed, so nothing is written and no job is created. Inbound HTTP never carries the flag, so this is an in-process caller's door. Note: the flag still suppresses none of those controls by itself (see below); it only admits the opt-outs that do packages/rest/src/import-prepare.ts#importControlOptOutRefusal

3. Sharing (plugin-sharing)

One of the largest consumers of the flag; node scripts/isystem-census.mjs --json lists every site with its package.

# 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, 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 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
45 Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) plugin-audit Get: the whole activity row and the whole sys_audit_log row, its before/after snapshots included, on find / findOne — the audit writer, each read gate's bounded probe and the activity backfill — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of sys_activity and of sys_audit_log to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld, of a fired milestone row whose watched fields it is not all served, and of an unstamped row (on count and aggregate too, so a total agrees with the rows), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks, #installCommentReadVisibility, packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility, packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility, packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction, packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction, packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard
46 Knowledge search returns hits unfiltered service-knowledge Lose: the permission filter over search results packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter
47 The inbound-mail read gates return early (the read AST) service-mailbox Get: every sys_mail_message and sys_mail_association row on find / findOne — the ingest writer and each gate's own pre-scan, which read the association and mailbox rows a decision needs. Lose: the narrowing of a message to callers who can read a record it is associated with or who own its mailbox (so the unassociated pool is restricted), and of an association to callers who can read the record it names (on count and aggregate too, so a total agrees with the rows) packages/services/service-mailbox/src/mail-read-visibility.ts#gateMiddleware
47b The inbound-mail ingest seam: ingest admits the system context ALONE, and rematch admits it without the organization, owner or manage_mailboxes check service-mailbox Get: a mail source hands a mailbox its messages, and server code re-matches any mailbox. Lose: nothing a user held — every other caller of ingest is refused (PERMISSION_DENIED), because an ingested message is shown on every record its correspondents' addresses match and a user who could hand the seam a message could write one in a correspondent's name; every other caller of rematch must be in the mailbox's organization and own the mailbox or hold manage_mailboxes packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#ingest, #authorizeRematch
47c The inbound-mail IMAP sync: syncMailbox and syncAll admit the system context ALONE service-mailbox Get: the sync job reads every mailbox row (its organization, address and IMAP cursor), writes the cursor back, and hands each fetched message to ingest under the system context. Lose: nothing a user held — every other caller is refused (PERMISSION_DENIED), because a sync stores a mailbox's mail and moves its cursor packages/services/service-mailbox/src/sync/mailbox-sync-service.ts#syncMailbox, #syncAll
47d The print service REFUSES the system context: printRecordToFile admits a human user acting for themselves alone service-print Get: nothing — a system caller is refused (PERMISSION_DENIED) before any page is read, session minted or browser started. Lose: nothing a user held — the PDF is rendered as the requesting user and never as the system, so a record is archived only by someone who could read it packages/services/service-print/src/print-service.ts#refuseUnlessHumanCaller
47e The caller-scoped attach verbs REFUSE the system context: attachFileAs and assertMayAttach admit a signed-in user alone service-storage Get: nothing — a system caller is refused (PERMISSION_DENIED); the system attaches through attachFile, whose rows name no user. Lose: nothing a user held — the caller-scoped verbs insert the attachment under the caller's context so every insert gate judges that user and both rows name them packages/services/service-storage/src/file-attachment-service.ts#requireUserContext

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

# Behaviour when isSystem Package What you get / what you lose Anchor
48 Object API-exposure gate bypassed (apiEnabled / apiMethods) runtime Get: internal self-writes ignore exposure declarations — these govern external exposure, not engine self-writes. The gate is refuseUnexposedDataAction, which callData asks first and the /data domain asks before its request gate; callData reads the same bypass once more for each nested-relation condition's target packages/runtime/src/action-execution.ts#callData, #refuseUnexposedDataAction
49 Action requiredPermissions bypassed runtime Get: engine self-invocation runs any action packages/runtime/src/action-execution.ts#actionPermissionError
50 manage_metadata bypassed on metadata writes runtime, rest Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on /layers, ?layers=true and /diff, whatever its entry gates withhold packages/runtime/src/domains/meta.ts#handleMetadataRequest, packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes, packages/rest/src/meta-item-write-routes.ts#registerMetaItemWriteRoutes, packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes, packages/rest/src/rest-server.ts#metaSaveVerdict, packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite
51 The shared metadata-write verdict itself returns allowed metadata-core Get: the one function all of row 50's doors consult answers yes before any capability is examined packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict
52 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/runtime/src/domains/analytics.ts#handleAnalyticsRequest, packages/runtime/src/domains/i18n.ts#handleI18nRequest, packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes, packages/rest/src/package-routes.ts#refusePackageRequest
53 MCP principal check satisfied runtime Get: MCP surface reachable with no user packages/runtime/src/domains/mcp.ts#handleMcpRequest
54 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
55 Package domain capability gates bypassed runtime Get: package management and package-inventory reads without the capability packages/runtime/src/domains/packages.ts#requireManageMetadata, #requireReadCapability
56 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
55b Walled environment-metadata refusal does not fire metadata-core, rest, runtime Get: under isolated / group, a write of the environment's metadata, of any type, at the /meta write doors (save, reset, publish, rollback) and POST /meta/_migrate-stored on both transports, at every /packages write door (create, the manifest PATCH, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, commits/:commitId/revert, duplicate, adopt-orphans, delete), at the /automation definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused 403 there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under single the refusal is inert for every caller packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict, packages/rest/src/rest-server.ts#walledCatalogRefusal, packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite, packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite
57 Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass runtime Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. 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. And starting a flow declared runAs: 'system' whose type is self-triggered (autolaunched, record_change, schedule) through any door that starts a flow by name — the trigger route, an action of type: 'flow' (REST /actions and the MCP run_action bridge) and a declared endpoint of type: 'flow' — which every other caller, the guest principal included, is refused 403 PERMISSION_DENIED (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag packages/runtime/src/domains/automation.ts#mayReadRunState, #refuseUngrantedFlowWrite, #isRunStarterOrRunStateReader, #refuseUngrantedRunLifecycleWrite, packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart
58 Audience-binding suggestion authority gate passes plugin-security Get: a system caller lists, accepts and dismisses suggestions without manage_metadata and, under a wall, without the platform-operator rung packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority
59 Email-template organization door passes; webhook provenance stamp skipped plugin-email, plugin-webhooks Get (sys_email_template): a create or update of a template row. The door refuses every other write that names a caller, 403 PERMISSION_DENIED (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (sys_webhook): the row is not marked as an admin customization packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite, packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp
60 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
61 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
61b The principal-session mint refuses a system context plugin-auth Get: nothing — mintPrincipalSession answers PERMISSION_DENIED / 403 before anything is minted. A principal session acts as the requesting user and never as the system, so a render or any other in-process act that needs a session must carry the user's own context packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront
62 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 fill-only stamp, so an elevated insert that names no organization lands organization_id = NULL and the wall hides it. ⛔ Neither path rewrites a supplied organization_id: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one 403 PERMISSION_DENIED exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it 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 three 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
63 packages/objectql/src/engine.ts#buildSession objectql Propagates isSystem into the hook session so hooks can tell engine self-writes from user writes
64 packages/objectql/src/engine.ts#isSystem objectql ScopedContext.isSystem getter — re-exposes the underlying execution context's flag
65 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.

  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 read at over a hundred sites across some twenty 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 since, 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 live totals, for the tree you run it on
node scripts/isystem-census.mjs --json   # every site, its kind and its package

This page states no totals, and that is deliberate. How many sites, in how many packages and files, is derived data: it moves whenever a read arrives or leaves, and a figure typed into hand-written prose is paid for at every merge. Two branches that each add a read bump the same sentence and conflict on it — or, bumping it to the same number, text-merge clean to a total neither tree holds, which is how this page once went silently wrong (#16919). So the totals are computed where they are read: the census prints them for the tree you run it on, and the check below prints them again on every pass. What CI holds is the register itself — every elevation read anchored, every anchor resolving — not a sum of it. A site that arrived or vanished is a human's editorial call: add or drop its row.

⛔ 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 the saved-report service (retired since, #20102) 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. The census narrows that corpus in the order below. The raw text it starts from is kept here as a dated snapshot:

Raw text Count
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 an object-literal / type key (producers and option objects) 310
— the remainder: text inside comments and string literals 358

The rest of those appearances are what the census classifies, and the page restates none of its figures:

Census step What it holds
Appearances parsed as a declaration every declaring slot — see below
Appearances parsed as a property read every read, before the collision is subtracted
Appearances parsed in some other syntactic position a local, a cast, a conditional
Of those reads: reads of one of the unrelated metadata fields subtracted — the collision this page opens with
Of those reads: reads of ExecutionContext.isSystem the census answer: every one sits in a symbol a row above anchors
— behaviour-bearing (rows 1–62 above) sections 1 to 5
— carry the flag onward only (rows 63–65 above) section 6

node scripts/isystem-census.mjs prints how many appearances fell in each syntactic slot, the subtraction, the answer and the packages and files that hold it; node scripts/check-system-context-census.mjs prints the answer again on every pass, with the distinct symbols those reads live in and how many files hold more than one read in one symbol.

The six raw-text rows 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 at the time — did not move once. They are kept because they are how far a raw grep sits from the census answer, which is the whole argument of this section; they are unenforced because a count of lines in test files certifies nothing. Re-measure them with node scripts/isystem-census.mjs when you want them current, and move the date.

What the census counts as a declaration. Not only the three field declarations above — those are three distinct fields that happen to share a name, and only the first is elevation. It counts every position where the parser puts the identifier in a declaring slot: those three, 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 the census's declaration count and nothing on this page — the read population and the anchored rows above 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 is scoped to the granting organization rather than read organization-less (#11670). It reads the legacy sys_permission_set ids an old grant may carry; that object is no longer registered (ADR-0131 D3), so the read answers no rows. ⛔ 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 several of the 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.
  • Add or delete one of several reads inside a symbol that keeps at least one, and the symbol set does not move, so it does not red. While this page stated totals, the site count moved on that edit — but the repair it prescribed was a generator rewriting the number, never a row. 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 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 symbol whose last read vanishes from the code leaves an anchor that names no read site, 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
Classification an anchor that is not a read site and is not a declared non-read citation
Dated snapshot a raw-text row above reworded off the page, or its measurement date dropped — the numbers themselves are never compared

A new read of ExecutionContext.isSystem belongs in this table in the same PR that introduces it — CI will say so when it lands in a symbol no row anchors yet; inside a symbol that already has one, only the reviewer will.

Related