diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx
index 5937ca57da4..b54d045f235 100644
--- a/content/docs/permissions/system-context.mdx
+++ b/content/docs/permissions/system-context.mdx
@@ -26,24 +26,25 @@ context whenever one exists.
-**Every anchor, and the population behind it, is checked by CI.**
+**Every row, 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](#maintaining-this-table).
+repo on each PR and refuses the page when the code holds an elevation read that
+no row here names — the check that matters most — when a row names a symbol
+that holds no read any more, or when the generated
+[site register](#site-register) differs from what the tree produces. 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 the rows say, and what is generated — read this before trusting a
+row.** A row names the symbols its behaviour lives in, by name, in its
+`Symbol` column; which file holds each one is the site register's to say, and
+the register is rebuilt from the tree. So a refactor that moves a symbol to
+another file changes the register and no row. The row itself is a human's
+claim: several reads inside one function share one name, so adding or deleting
+one of them moves only that symbol's read count in the register — CI reds until
+the register is regenerated, and whether the row still describes the symbol is
+the reviewer's call. See [Maintaining this table](#maintaining-this-table).
## Which `isSystem` this page is about
@@ -88,118 +89,120 @@ 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.
+Grouped by lane. Every row names, in its `Symbol` column, the symbols whose
+reads it describes; the [site register](#site-register) at the foot of the page
+gives each one's file. A name two files share is written as a full
+`path#symbol` anchor instead. "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 |
+| # | Behaviour when `isSystem` | Package | What you get / what you lose | Symbol |
|:--|:---|:---|:---|:---|
-| 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-authz-middleware.ts#registerAuthzMiddleware` |
-| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, an app-authored hook's (`runAs: 'system'`, a system context it inherits, its `sudo()`) and an action body's; the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation, by the action body's execution-context builder and `ObjectRepository.execute`, and on the api of a hook bound through the hook binder, whose inherited system context and `sudo()` it marks), at any depth of their writes, is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context with neither (the seeders, better-auth and its reconciliation, invitation placement, platform code's `sudo()`, which never sets the mark, and a hook registered in code) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource`, `packages/objectql/src/engine.ts#inheritsElevation` |
-| 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-authz-middleware.ts#registerAuthzMiddleware` |
-| 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-read-filter.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-field-readers.ts#resolveProjectionFieldMask` |
-| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin-object-admission.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-object-admission.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, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), 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-object-admission.ts#canWriteObject` |
-| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts#writeWallVerdict` |
-| 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-security-service.ts#registerSecurityService` |
-| 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-explain-access.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-cbp-write-gate.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` |
+| 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 | `registerAuthzMiddleware` |
+| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, an app-authored hook's (`runAs: 'system'`, a system context it inherits, its `sudo()`) and an action body's; the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation, by the action body's execution-context builder and `ObjectRepository.execute`, and on the api of a hook bound through the hook binder, whose inherited system context and `sudo()` it marks), at any depth of their writes, is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context with neither (the seeders, better-auth and its reconciliation, invitation placement, platform code's `sudo()`, which never sets the mark, and a hook registered in code) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook carries no `isSystem`, so the D12 gate judges it as the user it acts for | `elevationSource`, `inheritsElevation` |
+| 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 `registerAuthzMiddleware` |
+| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `getReadFilter` |
+| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `resolveProjectionFieldMask` |
+| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `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 | `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, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), 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 | `canWriteObject` |
+| 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `writeWallVerdict` |
+| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `registerSecurityService` |
+| 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 | `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 | `mayReadAuthoringTrail`, `requireCommitTrailReadCapability` |
+| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `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 | `checkControlledByParentWrite` |
+| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `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` |
+| 13 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `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 | `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 | `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 | `bindSnapshotRedactionMiddleware`, `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 | `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 | `settingsReadDoorMiddleware` |
### 2. Write pipeline and data integrity
-| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
+| # | Behaviour when `isSystem` | Package | What you get / what you lose | Symbol |
|:--|:---|:---|:---|:---|
-| 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. Two exceptions, both judged in every context: a `formula` value, which the `computed` strip takes from every caller, and a value for a stored field that declares `expression` when it differs from the value the engine computes in its place (`stored_expression`), so strict still refuses both | `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-referential-integrity.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 not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `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-driver-options.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` |
+| 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` | `update` |
+| 20 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `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 | `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 | `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. Two exceptions, both judged in every context: a `formula` value, which the `computed` strip takes from every caller, and a value for a stored field that declares `expression` when it differs from the value the engine computes in its place (`stored_expression`), so strict still refuses both | `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** | `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 | `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`, `packages/plugins/plugin-security/src/grant-permission-set-name.ts#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 not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#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 | `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`, `packages/plugins/plugin-security/src/system-write-guard.ts#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-secret-internal-fields.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-master-detail-related.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-referential-integrity.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-authz-middleware.ts#registerAuthzMiddleware` |
-| 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` |
-| 30c | **The import undo keeps the control skips** — `POST /data/import/jobs/:jobId/undo` | rest | Get: an undo whose deletes and restores are written with `skipAutomations` and `skipStateMachine`, so no metadata-bound hook, record-change trigger or `state_machine` rule refuses a reversal. Every other caller, every signed-in user and administrator included, undoes under those controls: a reversal one of them refuses is reported per row in the undo answer's `failures` and kept in the job's undo log, and the job is stamped reverted only once every row is. A historical job's `preserveAudit` belongs to the job, not the caller, and rides every undo. Inbound HTTP never carries the flag, so this is an in-process caller's door | `packages/rest/src/data-action-routes.ts#importUndoWriteContext` |
+| 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 | `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 | `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` | `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 | `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) | `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) | `registerAuthzMiddleware` |
+| 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 | `importControlOptOutRefusal` |
+| 30c | **The import undo keeps the control skips** — `POST /data/import/jobs/:jobId/undo` | rest | Get: an undo whose deletes and restores are written with `skipAutomations` and `skipStateMachine`, so no metadata-bound hook, record-change trigger or `state_machine` rule refuses a reversal. Every other caller, every signed-in user and administrator included, undoes under those controls: a reversal one of them refuses is reported per row in the undo answer's `failures` and kept in the job's undo log, and the job is stamped reverted only once every row is. A historical job's `preserveAudit` belongs to the job, not the caller, and rides every undo. Inbound HTTP never carries the flag, so this is an in-process caller's door | `importUndoWriteContext` |
### 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 |
+| # | Behaviour when `isSystem` | What you get / what you lose | Symbol |
|:--|:---|:---|:---|
-| 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 declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#bindDeclaredRuleNameGuard` |
+| 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 | `bindRuleHooks` |
+| 32 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `bypassVerdict` |
+| 33 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `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) | `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 | `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 | `listShares` |
+| 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `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 | `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" | `bindRuleProvenanceStamp` |
+| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `assertCanManageRules`, `bindDeclaredRuleNameGuard` |
### 4. Approvals, attachments, comments, knowledge
-| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
+| # | Behaviour when `isSystem` | Package | What you get / what you lose | Symbol |
|:--|:---|:---|:---|:---|
-| 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), the activity summary's message code (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, and the activity row's stored summary code (`metadata.summary_message`), 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), the removal of that summary code from the activity row's `metadata` (its params are field values), 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/activity-summary-message.ts#installActivitySummaryLocale`, `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` |
-| 47f | The file-reference claim path asks no read verdict of a system write: a record write claims or copies a referenced file only as its caller may read it (the download door's verdict), and a system write carries no caller to ask | service-storage | Get: a system write claims or copies any file the organization-scoped lookup reaches, as before #25692; a deliberate server-side copy between records runs this way. Lose: nothing a user held — a user write is judged by the download door's verdict, and a context-less programmatic write is not asked either | `packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf` |
+| 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` | `bindApprovalLockHook` |
+| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `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 | `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 | `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), the activity summary's message code (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, and the activity row's stored summary code (`metadata.summary_message`), 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), the removal of that summary code from the activity row's `metadata` (its params are field values), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `installCommentAccessHooks`, `installCommentReadVisibility`, `installActivityReadVisibility`, `installAuditLogReadVisibility`, `installActivityFieldRedaction`, `installAuditLogFieldRedaction`, `installActivitySummaryLocale`, `installParentFieldQueryGuard` |
+| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `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) | `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` | `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 | `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 | `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 | `requireUserContext` |
+| 47f | The file-reference claim path asks no read verdict of a system write: a record write claims or copies a referenced file only as its caller may read it (the download door's verdict), and a system write carries no caller to ask | service-storage | Get: a system write claims or copies any file the organization-scoped lookup reaches, as before #25692; a deliberate server-side copy between records runs this way. Lose: nothing a user held — a user write is judged by the download door's verdict, and a context-less programmatic write is not asked either | `referencingCallerOf` |
### 5. Actions, metadata plane, provenance, the organization wall
-| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
+| # | Behaviour when `isSystem` | Package | What you get / what you lose | Symbol |
|:--|:---|:---|:---|:---|
-| 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) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). The three doors ask one check, 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#flowStartRefusal` |
-| 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, and the declared-name guard does not run (a new row or a rename taking a declared webhook's name, 409) — the system writers are the platform's machinery (the legacy-secret sweep, the ownership ceremony, migrations) | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp`, `packages/plugins/plugin-webhooks/src/declared-webhooks.ts#bindDeclaredWebhookNameGuard` |
-| 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` |
+| 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 | `callData`, `refuseUnexposedDataAction` |
+| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `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 | `handleMetadataRequest`, `registerMetaCollectionRoutes`, `registerMetaItemWriteRoutes`, `registerMetaItemHistoryRoutes`, `metaSaveVerdict`, `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 | `metaWriteCapabilityVerdict` |
+| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `handleActionsRequest`, `handleAIRequest`, `handleAutomationRequest`, `handleMetadataRequest`, `handleSecurityRequest`, `handlePackagesRequest`, `handleAnalyticsRequest`, `handleI18nRequest`, `registerExternalDatasourceRoutes`, `refusePackageRequest` |
+| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `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 | `refusePackageRequest` |
+| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `requireManageMetadata`, `requireReadCapability` |
+| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `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 | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `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) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). The three doors ask one check, 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 | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal` |
+| 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 | `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, and the declared-name guard does not run (a new row or a rename taking a declared webhook's name, 409) — the system writers are the platform's machinery (the legacy-secret sweep, the ownership ceremony, migrations) | `isOrganizationWrite`, `bindWebhookProvenanceStamp`, `bindDeclaredWebhookNameGuard` |
+| 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 | `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 | `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 | `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
@@ -208,11 +211,11 @@ 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 |
+| # | Symbol | Package | What it does |
|:--|:---|:---|:---|
-| 63 | `packages/objectql/src/engine-execution-plumbing.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 |
+| 63 | `buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
+| 64 | `isSystem` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
+| 65 | `executionContextFromHook` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
---
@@ -340,9 +343,10 @@ 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.
+the list itself — every elevation read owned by a row, every anchor resolving —
+not a sum of it. Where a read lives is generated; which row describes it is a
+human's editorial call: name the read's symbol in a row's `Symbol` cell, or
+drop the row whose reads are gone.
**⛔ 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
@@ -379,7 +383,7 @@ restates none of its figures:
| 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 |
+| Of those reads: reads of `ExecutionContext.isSystem` | **the census answer**: every one sits in a symbol a row above names |
| — behaviour-bearing (rows 1–62 above) | sections 1 to 5 |
| — carry the flag onward only (rows 63–65 above) | section 6 |
@@ -447,7 +451,11 @@ 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.
+Renaming a symbol is now a loud red instead of a silent misdirection. The rows
+no longer write those anchors: a row names its symbols, and the
+[site register](#site-register) — generated, so a moved symbol needs no hand
+edit — writes one anchor per symbol. Only a name two files share is pinned in
+its row as a full anchor, and moves like one.
⚠️ **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
@@ -460,35 +468,190 @@ 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.
+ and the symbol set does not move — but that symbol's read count in the site
+ register does, so CI reds until the register is regenerated.** That is a
+ tripwire in the pull request's diff, not a row requirement: regenerating
+ clears it without touching the row, exactly as the old site total's generator
+ did, so whether the row still describes the symbol is the reviewer's call.
+ 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 fully 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
+be owned by a row here, and a symbol whose last read vanishes from the code
+leaves a row key that names no read, which is what makes a row describing a
protection that no longer exists fail.
-Four checks run, in `scripts/check-system-context-census.mjs`:
+Six 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 |
+| **Population** | an elevation read in a symbol no row's `Symbol` cell names — `--write` refuses to generate over it — or a file holding reads the page does not anchor |
+| **Row keys** | a key naming a symbol no census read lives in any more, a bare name two files share (pin it as `path#symbol`), or a row with no key that resolves to a read |
+| **Register** | a [site register](#site-register) that is not what `--write` generates from the tree: a read added, deleted or moved, or a key changed, without regenerating |
| **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.
+that introduces it — CI will say so when it lands in a symbol no row names
+yet; inside a symbol a row already names, the register's read count moves and
+CI reds until it is regenerated, and only the reviewer can say whether the row
+still describes it.
+
+## Site register
+
+Generated from the tree — never edit it by hand. One line per symbol the census
+finds an elevation read in: its full anchor, how many reads that symbol holds,
+and the rows above whose `Symbol` cell names it. A symbol that moves to another
+file moves here and nowhere else:
+`node scripts/check-system-context-census.mjs --write` rebuilds this register,
+and CI refuses one that differs from what the tree produces.
+
+{/* BEGIN GENERATED: system-context-census (scripts/check-system-context-census.mjs --write) — DO NOT EDIT */}
+
+| Anchor | Reads | Rows |
+|:---|--:|:---|
+| `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | 1 | 10 |
+| `packages/mcp/src/stdio-data-bridge.ts#enforceApiExposure` | 1 | 15 |
+| `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | 1 | 51 |
+| `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict` | 1 | 55b |
+| `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | 1 | 8 |
+| `packages/objectql/src/engine-driver-options.ts#buildDriverOptions` | 1 | 24 |
+| `packages/objectql/src/engine-execution-plumbing.ts#buildSession` | 1 | 63 |
+| `packages/objectql/src/engine-master-detail-related.ts#resolveOneHopRelated` | 1 | 29b |
+| `packages/objectql/src/engine-referential-integrity.ts#assertReferencesResolve` | 1 | 23 |
+| `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` | 1 | 29c |
+| `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` | 1 | 27 |
+| `packages/objectql/src/engine.ts#dependentCountIsDisclosable` | 1 | 28 |
+| `packages/objectql/src/engine.ts#inheritsElevation` | 1 | 1b |
+| `packages/objectql/src/engine.ts#insert` | 3 | 21, 22 |
+| `packages/objectql/src/engine.ts#isSystem` | 1 | 64 |
+| `packages/objectql/src/engine.ts#recordReferenceCheckElevation` | 1 | 29 |
+| `packages/objectql/src/engine.ts#update` | 4 | 19, 20 |
+| `packages/objectql/src/engine.ts#validate` | 1 | 21b |
+| `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | 1 | 13 |
+| `packages/plugins/organizations/src/organizations-plugin.ts#start` | 1 | 62 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#comment` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#reassign` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#remind` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#requestInfo` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#resolveActor` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#resubmit` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/approval-service.ts#sendBack` | 1 | 43 |
+| `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindApprovalLockHook` | 1 | 41 |
+| `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` | 1 | 42 |
+| `packages/plugins/plugin-approvals/src/payload-predicate-guard.ts#bindSnapshotPredicateGuard` | 1 | 17 |
+| `packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware` | 1 | 17 |
+| `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction` | 1 | 45 |
+| `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility` | 1 | 45 |
+| `packages/plugins/plugin-audit/src/activity-summary-message.ts#installActivitySummaryLocale` | 1 | 45 |
+| `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction` | 1 | 45 |
+| `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility` | 1 | 45 |
+| `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks` | 3 | 45 |
+| `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentReadVisibility` | 1 | 45 |
+| `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` | 1 | 45 |
+| `packages/plugins/plugin-audit/src/read-audit.ts#installReadAuditWriter` | 1 | 16 |
+| `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` | 1 | 26b |
+| `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | 1 | 12 |
+| `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` | 1 | 26 |
+| `packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront` | 1 | 61b |
+| `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite` | 1 | 59 |
+| `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` | 1 | 1b |
+| `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert` | 1 | 23d |
+| `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onUpdate` | 1 | 23d |
+| `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert` | 1 | 23c |
+| `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onUpdate` | 1 | 23c |
+| `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` | 1 | 23b |
+| `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` | 1 | 1, 2, 30 |
+| `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#checkControlledByParentWrite` | 1 | 9b |
+| `packages/plugins/plugin-security/src/security-plugin-explain-access.ts#explainAccessForCaller` | 2 | 9 |
+| `packages/plugins/plugin-security/src/security-plugin-field-readers.ts#resolveProjectionFieldMask` | 1 | 4 |
+| `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canExport` | 1 | 5 |
+| `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canReadObject` | 1 | 6 |
+| `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canWriteObject` | 1 | 6b |
+| `packages/plugins/plugin-security/src/security-plugin-read-filter.ts#getReadFilter` | 1 | 3 |
+| `packages/plugins/plugin-security/src/security-plugin-security-service.ts#registerSecurityService` | 3 | 7 |
+| `packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts#writeWallVerdict` | 1 | 6c |
+| `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | 1 | 58 |
+| `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite` | 1 | 25 |
+| `packages/plugins/plugin-sharing/src/rule-hooks.ts#bindRuleHooks` | 1 | 31 |
+| `packages/plugins/plugin-sharing/src/share-link-service.ts#createLink` | 3 | 38 |
+| `packages/plugins/plugin-sharing/src/share-link-service.ts#listLinks` | 1 | 38 |
+| `packages/plugins/plugin-sharing/src/share-link-service.ts#revokeLink` | 1 | 38 |
+| `packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware` | 1 | 37 |
+| `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp` | 1 | 39 |
+| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules` | 1 | 40 |
+| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#bindDeclaredRuleNameGuard` | 1 | 40 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts#assertCanManageShares` | 1 | 33 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts#bypassVerdict` | 1 | 32 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts#canManageShares` | 1 | 33 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts#grant` | 1 | 34 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts#listShares` | 1 | 36 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts#revoke` | 1 | 35 |
+| `packages/plugins/plugin-sharing/src/sharing-service.ts#shouldBypass` | 1 | 33 |
+| `packages/plugins/plugin-webhooks/src/declared-webhooks.ts#bindDeclaredWebhookNameGuard` | 1 | 59 |
+| `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | 1 | 59 |
+| `packages/rest/src/data-action-routes.ts#importUndoWriteContext` | 1 | 30c |
+| `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes` | 1 | 52 |
+| `packages/rest/src/import-prepare.ts#importControlOptOutRefusal` | 1 | 30b |
+| `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes` | 1 | 50 |
+| `packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes` | 2 | 50 |
+| `packages/rest/src/meta-item-write-routes.ts#registerMetaItemWriteRoutes` | 1 | 50 |
+| `packages/rest/src/package-routes.ts#refusePackageRequest` | 2 | 52, 54 |
+| `packages/rest/src/rest-server.ts#enforceAuth` | 1 | 18 |
+| `packages/rest/src/rest-server.ts#mayReadAuthoringTrail` | 1 | 8b |
+| `packages/rest/src/rest-server.ts#metaSaveVerdict` | 1 | 50 |
+| `packages/rest/src/rest-server.ts#walledCatalogRefusal` | 1 | 55b |
+| `packages/runtime/src/action-execution.ts#actionPermissionError` | 1 | 49 |
+| `packages/runtime/src/action-execution.ts#callData` | 1 | 48 |
+| `packages/runtime/src/action-execution.ts#refuseUnexposedDataAction` | 1 | 48 |
+| `packages/runtime/src/domains/actions.ts#handleActionsRequest` | 1 | 52 |
+| `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationAuthoring` | 1 | 56 |
+| `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite` | 1 | 56 |
+| `packages/runtime/src/domains/ai.ts#handleAIRequest` | 1 | 52 |
+| `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest` | 1 | 52 |
+| `packages/runtime/src/domains/automation.ts#handleAutomationRequest` | 1 | 52 |
+| `packages/runtime/src/domains/automation.ts#isRunStarterOrRunStateReader` | 1 | 57 |
+| `packages/runtime/src/domains/automation.ts#mayReadRunState` | 1 | 57 |
+| `packages/runtime/src/domains/automation.ts#refuseUngrantedFlowWrite` | 1 | 57 |
+| `packages/runtime/src/domains/automation.ts#refuseUngrantedRunLifecycleWrite` | 1 | 57 |
+| `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | 1 | 55b |
+| `packages/runtime/src/domains/i18n.ts#handleI18nRequest` | 1 | 52 |
+| `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | 1 | 53 |
+| `packages/runtime/src/domains/meta.ts#handleMetadataRequest` | 5 | 50, 52 |
+| `packages/runtime/src/domains/packages.ts#handlePackagesRequest` | 1 | 52 |
+| `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` | 1 | 55b |
+| `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` | 1 | 8b |
+| `packages/runtime/src/domains/packages.ts#requireManageMetadata` | 1 | 55 |
+| `packages/runtime/src/domains/packages.ts#requireReadCapability` | 1 | 55 |
+| `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` | 2 | 50 |
+| `packages/runtime/src/domains/security.ts#handleSecurityRequest` | 1 | 52 |
+| `packages/runtime/src/flow-start-admission.ts#flowStartRefusal` | 1 | 57 |
+| `packages/runtime/src/sandbox/body-runner.ts#executionContextFromHook` | 1 | 65 |
+| `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner` | 1 | 60 |
+| `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` | 1 | 46 |
+| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#authorizeRematch` | 1 | 47b |
+| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts#ingest` | 1 | 47b |
+| `packages/services/service-mailbox/src/mail-read-visibility.ts#gateMiddleware` | 1 | 47 |
+| `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts#syncAll` | 1 | 47c |
+| `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts#syncMailbox` | 1 | 47c |
+| `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` | 1 | 61 |
+| `packages/services/service-print/src/print-service.ts#refuseUnlessHumanCaller` | 1 | 47d |
+| `packages/services/service-settings/src/settings-read-door.ts#settingsReadDoorMiddleware` | 1 | 18b |
+| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks` | 3 | 44 |
+| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentReadVisibility` | 1 | 44 |
+| `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext` | 1 | 47e |
+| `packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf` | 1 | 47f |
+
+{/* END GENERATED: system-context-census */}
## Related
diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs
index 577f282b4aa..81b81076c92 100644
--- a/scripts/check-system-context-census.mjs
+++ b/scripts/check-system-context-census.mjs
@@ -7,7 +7,8 @@
*
* node scripts/check-system-context-census.mjs
* node scripts/check-system-context-census.mjs --self-test
- * node scripts/check-system-context-census.mjs --fix # RETIRED: refuses, exit 2 -- nothing on the page is generated
+ * node scripts/check-system-context-census.mjs --write # rebuild the page's generated site register, nothing else
+ * node scripts/check-system-context-census.mjs --fix # RETIRED: refuses, exit 2 -- names --write
*
* That page declares itself "the authority" for every platform behaviour keyed off
* `ExecutionContext.isSystem`, and says it is "built by census over the whole repo,
@@ -64,17 +65,20 @@
* resolving, and the census's symbol set for that file stops matching the
* page's.
* ⚠️ Delete ONE of several reads inside a symbol that keeps at least one, and
- * the symbol set does not move, so this gate may NOT red.
+ * the symbol set does not move, so the ANCHOR checks (A-D) may NOT red.
*
* That second line is the precision the line numbers had and these anchors do
* not. It is the reason the page carries the same warning where a reader meets
* the anchors: a gap stated on the instrument and not on the artifact is a gap
- * only the instrument's author knows about. ⛔ It is NOT closed by adding a
+ * only the instrument's author knows about. ⛔ It is NOT closed by a HAND-WRITTEN
* count of reads per file -- a count of reads cannot be satisfied by a page whose
* anchors are symbols, which is precisely why the population rule is per file and
- * per symbol.
+ * per symbol. Since #26442 the GENERATED site register counts reads per symbol,
+ * so that edit drifts the register and F reds until it is regenerated: a tripwire
+ * in the diff, cleared by `--write` without any row edit (section F says what that
+ * does and does not prove).
*
- * ## The four checks
+ * ## The checks
*
* A RESOLUTION delegated WHOLE to `sweepCorpus` over the `CORPUS`
* registration below: every anchor names a tracked file, every
@@ -93,6 +97,14 @@
* D CLASSIFICATION a symbol anchor the census does not call an elevation read
* must be a declared `NON_READ_ANCHORS` row, and that row's
* symbol must still be declared by its file.
+ * E ROW REFERENCES every `row N` in the prose and the ledger is held to the
+ * row its seam names (`PAGE_ROW_REFERENCES`).
+ * F SITE REGISTER (#26442) every census read is owned by a row that names its
+ * symbol, every row key names a read, and the generated register
+ * is byte-equal to what `--write` produces from the tree. ⭐ Since
+ * the rows name symbols and the register writes the anchors, F is
+ * where POPULATION's editorial half now lives: B holds the
+ * register's anchors, F holds who owns them.
*
* ## ⭐ The page states no totals (#22927), and the text decomposition is dated
*
@@ -162,20 +174,27 @@
* undeclared overlap reads as a row that excuses an anchor when it does not, and a
* declared overlap that has ended is a row nobody re-examined.
*
- * ## ⛔ `--fix` is retired, and refuses rather than vanishing
+ * ## ⛔ `--fix` is retired, and refuses rather than vanishing; `--write` is the one writer
*
* It had two lives. It re-anchored a pure line shift until symbol anchors gave
* that repair no subject (#15921), then rewrote the declared counts (#16919) until
- * the counts left the page (#22927). Nothing on the page is generated any more,
- * so `gen:system-context-census` and the page's `merge=os-regen` route went too.
- * ⛔ The flag is NOT silently accepted -- a `--fix` that writes nothing and exits 0
- * reads exactly like a repair that worked, and a branch cut before #22927 still
- * carries a merge-driver row that names the generator. It refuses with exit 2 and
- * says why.
- *
- * ⇒ Every red here is a HUMAN edit: a symbol was renamed (update the anchor),
- * a read arrived or vanished (write or delete the row), or a citation moved out of
- * the symbol that held it.
+ * the counts left the page (#22927), and `gen:system-context-census` and the
+ * page's `merge=os-regen` route went with them. ⛔ The flag is NOT silently
+ * accepted -- a `--fix` that writes nothing and exits 0 reads exactly like a repair
+ * that worked, and a branch cut before #22927 still carries a merge-driver row that
+ * names the generator. It refuses with exit 2 and names `--write`.
+ *
+ * `--write` (#26442) rebuilds the page's one generated half, the site register
+ * (section F), and touches no byte outside its marker pair. The page is still not
+ * `merge=os-regen` routed: like `tenant-audit-census.mdx`, it text-merges, the
+ * register's one-line-per-symbol shape keeps two read-adding branches apart, and a
+ * merge that leaves the region wrong is F's drift refusal in the merge queue.
+ *
+ * ⇒ A `[register-drift]` alone is `--write`. Every other red here is a HUMAN
+ * edit: a read arrived in a symbol no row names (add the name to the row that
+ * describes it, or a row), a row's key names nothing (re-key or drop the row), a
+ * name two files share (pin it), or a citation in the prose moved out of the
+ * symbol that held it.
*
* ## Refusals, never quiet passes (#4690)
*
@@ -262,20 +281,23 @@ const SELF_TEST_BATTERIES = Object.freeze({
'the dated decomposition: present and dated, never compared': 3,
'⛔ and the half that must NOT have moved: the contract still reds': 2,
'absence is loud': 1,
- '⛔ --fix is retired: the gate writes nothing, and the flag refuses': 4,
+ '⛔ --fix is retired, and only --write writes: the register, nothing else': 5,
'⭐ THE RULED RED-FIRST PAIR: a symbol rename REDS, a pure line move does NOT': 5,
- '⭐ the precision this trades away, pinned so nobody rediscovers it as a bug': 2,
+ '⭐ the precision the anchors trade away, and the register count that now catches it': 3,
'the refusal has to SHOW its work (both counts, the symbol, the file)': 2,
'⭐ CORPUS REGISTRATION: one resolver, not a second implementation': 3,
'WIRING: this gate, and its self-test, really run in CI': 2,
'POPULATION DECLARATION: what the dispatch derivation is told this gate reads': 6,
'⭐ ROW REFERENCES: held by seam, and the insertion that was silent (#15869)': 23,
+ '⭐ THE SITE REGISTER: generated, drift-checked, and every read owned by a row (#26442)': 12,
+ '⭐ THE REAL PAGE: the register is current, and an unregenerated read change reds it': 5,
});
// DELETING an entry silences that battery's floor exactly as effectively as
// zeroing it, so the roster's own size is pinned too. (18 -> 16 when #22927
-// retired the counts: 'counts' and the CRITERION battery tested only them.)
-const SELF_TEST_BATTERY_FLOOR = 16;
+// retired the counts: 'counts' and the CRITERION battery tested only them;
+// 16 -> 18 when #26442 generated the site register.)
+const SELF_TEST_BATTERY_FLOOR = 18;
// The key an assertion is filed under when no battery is open. It is not a
// declared battery, so it reds by the same set difference rather than silently
@@ -920,6 +942,340 @@ export function checkRowReferences({ pageText, ledger = NON_READ_ANCHORS, pageRe
return { problems, held };
}
+/* ─────────── The generated site register: the page's census half (#26442) ─────────── */
+
+/**
+ * ── Why the census half of this page is GENERATED, and the prose half is not ──
+ *
+ * Every behaviour row used to carry its anchor in full -- `packages/…/file.ts#symbol`
+ * -- so the page held two kinds of text in one cell: an editorial claim (THIS
+ * symbol's read is THIS behaviour) and a census fact (that symbol lives in THAT
+ * file). Only the first is a human's to write, and only the second churns. Measured
+ * at `00a4593ad7`: 84 commits touched the page in the 30 days before it, and 12 of
+ * them changed nothing on it but the path inside an anchor -- a refactor moved a
+ * symbol into a new file (`engine.ts` → `engine-driver-options.ts`,
+ * `security-plugin.ts` → `security-plugin-authz-middleware.ts`, …) and its author
+ * re-typed the path by hand, because this gate's `--fix` had nothing left to
+ * generate.
+ *
+ * So the two halves are split the way `scripts/tenant-audit-census.mjs` splits its
+ * page:
+ *
+ * ROW KEYS each behaviour row names the symbols its behaviour lives in, by
+ * NAME, in the table's `Symbol` column -- `registerAuthzMiddleware`,
+ * never the path. A name says which read the row is ABOUT and does
+ * not move when the symbol moves to another file.
+ * REGISTER a generated region (`REGISTER_BEGIN` … `REGISTER_END`) listing
+ * every census site as `path#symbol`, how many reads that symbol
+ * holds, and the rows whose keys name it. `--write` rebuilds it from
+ * the tree; the check refuses a committed region that differs.
+ *
+ * ⇒ A symbol moved to another file is a regeneration, never a hand edit: the key
+ * still names it, and the register moves the path.
+ *
+ * ## A key is a NAME, so it must name ONE file -- or be pinned
+ *
+ * A bare key resolves when exactly one file in the census holds reads in a symbol
+ * of that name. A name two files share (`isUserContextWrite`, `onInsert`) is
+ * ambiguous and is refused, naming every file -- the row then PINS it, writing the
+ * full `path#symbol` anchor the shared resolver also checks. ⚠️ A pinned key moves
+ * like the old anchors did: that is the price of the collision, paid only where it
+ * exists, and a refusal names it the day a new collision appears.
+ *
+ * ## ⭐ What the register adds over the old anchors, and what it does not
+ *
+ * ⭐ Every read is still owned by a row: a census site that no key names is
+ * `[read-without-a-row]`, and `--write` refuses to generate over it, so the
+ * mandatory CENSUS -> PAGE direction is still a human's editorial act.
+ * ⭐ A key whose name no census read carries any more is `[row-key-no-read]` --
+ * the deletion shape: the row stands, its site is gone.
+ * ⭐ The register counts reads PER SYMBOL. Adding or deleting one of several
+ * reads inside a symbol moves that count, so the committed region drifts and
+ * the gate reds until it is regenerated -- the gap the symbol anchors
+ * declared (see the header) now shows in the PR's diff.
+ * ⚠️ That count is a TRIPWIRE, not a row requirement: regenerating clears it
+ * without touching any row's prose, exactly as the retired count generator
+ * did. Whether the row still describes the symbol is the reviewer's call; the
+ * diff line is what tells them to make it.
+ *
+ * ## The page states no totals
+ *
+ * The register carries one line per symbol and no sum. A total line would be the
+ * one line every two read-adding PRs both rewrite (#22927's ruling, and the reason
+ * the page left `merge=os-regen`); per-symbol lines text-merge apart, and a merge
+ * that leaves the region wrong is this gate's drift refusal in the merge queue.
+ */
+export const REGISTER_BEGIN =
+ '{/* BEGIN GENERATED: system-context-census (scripts/check-system-context-census.mjs --write) — DO NOT EDIT */}';
+export const REGISTER_END = '{/* END GENERATED: system-context-census */}';
+
+/** The header a behaviour table names its key column with. */
+const KEY_COLUMN = 'Symbol';
+/** A row id: `12`, `1b`, `47f`. */
+const ROW_ID = /^\|\s*(\d+[a-z]?)\s*\|/;
+/** A bare key: an identifier, nothing else -- no path, no `#`. */
+const BARE_KEY = /^[A-Za-z_$][\w$]*$/;
+
+/** Split a markdown table line into cells, never splitting inside a code span. */
+function tableCells(line) {
+ const cells = [];
+ let cell = '';
+ let inSpan = false;
+ for (const ch of line) {
+ if (ch === '`') inSpan = !inSpan;
+ if (ch === '|' && !inSpan) {
+ cells.push(cell);
+ cell = '';
+ continue;
+ }
+ cell += ch;
+ }
+ cells.push(cell);
+ return cells;
+}
+
+/**
+ * Cut the page into its hand-written text and its generated region.
+ *
+ * @returns {{ prose: string, region: string, begin: number } | { error: string }}
+ */
+export function splitRegister(pageText) {
+ const begins = pageText.split(REGISTER_BEGIN).length - 1;
+ const ends = pageText.split(REGISTER_END).length - 1;
+ const begin = pageText.indexOf(REGISTER_BEGIN);
+ const end = pageText.indexOf(REGISTER_END);
+ if (begins !== 1 || ends !== 1 || end < begin) {
+ return {
+ error:
+ `${PAGE} carries ${begins} BEGIN and ${ends} END marker(s) of the generated site register -- ` +
+ 'exactly one of each, BEGIN first, is required. Restore the pair and run ' +
+ '`node scripts/check-system-context-census.mjs --write`; this gate does not guess where the ' +
+ 'register belongs.',
+ };
+ }
+ return {
+ prose: pageText.slice(0, begin) + pageText.slice(end + REGISTER_END.length),
+ region: pageText.slice(begin, end + REGISTER_END.length),
+ begin,
+ };
+}
+
+/** Replace the generated region, and nothing else. Throws on a missing marker pair. */
+export function spliceRegister(pageText, region) {
+ const split = splitRegister(pageText);
+ if ('error' in split) throw new Error(split.error);
+ const end = pageText.indexOf(REGISTER_END) + REGISTER_END.length;
+ return pageText.slice(0, split.begin) + region + pageText.slice(end);
+}
+
+/**
+ * Every row of every table whose header carries a `Symbol` column, with the keys
+ * its `Symbol` cell writes, in page order.
+ *
+ * A code span holding a bare identifier is a BARE key. A code span the shared
+ * extractor reads as an anchor is a PINNED key -- `{ file, symbol }`, or a
+ * file-level `{ file, symbol: null }`. Any other span is prose and is ignored.
+ *
+ * @returns {{ id: string, docLine: number, keys: ({ kind: 'bare', symbol: string } | { kind: 'pinned', file: string, symbol: string|null })[] }[]}
+ */
+export function extractRowKeys(pageText) {
+ const lines = pageText.split('\n');
+ const rows = [];
+ let keyAt = -1;
+ lines.forEach((line, i) => {
+ if (!line.startsWith('|')) {
+ keyAt = -1;
+ return;
+ }
+ if (/^\|\s*:?-/.test(lines[i + 1] ?? '')) {
+ keyAt = tableCells(line).map((cell) => cell.trim()).indexOf(KEY_COLUMN);
+ return;
+ }
+ const id = ROW_ID.exec(line);
+ if (keyAt === -1 || !id) return;
+ const cell = tableCells(line)[keyAt] ?? '';
+ const keys = [];
+ for (const anchor of extractAnchors(cell).anchors) {
+ if (anchor.repo) continue;
+ keys.push({ kind: 'pinned', file: anchor.path, symbol: anchor.symbol ?? null, index: anchor.index });
+ }
+ for (const m of cell.matchAll(/`([^`]+)`/g)) {
+ if (BARE_KEY.test(m[1])) keys.push({ kind: 'bare', symbol: m[1], index: m.index });
+ }
+ keys.sort((a, b) => a.index - b.index);
+ rows.push({ id: id[1], docLine: i + 1, keys: keys.map(({ index, ...key }) => key) });
+ });
+ return rows;
+}
+
+/** A census site's register key: `path#symbol`, or the bare path for a file-level read. */
+function siteKey(site) {
+ return site.symbol === null ? site.file : `${site.file}#${site.symbol}`;
+}
+
+/**
+ * Resolve every row key against the census, and every census site to the rows
+ * that own it.
+ *
+ * @returns {{ owners: Map, reads: Map, problems: string[], keys: number }}
+ */
+export function resolveRowKeys(rows, census) {
+ const problems = [];
+ /** @type {Map} site key -> reads in it */
+ const reads = new Map();
+ /** @type {Map>} symbol name -> files holding reads in a symbol of that name */
+ const filesBySymbol = new Map();
+ for (const site of census.sites) {
+ const key = siteKey(site);
+ reads.set(key, (reads.get(key) ?? 0) + 1);
+ if (site.symbol === null) continue;
+ if (!filesBySymbol.has(site.symbol)) filesBySymbol.set(site.symbol, new Set());
+ filesBySymbol.get(site.symbol).add(site.file);
+ }
+ /** @type {Map} site key -> owning row ids, in page order */
+ const owners = new Map();
+ const own = (key, id) => {
+ if (!owners.has(key)) owners.set(key, []);
+ if (!owners.get(key).includes(id)) owners.get(key).push(id);
+ };
+ let keys = 0;
+ for (const row of rows) {
+ let ownsARead = false;
+ for (const key of row.keys) {
+ keys += 1;
+ if (key.kind === 'pinned') {
+ /* A pinned anchor that is not a census site is a CITATION (`READONLY_CLASS_REASONS`
+ * beside row 22's read). It owns nothing; CLASSIFICATION (check D) decides whether
+ * it is a declared one. */
+ const pinned = key.symbol === null ? key.file : `${key.file}#${key.symbol}`;
+ if (reads.has(pinned)) {
+ own(pinned, row.id);
+ ownsARead = true;
+ }
+ continue;
+ }
+ const files = [...(filesBySymbol.get(key.symbol) ?? [])].sort();
+ if (files.length === 0) {
+ problems.push(
+ `[row-key-no-read] row ${row.id} (${PAGE}:${row.docLine}) names \`${key.symbol}\`, and no census ` +
+ 'read lives in a symbol of that name any more -- the read was deleted, or the symbol was ' +
+ 'renamed under the row. Re-key the row to the symbol that holds the read now, or drop the row.'
+ );
+ continue;
+ }
+ if (files.length > 1) {
+ problems.push(
+ `[row-key-ambiguous] row ${row.id} (${PAGE}:${row.docLine}) names \`${key.symbol}\`, and ` +
+ `${files.length} files hold reads in a symbol of that name (${files.join(', ')}). A bare ` +
+ `key must name one file: pin it as \`${files[0]}#${key.symbol}\` (or whichever this row ` +
+ 'is about) in every row that means one of them.'
+ );
+ continue;
+ }
+ own(`${files[0]}#${key.symbol}`, row.id);
+ ownsARead = true;
+ }
+ if (!ownsARead && row.keys.length > 0) {
+ problems.push(
+ `[row-without-a-read] row ${row.id} (${PAGE}:${row.docLine}) keys no census read at all -- its ` +
+ 'Symbol cell names only citations or keys that resolved to nothing. A behaviour row is a ' +
+ 'read the code holds; one whose reads are gone describes a protection that no longer exists.'
+ );
+ } else if (row.keys.length === 0) {
+ problems.push(
+ `[row-without-a-key] row ${row.id} (${PAGE}:${row.docLine}) writes nothing in its \`${KEY_COLUMN}\` ` +
+ 'cell, so no read is tied to it and the register cannot name it.'
+ );
+ }
+ }
+ for (const site of [...census.sites].sort((a, b) => siteKey(a).localeCompare(siteKey(b)))) {
+ const key = siteKey(site);
+ if (owners.has(key)) continue;
+ owners.set(key, []);
+ problems.push(
+ `[read-without-a-row] ${key} holds ${reads.get(key)} elevation read(s) ` +
+ `(\`${site.text.trim().slice(0, 90)}\`) and no row's \`${KEY_COLUMN}\` cell names \`${site.symbol ?? key}\`. ` +
+ 'Either the page is missing this behaviour -- add a row, or add the name to the row that ' +
+ 'describes it -- or a key was renamed away from it. `--write` refuses until a row owns it.'
+ );
+ }
+ return { owners, reads, problems, keys };
+}
+
+/** Render the generated region: one line per census site, sorted, no totals. */
+export function renderRegister({ owners, reads }) {
+ const lines = [
+ REGISTER_BEGIN,
+ '',
+ '| Anchor | Reads | Rows |',
+ '|:---|--:|:---|',
+ ];
+ for (const key of [...reads.keys()].sort()) {
+ const rows = owners.get(key) ?? [];
+ lines.push(`| \`${key}\` | ${reads.get(key)} | ${rows.length > 0 ? rows.join(', ') : '—'} |`);
+ }
+ lines.push('', REGISTER_END);
+ return lines.join('\n');
+}
+
+/** The first line at which two texts differ, so a drift refusal names it. */
+function firstDifference(committed, expected) {
+ const a = committed.split('\n');
+ const b = expected.split('\n');
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
+ if (a[i] !== b[i]) {
+ return { line: i + 1, committed: a[i] ?? '(region ends here)', expected: b[i] ?? '(census ends here)' };
+ }
+ }
+ return null;
+}
+
+/**
+ * F. THE SITE REGISTER -- the row keys resolve, every read has a row, and the
+ * committed region is exactly what the tree generates. Pure, so `--self-test`
+ * drives it on fixtures and on the real page.
+ *
+ * @returns {{ problems: string[], expected: string|null, stats: { rows: number, keys: number, registered: number } }}
+ */
+export function evaluateRegister({ pageText, census }) {
+ const problems = [];
+ const split = splitRegister(pageText);
+ if ('error' in split) {
+ problems.push(`[no-register] ${split.error}`);
+ return { problems, expected: null, stats: { rows: 0, keys: 0, registered: 0 } };
+ }
+ const rows = extractRowKeys(split.prose);
+ if (rows.length === 0) {
+ problems.push(
+ `[no-row-keys] the page yielded ZERO rows under a \`${KEY_COLUMN}\` column, so no read could be ` +
+ 'owned by anything and the register would be generated from nothing -- refusing instead. A ' +
+ `table's key column was renamed away from \`${KEY_COLUMN}\`, or the tables are gone.`
+ );
+ return { problems, expected: null, stats: { rows: 0, keys: 0, registered: 0 } };
+ }
+ const resolved = resolveRowKeys(rows, census);
+ problems.push(...resolved.problems);
+ const expected = renderRegister(resolved);
+ if (split.region !== expected) {
+ const diff = firstDifference(split.region, expected);
+ problems.push(
+ '[register-drift] the generated site register on the page is not what the tree produces -- a read ' +
+ 'was added, deleted or moved, or a row key changed, and the region was not regenerated' +
+ (diff
+ ? `. First difference at register line ${diff.line}:\n` +
+ ` committed : ${diff.committed}\n` +
+ ` census : ${diff.expected}\n`
+ : '.\n') +
+ ' Fix: node scripts/check-system-context-census.mjs --write'
+ );
+ }
+ return {
+ problems,
+ expected,
+ stats: { rows: rows.length, keys: resolved.keys, registered: resolved.reads.size },
+ };
+}
+
/**
* ⛔ The six numbers this gate deliberately does NOT hold to the census -- the
* only figures the page still carries -- listed here so that stays a decision
@@ -1317,24 +1673,72 @@ function readFileAt(root) {
/**
* ⛔ `--fix` is RETIRED, and says so instead of exiting 0 in silence.
*
- * Nothing on the page is generated: its anchors are symbols, which encode no
- * position to repair (#15921), and it states no census totals (#22927). A
- * recognised flag that quietly ran the check would read as a regeneration that
- * worked -- to a person, and to a branch cut before #22927 whose merge-driver row
- * still names `gen:system-context-census`. So it refuses, before any census runs.
+ * It named two repairs that are gone -- the line-shift re-anchor (#15921) and the
+ * census-total rewrite (#22927) -- and a recognised flag that quietly ran the check
+ * would read as a regeneration that worked, to a person and to a branch cut before
+ * #22927 whose merge-driver row still names `gen:system-context-census`. The page's
+ * one generated half now is the site register, and `--write` is the only flag that
+ * writes it (#26442). So `--fix` refuses, before any census runs, and names
+ * `--write`.
*/
const FIX_RETIRED_EXIT = 2;
function refuseRetiredFix() {
process.stderr.write(
- 'check-system-context-census --fix: RETIRED -- nothing on the page is generated, so there is\n' +
- ' nothing to rewrite. Anchors are `path#symbol` (scripts/symbol-anchors.mjs) and the page states\n' +
- ' no census totals: `node scripts/isystem-census.mjs` prints them. Every red here is a human\n' +
- ' edit -- a renamed symbol, or a read that arrived or vanished (add or drop its row).\n'
+ 'check-system-context-census --fix: RETIRED -- it named the line-shift re-anchor and the census-total\n' +
+ ' rewrite, both gone. The page\'s one generated half is the site register:\n' +
+ ' `node scripts/check-system-context-census.mjs --write` rebuilds it and touches nothing else.\n' +
+ ' The page states no totals: `node scripts/isystem-census.mjs` prints them.\n'
);
return FIX_RETIRED_EXIT;
}
+/**
+ * `--write`: rebuild the site register from the tree, and nothing else.
+ *
+ * ⛔ It refuses -- writing nothing -- while a row key does not resolve or a read has
+ * no row: the register's `Rows` column is the editorial claim, and a generator that
+ * filled it with a dash would land a read no row describes. Everything outside the
+ * marker pair is byte-identical after a write (`spliceRegister`).
+ */
+function write() {
+ const readFile = readFileAt(ROOT);
+ let pageText;
+ try {
+ pageText = readFile(PAGE);
+ } catch (error) {
+ process.stderr.write(`::error::[unreadable-page] ${PAGE} could not be read -- ${error.message}\n`);
+ return 1;
+ }
+ const split = splitRegister(pageText);
+ if ('error' in split) {
+ process.stderr.write(`::error::[no-register] ${split.error}\n`);
+ return 1;
+ }
+ const rows = extractRowKeys(split.prose);
+ const resolved = resolveRowKeys(rows, runCensus({ root: ROOT }));
+ if (rows.length === 0 || resolved.problems.length > 0) {
+ for (const problem of resolved.problems) process.stderr.write(`::error::${problem}\n`);
+ process.stderr.write(
+ `\ncheck-system-context-census --write: REFUSED, nothing written -- ${rows.length} keyed row(s), ` +
+ `${resolved.problems.length} problem(s). Every read needs a row that names its symbol before the ` +
+ 'register can say which row owns it.\n'
+ );
+ return 1;
+ }
+ const next = spliceRegister(pageText, renderRegister(resolved));
+ if (next === pageText) {
+ process.stdout.write(`check-system-context-census --write: ${PAGE} register already current\n`);
+ return 0;
+ }
+ writeFileSync(join(ROOT, PAGE), next);
+ process.stdout.write(
+ `check-system-context-census --write: rewrote the site register in ${PAGE} ` +
+ `(${resolved.reads.size} symbol(s), ${rows.length} keyed row(s))\n`
+ );
+ return 0;
+}
+
function run() {
const readFile = readFileAt(ROOT);
let pageText;
@@ -1357,14 +1761,19 @@ function run() {
return 1;
}
- const { problems, stats } = evaluate({ pageText, census, readFile, sweep });
+ const { problems: anchorProblems, stats } = evaluate({ pageText, census, readFile, sweep });
+ const register = evaluateRegister({ pageText, census });
+ const problems = [...anchorProblems, ...register.problems];
for (const problem of problems) process.stderr.write(`::error::${problem}\n`);
if (problems.length > 0) {
process.stderr.write(
`\ncheck-system-context-census: ${problems.length} problem(s) over ${stats.anchors} anchors ` +
`and ${stats.sites} census sites.\n` +
- 'Re-run the census with `node scripts/isystem-census.mjs --json`. ⛔ Every finding here is a ' +
- 'human edit -- nothing on the page is generated.\n' +
+ 'The site register is GENERATED: a moved, added or deleted read in a symbol a row already ' +
+ 'names is `node scripts/check-system-context-census.mjs --write`, and the anchor findings it ' +
+ 'raises clear with it. Everything else is a human edit -- a row key (`[read-without-a-row]`, ' +
+ '`[row-key-*]`), a NON_READ_ANCHORS row, or an anchor in the prose. ' +
+ 'Re-run the census with `node scripts/isystem-census.mjs --json`.\n' +
`\nThe anchor grammar:\n ${ANCHOR_GRAMMAR}\n`
);
return 1;
@@ -1375,8 +1784,10 @@ function run() {
`cites ${stats.citedSymbols} symbol(s) against ${stats.requiredSymbols} required, over ` +
`${stats.anchors} anchors and ${stats.fileLevelAnchors} file-level citation(s); ` +
`${stats.nonReadAnchors} declared non-read; ${stats.collapsingFiles} file(s) hold more than one ` +
- `read in one symbol (the declared precision loss); ${stats.rowRefsHeld} row reference(s) resolve ` +
- `to their keyed row, ${stats.rowRefsUnheld} declared unheld.\n`
+ `read in one symbol (counted per symbol in the register); ${stats.rowRefsHeld} row reference(s) resolve ` +
+ `to their keyed row, ${stats.rowRefsUnheld} declared unheld; ${register.stats.keys} key(s) in ` +
+ `${register.stats.rows} row(s) own every read, and the generated register (${register.stats.registered} ` +
+ 'symbol(s)) matches the tree.\n'
);
return 0;
}
@@ -1626,6 +2037,48 @@ function fixtureUnenforcedTable({ linesTotal = 6, dropTestsRow = false, dated =
].join('\n');
}
+/**
+ * ── The SITE REGISTER fixture (#26442) ──────────────────────────────────────
+ *
+ * Two rows keyed by NAME, two census sites in two files, and a register generated
+ * by the same three calls `--write` makes. Every case below changes ONE input --
+ * the census or one key -- so the difference it reports is that change's alone.
+ */
+const REGISTER_FIXTURE_CENSUS = {
+ ...FIXTURE_CENSUS,
+ sites: [
+ { file: 'pkg/a.ts', line: 2, receiver: 'ctx', package: 'pkg', symbol: 'handler', text: 'if (ctx.isSystem) return ALLOW;' },
+ { file: 'pkg/b.ts', line: 4, receiver: 'ctx', package: 'pkg', symbol: 'carry', text: 'isSystem: ctx.isSystem,' },
+ ],
+ files: ['pkg/a.ts', 'pkg/b.ts'],
+};
+
+/** The fixture's prose: a `Symbol`-keyed table and an EMPTY marker pair. */
+function registerFixtureProse({ keys = ['`handler`', '`carry`'], header = KEY_COLUMN, markers = true } = {}) {
+ return [
+ '---',
+ 'title: register fixture',
+ '---',
+ '',
+ `| # | Behaviour | ${header} |`,
+ '|:--|:---|:---|',
+ `| 1 | **The short-circuit** runs first | ${keys[0]} |`,
+ `| 1b | carries the flag onward | ${keys[1]} |`,
+ '',
+ '## Site register',
+ '',
+ ...(markers ? [REGISTER_BEGIN, REGISTER_END] : []),
+ '',
+ ].join('\n');
+}
+
+/** Generate the fixture's register exactly as `--write` does: split, key, resolve, render, splice. */
+function generateRegister(pageText, census) {
+ const split = splitRegister(pageText);
+ const resolved = resolveRowKeys(extractRowKeys(split.prose), census);
+ return { page: spliceRegister(pageText, renderRegister(resolved)), problems: resolved.problems };
+}
+
// Set by `selfTest()` only after its verdict is printed, and read at the
// dispatch: a `return` that leaves the function above that line prints nothing
// and still exits 0 — a self-test that never finished, reported as one that
@@ -1873,14 +2326,15 @@ function selfTest() {
const noAnchors = run('---\ntitle: x\n---\n\nnothing here.\n');
t('ABSENCE: a page with no anchors refuses', noAnchors.problems.some((p) => p.startsWith('[no-anchors]')));
- // ── ⛔ --fix is retired: the gate writes nothing, and the flag refuses ─────
+ // ── ⛔ --fix is retired, and only --write writes: the register, nothing else ──
//
- // ⭐ Two failures this pins, in opposite directions. A `--fix` that quietly ran
- // the check would read as a regeneration that worked, so the flag must REFUSE --
- // run as a child, because the refusal is the dispatch's. And a gate that writes
- // the page again -- the retired line-shift repair, or a count regenerator -- is a
- // generated half coming back to a page #22927 made pure prose.
- battery('⛔ --fix is retired: the gate writes nothing, and the flag refuses');
+ // ⭐ Three failures this pins. A `--fix` that quietly ran the check would read as
+ // a regeneration that worked, so the flag must REFUSE -- run as a child, because
+ // the refusal is the dispatch's. A gate that rewrites the page's PROSE again --
+ // the retired line-shift repair, or a count regenerator -- is a generated half
+ // coming back where #22927 ruled it out. And the one write that is allowed,
+ // `--write` (#26442), must touch the register and not one byte around it.
+ battery('⛔ --fix is retired, and only --write writes: the register, nothing else');
let ownSourceForFix = null;
try {
ownSourceForFix = readFileSync(join(ROOT, 'scripts/check-system-context-census.mjs'), 'utf8');
@@ -1893,10 +2347,28 @@ function selfTest() {
!/\bfixAnchors\b/.test(ownSourceForFix),
'the retired line-shift repair is back in this file'
);
+ const pageWrites = ownSourceForFix.match(/writeFileSync\(\s*join\(ROOT, PAGE\)/g) ?? [];
+ const writeBody = /\nfunction write\(\) \{[\s\S]*?\n\}\n/.exec(ownSourceForFix)?.[0] ?? '';
+ t(
+ '--write: the page is written in exactly ONE place, inside `write()`, and from a splice -- no ' +
+ 'count regenerator survives',
+ pageWrites.length === 1 &&
+ /writeFileSync\(\s*join\(ROOT, PAGE\), next\)/.test(writeBody) &&
+ /const next = spliceRegister\(pageText, renderRegister\(resolved\)\);/.test(writeBody) &&
+ !/^export (?:const|function) (?:DECLARED_COUNTS|regenerateDeclaredCounts)\b/m.test(ownSourceForFix),
+ `${pageWrites.length} page write(s); write() found: ${writeBody.length > 0}`
+ );
+ }
+ {
+ const prose = ['intro', '', REGISTER_BEGIN, '| `old` |', REGISTER_END, '', 'outro `pkg/a.ts#handler`', ''].join('\n');
+ const spliced = spliceRegister(prose, [REGISTER_BEGIN, '| `new` |', REGISTER_END].join('\n'));
+ const before = splitRegister(prose);
+ const after = splitRegister(spliced);
t(
- '--fix: nothing here writes the page, and no count regenerator survives -- the page is pure prose',
- !/writeFileSync\(\s*join\(ROOT, PAGE\)/.test(ownSourceForFix) &&
- !/^export (?:const|function) (?:DECLARED_COUNTS|regenerateDeclaredCounts)\b/m.test(ownSourceForFix)
+ '--write: a splice replaces the register and leaves EVERY byte outside the marker pair identical',
+ !('error' in after) && after.prose === before.prose && after.region.includes('`new`') &&
+ !spliced.includes('`old`'),
+ JSON.stringify(spliced)
);
}
let fixRun = null;
@@ -1915,9 +2387,11 @@ function selfTest() {
`status ${fixRun?.status}`
);
t(
- '--fix REFUSES: and says why, naming where the live count lives now',
- /RETIRED/.test(fixRun?.stderr ?? '') && /node scripts\/isystem-census\.mjs/.test(fixRun?.stderr ?? ''),
- (fixRun?.stderr ?? '').replace(/\s+/g, ' ').slice(0, 160)
+ '--fix REFUSES: and says why, naming `--write` and where the live count lives now',
+ /RETIRED/.test(fixRun?.stderr ?? '') &&
+ /check-system-context-census\.mjs --write/.test(fixRun?.stderr ?? '') &&
+ /node scripts\/isystem-census\.mjs/.test(fixRun?.stderr ?? ''),
+ (fixRun?.stderr ?? '').replace(/\s+/g, ' ').slice(0, 200)
);
// ── ⭐ THE RULED RED-FIRST PAIR, on a real tree ─────────────────────────────
@@ -2017,7 +2491,7 @@ function selfTest() {
// something else. Two reads inside ONE symbol, one of them added or deleted: the
// symbol set does not move, and this gate does not red. (Until #22927 the page's
// site total moved on that edit -- see the header for what that tripwire was.)
- battery('⭐ the precision this trades away, pinned so nobody rediscovers it as a bug');
+ battery('⭐ the precision the anchors trade away, and the register count that now catches it');
const twoInOne = {
...FIXTURE_CENSUS,
sites: [
@@ -2028,11 +2502,33 @@ function selfTest() {
const bothPresent = run(fixturePage(), twoInOne);
const oneDeleted = run(fixturePage(), FIXTURE_CENSUS);
t(
- '⛔ THE DECLARED GAP: adding or deleting one of two reads inside one symbol does NOT red -- ' +
- 'stated here rather than discovered later',
+ '⛔ THE DECLARED GAP, in the ANCHOR verdict: adding or deleting one of two reads inside one symbol ' +
+ 'does NOT red `evaluate` -- stated here rather than discovered later',
bothPresent.problems.length === 0 && oneDeleted.problems.length === 0,
`${bothPresent.problems.join(' | ')} // ${oneDeleted.problems.join(' | ')}`
);
+ {
+ /* ⭐ #26442: the register counts reads PER SYMBOL, so the same edit moves a
+ * generated line and the WHOLE gate reds until it is regenerated. A tripwire
+ * in the diff, not a row requirement -- the header says so. */
+ const oneRead = generateRegister(registerFixtureProse(), REGISTER_FIXTURE_CENSUS).page;
+ const secondRead = {
+ ...REGISTER_FIXTURE_CENSUS,
+ sites: [
+ ...REGISTER_FIXTURE_CENSUS.sites,
+ { file: 'pkg/a.ts', line: 3, receiver: 'opts', package: 'pkg', symbol: 'handler', text: 'opts.isSystem' },
+ ],
+ };
+ const counted = evaluateRegister({ pageText: oneRead, census: secondRead });
+ t(
+ '⭐ and the REGISTER closes it: the second read inside `handler` moves its count, so the committed ' +
+ 'register drifts -- red, with no row-key finding, until `--write` regenerates it',
+ counted.problems.length === 1 &&
+ counted.problems[0].startsWith('[register-drift]') &&
+ counted.problems[0].includes('| `pkg/a.ts#handler` | 2 | 1 |'),
+ counted.problems.join(' | ')
+ );
+ }
t(
'⭐ and the half that DOES hold: deleting the whole symbol reds, so the gap is bounded',
run(fixturePage(), { ...FIXTURE_CENSUS, sites: [{ file: 'pkg/a.ts', line: 8, receiver: 'ctx', package: 'pkg', symbol: 'unrelated', text: 'x' }] })
@@ -2296,6 +2792,209 @@ function selfTest() {
}
}
+ // ── ⭐ THE SITE REGISTER: generated, drift-checked, every read owned by a row ──
+ //
+ // #26442. The page's census half is generated, so the two things this battery
+ // must show are the two the card was ruled on: a symbol MOVE needs no hand edit
+ // (the key is a name; regenerating moves the path), and the drift check still
+ // reds every read added, deleted or moved without regenerating. Plus the refusals
+ // that keep the CENSUS -> PAGE direction a human's: a read no row names, a key
+ // that names nothing, a name two files share.
+ battery('⭐ THE SITE REGISTER: generated, drift-checked, and every read owned by a row (#26442)');
+ const regGreen = generateRegister(registerFixtureProse(), REGISTER_FIXTURE_CENSUS);
+ const regCheck = (page, census = REGISTER_FIXTURE_CENSUS) => evaluateRegister({ pageText: page, census });
+ const regCodes = (result) => result.problems.map((p) => p.slice(0, p.indexOf(']') + 1));
+ t(
+ 'REGISTER control: a page whose register was generated from this census reports nothing, and the ' +
+ 'register really lists both sites with their owning rows',
+ regGreen.problems.length === 0 &&
+ regCheck(regGreen.page).problems.length === 0 &&
+ regGreen.page.includes('| `pkg/a.ts#handler` | 1 | 1 |') &&
+ regGreen.page.includes('| `pkg/b.ts#carry` | 1 | 1b |'),
+ regCheck(regGreen.page).problems.join(' | ')
+ );
+
+ // ⭐ the MOVE: `carry` leaves pkg/b.ts for pkg/c.ts, name unchanged.
+ const movedCensus = {
+ ...REGISTER_FIXTURE_CENSUS,
+ sites: REGISTER_FIXTURE_CENSUS.sites.map((site) => (site.symbol === 'carry' ? { ...site, file: 'pkg/c.ts' } : site)),
+ };
+ const staleAfterMove = regCheck(regGreen.page, movedCensus);
+ t(
+ '⭐ MOVE, not regenerated: the committed register DRIFTS and the refusal names the new path -- ' +
+ 'and the move raises no row-key finding, because no row has to change',
+ regCodes(staleAfterMove).join(',') === '[register-drift]' &&
+ staleAfterMove.problems[0].includes('pkg/c.ts#carry'),
+ staleAfterMove.problems.join(' | ')
+ );
+ const regenerated = generateRegister(regGreen.page, movedCensus);
+ t(
+ '⭐ MOVE, regenerated: `--write` clears it with ZERO hand edits -- every byte outside the register is ' +
+ 'the page before the move',
+ regenerated.problems.length === 0 &&
+ regCheck(regenerated.page, movedCensus).problems.length === 0 &&
+ splitRegister(regenerated.page).prose === splitRegister(regGreen.page).prose &&
+ regenerated.page.includes('| `pkg/c.ts#carry` | 1 | 1b |') &&
+ !regenerated.page.includes('pkg/b.ts#carry'),
+ regCheck(regenerated.page, movedCensus).problems.join(' | ')
+ );
+
+ // ⭐ the ARRIVAL: a read in a symbol no key names.
+ const arrivedCensus = {
+ ...REGISTER_FIXTURE_CENSUS,
+ sites: [
+ ...REGISTER_FIXTURE_CENSUS.sites,
+ { file: 'pkg/b.ts', line: 9, receiver: 'ctx', package: 'pkg', symbol: 'fresh', text: 'if (ctx.isSystem) skip();' },
+ ],
+ };
+ const unowned = regCheck(regGreen.page, arrivedCensus);
+ t(
+ '⭐ ARRIVAL: a read in a symbol no row names is `[read-without-a-row]`, naming the anchor -- and the ' +
+ 'register drifts too',
+ unowned.problems.some((p) => p.startsWith('[read-without-a-row] pkg/b.ts#fresh holds 1')) &&
+ regCodes(unowned).includes('[register-drift]'),
+ unowned.problems.join(' | ')
+ );
+ t(
+ '⭐ ARRIVAL: and `--write` REFUSES over it -- the generator never assigns a read to a row, a human does',
+ generateRegister(regGreen.page, arrivedCensus).problems.some((p) => p.startsWith('[read-without-a-row]'))
+ );
+
+ // the DELETION shape: the row stands, its read is gone.
+ const deletedCensus = { ...REGISTER_FIXTURE_CENSUS, sites: [REGISTER_FIXTURE_CENSUS.sites[0]] };
+ const orphaned = regCheck(regGreen.page, deletedCensus);
+ t(
+ 'DELETION: a key whose name no census read carries is `[row-key-no-read]`, and the row that keyed ' +
+ 'only it is `[row-without-a-read]`',
+ orphaned.problems.some((p) => p.startsWith('[row-key-no-read] row 1b') && p.includes('`carry`')) &&
+ orphaned.problems.some((p) => p.startsWith('[row-without-a-read] row 1b')),
+ orphaned.problems.join(' | ')
+ );
+
+ // a NAME two files share must be pinned.
+ const collidingCensus = {
+ ...REGISTER_FIXTURE_CENSUS,
+ sites: [
+ ...REGISTER_FIXTURE_CENSUS.sites,
+ { file: 'pkg/d.ts', line: 1, receiver: 'ctx', package: 'pkg', symbol: 'carry', text: 'ctx.isSystem' },
+ ],
+ };
+ const ambiguous = regCheck(regGreen.page, collidingCensus);
+ t(
+ 'COLLISION: a bare key two files hold reads under is `[row-key-ambiguous]`, naming both files',
+ ambiguous.problems.some(
+ (p) => p.startsWith('[row-key-ambiguous] row 1b') && p.includes('pkg/b.ts') && p.includes('pkg/d.ts')
+ ),
+ ambiguous.problems.join(' | ')
+ );
+ const pinnedProse = registerFixtureProse({ keys: ['`handler`', '`pkg/b.ts#carry`, `pkg/d.ts#carry`'] });
+ const pinned = generateRegister(pinnedProse, collidingCensus);
+ t(
+ 'COLLISION: PINNING the key with its full anchor resolves it -- both sites owned, nothing refused',
+ pinned.problems.length === 0 &&
+ regCheck(pinned.page, collidingCensus).problems.length === 0 &&
+ pinned.page.includes('| `pkg/d.ts#carry` | 1 | 1b |'),
+ [...pinned.problems, ...regCheck(pinned.page, collidingCensus).problems].join(' | ')
+ );
+
+ // absence is loud, here too.
+ t(
+ 'ABSENCE: a page with no marker pair is `[no-register]` -- never "nothing to compare"',
+ regCodes(regCheck(registerFixtureProse({ markers: false }))).join(',') === '[no-register]'
+ );
+ t(
+ 'ABSENCE: a SECOND marker pair is refused as well -- the gate does not guess which one is the register',
+ regCodes(regCheck(`${regGreen.page}\n${REGISTER_BEGIN}\n${REGISTER_END}\n`)).join(',') === '[no-register]'
+ );
+ t(
+ 'ABSENCE: a key column renamed away from `Symbol` leaves ZERO keyed rows, which REFUSES rather than ' +
+ 'generating an ownerless register',
+ regCodes(regCheck(generateRegister(registerFixtureProse(), REGISTER_FIXTURE_CENSUS).page.replace(
+ `| # | Behaviour | ${KEY_COLUMN} |`, '| # | Behaviour | Anchor |'
+ ))).join(',') === '[no-row-keys]'
+ );
+ t(
+ 'REFUSAL: the drift refusal SHOWS its work -- the committed line, the census line and the fix command',
+ /committed : \| `pkg\/b\.ts#carry` \| 1 \| 1b \|/.test(staleAfterMove.problems[0] ?? '') &&
+ /census {4}: \| `pkg\/c\.ts#carry` \| 1 \| 1b \|/.test(staleAfterMove.problems[0] ?? '') &&
+ (staleAfterMove.problems[0] ?? '').includes('node scripts/check-system-context-census.mjs --write'),
+ staleAfterMove.problems[0]
+ );
+
+ // ── ⭐ THE REAL PAGE: its register is current, and the real tree's reads still red it ──
+ //
+ // The fixture proves the mechanism; this proves the PAGE carries it. The real
+ // census is mutated IN MEMORY -- one read added inside a keyed symbol, one keyed
+ // symbol moved to a new file, one read in a symbol no row names -- and the real
+ // page is never written (its bytes are compared before and after).
+ battery('⭐ THE REAL PAGE: the register is current, and an unregenerated read change reds it');
+ let realRegisterPage = null;
+ let realCensus = null;
+ try {
+ realRegisterPage = readFileSync(join(ROOT, PAGE), 'utf8');
+ realCensus = runCensus({ root: ROOT });
+ } catch (err) {
+ t('REAL: the page and the census are readable', false, err.code ?? err.message);
+ }
+ if (realRegisterPage !== null && realCensus !== null) {
+ const pageHash = createHash('sha256').update(realRegisterPage).digest('hex');
+ const today = evaluateRegister({ pageText: realRegisterPage, census: realCensus });
+ t(
+ "⭐ TODAY'S TREE: every read on the real page is owned by a keyed row and the committed register is " +
+ 'exactly what the tree generates',
+ today.problems.length === 0 && today.stats.rows > 0 && today.stats.registered > 0,
+ today.problems.join(' | ').slice(0, 400)
+ );
+ const probe = realCensus.sites.find((site) => site.file === 'packages/objectql/src/engine-driver-options.ts' && site.symbol === 'buildDriverOptions');
+ if (!probe) {
+ t('REAL: the probe symbol `engine-driver-options.ts#buildDriverOptions` still holds a read', false, 're-pick a probe');
+ } else {
+ const added = evaluateRegister({
+ pageText: realRegisterPage,
+ census: { ...realCensus, sites: [...realCensus.sites, { ...probe, line: probe.line + 1 }] },
+ });
+ t(
+ '⭐ REAL, ADDED: one more read inside `buildDriverOptions` (a symbol row 24 keys) reds the real page ' +
+ 'with the drift refusal alone -- no row has to change, the count does',
+ regCodes(added).join(',') === '[register-drift]' &&
+ added.problems[0].includes('packages/objectql/src/engine-driver-options.ts#buildDriverOptions'),
+ added.problems.join(' | ').slice(0, 400)
+ );
+ const movedReal = {
+ ...realCensus,
+ sites: realCensus.sites.map((site) =>
+ site.symbol === 'buildDriverOptions' ? { ...site, file: 'packages/objectql/src/engine-moved-for-the-probe.ts' } : site
+ ),
+ };
+ const moved = evaluateRegister({ pageText: realRegisterPage, census: movedReal });
+ const regenReal = generateRegister(realRegisterPage, movedReal);
+ t(
+ '⭐ REAL, MOVED: `buildDriverOptions` moved to another file reds the real page with the drift refusal ' +
+ 'alone, and regenerating clears it with every byte outside the register unchanged',
+ regCodes(moved).join(',') === '[register-drift]' &&
+ regenReal.problems.length === 0 &&
+ evaluateRegister({ pageText: regenReal.page, census: movedReal }).problems.length === 0 &&
+ splitRegister(regenReal.page).prose === splitRegister(realRegisterPage).prose,
+ moved.problems.join(' | ').slice(0, 400)
+ );
+ const strayReal = evaluateRegister({
+ pageText: realRegisterPage,
+ census: { ...realCensus, sites: [...realCensus.sites, { ...probe, symbol: 'aSymbolNoRowNames' }] },
+ });
+ t(
+ '⭐ REAL, ARRIVED: a read in a symbol no row names is `[read-without-a-row]` on the real page',
+ strayReal.problems.some((p) =>
+ p.startsWith('[read-without-a-row] packages/objectql/src/engine-driver-options.ts#aSymbolNoRowNames')
+ ),
+ strayReal.problems.join(' | ').slice(0, 400)
+ );
+ }
+ t(
+ '⛔ REAL, SAFETY: the real page was never written -- same bytes before and after',
+ createHash('sha256').update(readFileSync(join(ROOT, PAGE), 'utf8')).digest('hex') === pageHash
+ );
+ }
+
battery('WIRING: this gate, and its self-test, really run in CI');
const SELF = 'scripts/check-system-context-census.mjs';
let lintYml = null;
@@ -2519,5 +3218,6 @@ if (isEntrypoint(import.meta.url)) {
process.exit(selfTestCode);
}
if (argv.includes('--fix')) process.exit(refuseRetiredFix());
+ if (argv.includes('--write')) process.exit(write());
process.exit(run());
}