diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx
index 460dac0c50..1227e7a146 100644
--- a/content/docs/permissions/system-context.mdx
+++ b/content/docs/permissions/system-context.mdx
@@ -27,14 +27,21 @@ context whenever one exists.
**Every anchor, and every count about the census population, is checked by CI.**
`scripts/check-system-context-census.mjs` re-runs the AST census over the whole
-repo on each PR and refuses the page when an anchor points at a line that is no
-longer the site it names, when a stated count about the population disagrees
-with the census, or — the check that matters most — when the code holds an
-elevation read that no row here anchors. Six raw text counts in
+repo on each PR and refuses the page when an anchor names a symbol its file no
+longer declares, when a stated count about the population disagrees with the
+census, or — the check that matters most — when the code holds an elevation read
+that no row here anchors. Six raw text counts in
[Maintaining this table](#maintaining-this-table) are deliberately **not**
-enforced, and say there when they were measured. Pure line rot is repaired by
-`node scripts/check-system-context-census.mjs --fix`; a site that arrived or
-vanished is deliberately left for a person. See
+enforced, and say there when they were measured.
+
+⚠️ **What these anchors buy, and what they cost — read this before trusting a
+row.** Every anchor here is a `path#symbol` citation. Nothing encodes a
+position, so an unrelated edit above a site cannot rot one, and a repair is
+never mechanical: there are no numbers to renumber. The price is granularity.
+Several reads inside one function collapse onto one anchor, so **deleting a
+whole symbol reds this page, and deleting one of several reads inside a symbol
+that keeps at least one may not.** The gap is real, it is measured below, and it
+is left open deliberately rather than hidden — see
[Maintaining this table](#maintaining-this-table).
@@ -46,10 +53,10 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
-| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
-| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1634` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
-| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
-| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |
+| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts#isSystem` | The elevation flag on an operation's context | ✅ |
+| `Object.isSystem` — `packages/spec/src/data/object.zod.ts#isSystem` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
+| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts#isSystem` | Built-in template; tenants may override but should not delete | ❌ |
+| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts#isSystem` | Platform-infrastructure environment, not user data | ❌ |
The collision is a genuine hazard rather than a naming nit: `Object.isSystem`
changes an object's **default sharing**, and `ExecutionContext.isSystem` changes
@@ -57,22 +64,22 @@ whether **sharing grants are materialised** — so a search for "isSystem sharin
returns both, and they are unrelated decisions.
A fifth, closely-spelled family — `isSystemObjectName()` /
-`isSystemObject()` in `packages/runtime/src/action-execution.ts:66`,
-`packages/mcp/src/mcp-http-tools.ts:222` — keys on the `sys_` **name prefix**,
+`isSystemObject()` in `packages/runtime/src/action-execution.ts#isSystemObjectName`,
+`packages/mcp/src/mcp-http-tools.ts#isSystemObject` — keys on the `sys_` **name prefix**,
not on any flag.
## How the flag is set
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
-cannot set it (`packages/rest/src/rest-server.ts:1858`, `:1887`), and neither
-can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
+cannot set it (`packages/rest/src/rest-server.ts#enforceAuth`), and neither
+can an action body (`packages/runtime/src/domains/actions.ts#handleActionsRequest`). It is
written by internal callers only, as an option on the engine call:
```ts
await engine.insert('crm_account', row, { context: { isSystem: true } });
```
-Its parse-time default is `false` (`execution-context.zod.ts:269`), so an absent
+Its parse-time default is `false` (`packages/spec/src/kernel/execution-context.zod.ts#isSystem`), so an absent
context is never elevated.
---
@@ -87,40 +94,40 @@ that silently does not happen.
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
-| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1686` |
-| 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 | guard at `security-plugin.ts:2612` (the step 3.5 block), skipped by `:1686` |
-| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4440` |
-| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4591` |
-| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4669` |
-| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1513`, `:1535` |
-| 7 | 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 | `object-schema-fls.ts:228` |
-| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3953` |
-| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
-| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
-| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1427` |
-| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
-| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
-| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` |
-| 15 | **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 | `read-audit.ts:556` |
-| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
-| 17 | 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 | `rest-server.ts:1890` |
+| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
+| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin.ts#start` |
+| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` |
+| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#computeReadableFields` |
+| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` |
+| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
+| 7 | 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` |
+| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` |
+| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` |
+| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `packages/plugins/plugin-security/src/permission-set-projection.ts#createPermissionSetWriteThrough` |
+| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` |
+| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` |
+| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts#assertTenantAdmin` |
+| 14 | 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` |
+| 15 | **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` |
+| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware` |
+| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `packages/rest/src/rest-server.ts#enforceAuth` |
### 2. Write pipeline and data integrity
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
-| 18 | **`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` | `objectql/src/engine.ts:11767` |
-| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11950` |
-| 20 | **`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 | `objectql/src/engine.ts:10415` |
-| 21 | 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 | `objectql/src/engine.ts:10548`, `readonly-strict-errors.ts:66` |
-| 22 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:6253` |
-| 23 | 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 | `objectql/src/engine.ts:3930`, `:3940`, `:3967` |
-| 24 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
-| 25 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` |
-| 26 | 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 | `objectql/src/engine.ts:6952` |
-| 27 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12569` |
-| 28 | 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` | `objectql/src/engine.ts:12498` |
-| 29 | **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) | `security-plugin.ts:1686` |
+| 18 | **`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` |
+| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `packages/objectql/src/engine.ts#update` |
+| 20 | **`readonly` strip bypassed — INSERT** | objectql | Same, on create — one gate over BOTH create-side passes since the 2026-09-03 ruling moved the static-`readonly` strip in beside the runtime-owned one and deleted the DataProtocol ingress copy. `isSystem` is the **only** exemption on this path: `preserveAudit` is deliberately not read on create, so a non-system historical import is still stripped | `packages/objectql/src/engine.ts#insert` |
+| 21 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `packages/objectql/src/engine.ts#insert`, `packages/objectql/src/readonly-strict-errors.ts#READONLY_CLASS_REASONS` |
+| 22 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
+| 23 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |
+| 24 | 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` |
+| 25 | 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` |
+| 26 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `packages/objectql/src/engine.ts#stripSearchCompanionFromRead` |
+| 27 | 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` |
+| 28 | 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` |
+| 29 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
### 3. Sharing (`plugin-sharing`)
@@ -128,49 +135,49 @@ The largest single consumer — **17 of the 106 sites**.
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
-| 30 | **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: `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 | `rule-hooks.ts:292` |
-| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `plugin-sharing/src/sharing-service.ts:677` |
-| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement | `plugin-sharing/src/sharing-service.ts:943`, `:1030`, `:1787` |
-| 33 | `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) | `plugin-sharing/src/sharing-service.ts:1238` |
-| 34 | `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 | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) |
-| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
-| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` |
-| 37 | 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 | `plugin-sharing/src/share-link-service.ts:459`, `:513`, `:517`, `:590`, `:620` |
-| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:66` |
-| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:279`, `:518` |
+| 30 | **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` |
+| 31 | Sharing write verdict short-circuits to `allow` | Get: writes pass the sharing gate unconditionally | `packages/plugins/plugin-sharing/src/sharing-service.ts#bypassVerdict` |
+| 32 | 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` |
+| 33 | `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` |
+| 34 | `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) |
+| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `packages/plugins/plugin-sharing/src/sharing-service.ts#listShares` |
+| 36 | `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` |
+| 37 | 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` |
+| 38 | 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` |
+| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule` |
### 4. Approvals, reports, attachments, comments, knowledge
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
-| 40 | **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` | `lifecycle-hooks.ts:347` |
-| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
-| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:1096`, `:1219`, `:3475`, `:3623`, `:3791`, `:3862`, `:4051`, `:4091` |
-| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
-| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
-| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
-| 46 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | `comment-access-hooks.ts:322`, `:449`, `:488`, `:540` |
-| 47 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `service-knowledge/src/knowledge-service.ts:316` |
+| 40 | **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` |
+| 41 | 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` |
+| 42 | 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` |
+| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `packages/plugins/plugin-reports/src/report-service.ts#saveReport` |
+| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `packages/plugins/plugin-reports/src/report-service.ts#assertExportAllowed`, `#canAccessReport`, `#listReports`, `#listSchedules` |
+| 45 | 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` |
+| 46 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility` |
+| 47 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` |
### 5. Actions, metadata plane, provenance, the organization wall
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
-| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
-| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
-| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5520`, `:6977`, `:7225`, `:7656`, `:7849` |
-| 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 | `meta-write-capability.ts:134` |
-| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:1079`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:552`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
-| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
-| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
-| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` |
-| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:177`, `:268` |
-| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:255`, `:546`, `:636` |
-| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
-| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:77`, `webhook-provenance.ts:68` |
-| 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 | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:319` |
-| 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 | `inbox-caller.ts:148` |
-| 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 authoritative stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ This is why a forged `organization_id` is overwritten on the non-elevated path and not here: elevation is the seam the legitimate cross-organization writers use | `organizations-plugin.ts:302` |
+| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `packages/runtime/src/action-execution.ts#callData` |
+| 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 | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` |
+| 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/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: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `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` |
+| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#refuseUnrelatedScreenRead` |
+| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin` |
+| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-provenance.ts#bindEmailTemplateProvenanceStamp`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
+| 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` |
+| 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 authoritative stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ This is why a forged `organization_id` is overwritten on the non-elevated path and not here: elevation is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` |
### 6. Reads that only carry the flag onward
@@ -180,10 +187,10 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
-| 63 | `objectql/src/engine.ts:3737` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
-| 64 | `objectql/src/engine.ts:15016` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
-| 65 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
-| 66 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
+| 63 | `packages/objectql/src/engine.ts#buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
+| 64 | `packages/objectql/src/engine.ts#isSystem` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
+| 65 | `packages/plugins/plugin-reports/src/report-service.ts#executeReport` | plugin-reports | Threads the flag into the engine call that runs a report |
+| 66 | `packages/runtime/src/sandbox/body-runner.ts#executionContextFromHook` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
---
@@ -194,13 +201,13 @@ assuming `isSystem` covers it is a documented source of bugs.
| Assumption | Reality | Anchor |
|:---|:---|:---|
-| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` |
-| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
-| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10398`–`10415` |
-| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) |
-| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
-| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
-| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1858`, `:1887`; `domains/actions.ts:414` |
+| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `packages/metadata-protocol/src/seed-loader.ts#SEED_OPTIONS` (rationale at `#writeDeferredReference`, #3760), `packages/spec/src/automation/flow.zod.ts#runAs` |
+| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `packages/objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
+| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `packages/objectql/src/engine.ts#insert` |
+| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `packages/spec/src/data/field.zod.ts#readonly` (#3493 / #6640) |
+| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner` |
+| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `packages/plugins/plugin-auth/src/last-admin-guard.ts` |
+| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `packages/rest/src/rest-server.ts#enforceAuth`; `packages/runtime/src/domains/actions.ts#handleActionsRequest` |
---
@@ -212,10 +219,10 @@ should recognise it instead of re-deriving it.
1. **The `owner_id` gap has two independent compensations and no shared
mechanism.** Row 2 is a real gap; the platform repairs it twice, in
unrelated places — inline for automation flow writes
- (`runtime-identity.ts:279`, whose own comment states the reason: "the
+ (`packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, whose own comment states the reason: "the
security middleware that stamps it short-circuits on `isSystem` — so the
writer fills it here"), and as a boot-time sweep for seeded rows
- (`plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
+ (`packages/plugins/plugin-security/src/claim-seed-ownership.ts`). Any *third* system write
path gets neither. If you add one, stamp ownership yourself.
2. **Sharing materialisation is no longer skipped — the rough edge is closed,
@@ -254,7 +261,7 @@ should recognise it instead of re-deriving it.
rule-materialised grant that the next reconcile silently restores.
5. **`applySystemFields` does not read this flag.** It is named as if it did.
- `packages/objectql/src/registry.ts:475` is **schema-side column
+ `packages/objectql/src/registry.ts#applySystemFields` is **schema-side column
provisioning** — which columns an object carries — and consumes
`ExecutionContext.isSystem` zero times. The write-time ownership behaviour
people attribute to it is row 2, in `plugin-security`.
@@ -307,7 +314,7 @@ one ends: it returns prose inside comments and strings, the three unrelated
metadata fields, and the `isSystemObject` / `isSystemObjectName` /
`isSystemLedgerObject` name helpers. Worse, it loses real sites — a regex pass
over this same corpus silently dropped **6** reads in
-`plugin-reports/src/report-service.ts` to a quoting desync and **11** more to
+`packages/plugins/plugin-reports/src/report-service.ts` to a quoting desync and **11** more to
`(ctx?.session as any)?.isSystem` casts. So the census walks the TypeScript AST
and classifies each appearance of the identifier by where the parser puts it:
a **read** (a table row here), a **declaration**, an object-literal or type
@@ -336,6 +343,8 @@ still holds equal to the census on every pull request:
| — carry the flag onward only (rows 63–66 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
+| — the distinct symbols those reads live in — what this page anchors | 89 | ✅ |
+| — of those files, the ones holding more than one read in one symbol | 9 | ✅ |
The six rows marked — are a **dated decomposition, not a live claim**: they were
measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count
@@ -367,49 +376,68 @@ moves this count and moves nothing else on this page — the census's read
population, the anchored rows above, and the packages and files totals all stay
where they are. The most recent arrival is the scoped
seed context threaded into the org-admin permission-set lookup in
-`plugins/plugin-security/src/auto-org-admin-grant.ts`, so that read resolves
+`packages/plugins/plugin-security/src/auto-org-admin-grant.ts`, so that read resolves
against the granting organization's own catalog row rather than an
-organization-less one (#11670). ⛔ Cited without a line number deliberately: an
-anchor here would be refused, and rightly — this page anchors elevation
-**reads**, and a declaration is not one.
+organization-less one (#11670). ⛔ Cited as a FILE deliberately, with no
+`#symbol`: this page's symbol anchors name the enclosing declaration of an
+elevation **read**, and a declaration of the shape is not one — so there is no
+symbol here for a row to name, and inventing one would put a name on the page
+that no rename could ever red.
Counting by hand is what made the previous edition wrong in two independent
ways, so both are worth naming. Its headline said "80 distinct sites across 18
packages" while its own tables anchored **77** — the number never matched the
page it described. And a `grep -c` for anchor-shaped text over the previous edition answered
**64**, because it counted *lines carrying an anchor*, not anchors: that page's
-real anchor population was **111** once continuation anchors (`` `:1409` ``) and
-range ends are counted.
-Decompose a text count before comparing it to anything.
+real anchor population was **111** once continuation anchors and range ends are
+counted. Decompose a text count before comparing it to anything.
### What CI holds, and why it is the population and not just the anchors
A line-number anchor rots on every unrelated edit to the same file, silently:
-re-resolving all 111 anchors of the previous edition found **101 pointing at a
-line that no longer held what the row named**, while only **10** were still
+re-resolving all 111 anchors of the edition before last found **101 pointing at
+a line that no longer held what the row named**, while only **10** were still
correct — and **41 of them named a basename that matches two files**, so they
-could not be placed without reading the row's Package column. This edition
-spells an ambiguous basename far enough to be unique
-(`objectql/src/engine.ts`, not `engine.ts`), which is what makes an anchor
-mechanically resolvable at all.
+could not be placed without reading the row's Package column. Neither failure is
+reachable from this edition: it carries no line numbers at all, and it spells
+every path in full from the repository root. Each anchor is
+`packages/…/file.ts#symbol`, resolved by the shared symbol-anchor resolver
+(`scripts/symbol-anchors.mjs`) against that file's own declaration sites — the
+same resolver, and the same registration shape, that holds `docs/adr/**`.
+Renaming a symbol is now a loud red instead of a silent misdirection.
+
+⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
+cannot say WHICH read inside a function it means, and **9** of the **45**
+anchored files hold more than one read inside a single symbol. So the population
+check runs per file at symbol granularity: every file the census finds a read in
+must be anchored, and the set of symbols this page cites into that file must
+equal the set of symbols the census finds reads in. Two consequences, and the
+second one is a hole:
+
+- **Delete a whole symbol and this page reds** — the anchor stops resolving and
+ the symbol set stops matching, in the same run.
+- **Delete one of several reads inside a symbol that keeps at least one, and the
+ symbol set does not move, so it may not red.** The previous edition's line
+ numbers did close this one: both reads lost in the last drift lived inside
+ `callerContext()` helpers that still exist under the same names. Closing it
+ again means a span-aware resolver and per-read disambiguation in those nine
+ files; that is its own card, and it is deliberately not folded in here.
Resolving anchors is nevertheless the *second* check, not the first. ⭐ **A gate
that only checks what the page already says can never find what the page failed
-to say.** Re-resolving every anchor of the previous edition would have passed
+to say.** Re-resolving every anchor of the edition before last would have passed
while it was missing 32 sites and its headline was 29 too low. So the
load-bearing direction runs census → page: every elevation read in the code must
be anchored here, and a site that vanishes from the code takes the census total
-with it, which is what makes a row describing a protection that no longer exists
-fail. That matters because the two reads deleted during the last drift lived
-inside `callerContext()` helpers that still exist under the same names — **a
-symbol-name anchor would have resolved, and would have stayed green.**
+with it — the site, package and file counts above are census-derived — which is
+what makes a row describing a protection that no longer exists fail.
Four checks run, in `scripts/check-system-context-census.mjs`:
| Check | What fails |
|:---|:---|
-| **Population** | an elevation read in the code with no anchor here |
-| **Resolution** | an anchor whose spelling matches no tracked file, matches two, or names a line the file does not have |
+| **Population** | a file holding an elevation read with no anchor here, or a symbol holding one that no anchor here names |
+| **Resolution** | an anchor naming no tracked file, or naming a symbol that file does not declare — and any surviving line number, which is no longer an anchor form |
| **Counts** | any **census-derived** number above that disagrees with the census — including the count-sentence wording, so the check cannot go quietly vacuous. The six raw text counts are exempt by design, but their rows must still be present and dated |
| **Classification** | an anchor that is not a read site and is not a declared non-read citation |
diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs
index 5ec795c864..2e1c7815ae 100644
--- a/scripts/check-system-context-census.mjs
+++ b/scripts/check-system-context-census.mjs
@@ -7,7 +7,7 @@
*
* node scripts/check-system-context-census.mjs
* node scripts/check-system-context-census.mjs --self-test
- * node scripts/check-system-context-census.mjs --fix # re-anchor rotted lines
+ * node scripts/check-system-context-census.mjs --fix # nothing to repair -- see below
*
* 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,
@@ -33,29 +33,69 @@
* carry an anchor. The other direction (PAGE -> CENSUS) is worth having and cheap,
* but it is the second gate, not the first.
*
- * The two deletions are the reason a symbol-name anchor is not sufficient either.
- * Both were the `isSystem` propagation inside a `callerContext()` helper; both
- * helpers still exist under the same name. **A symbol anchor would still resolve
- * and would still be green** while the protection the row described was gone.
* Deletions are caught here by the counts, which are census-derived: lose a site
- * and the page's declared 109 stops being true.
+ * and the page's declared total stops being true.
+ *
+ * ## ⭐ Why the anchors are `path#symbol` and no longer `path:line` (#15921)
+ *
+ * A line number is not an anchor form anywhere in this repo any more. The
+ * `docs/adr/**` migration measured 243 of 337 live line anchors broken -- 72.1%,
+ * a one-way lower bound -- and ruled the whole class out; this page joins that
+ * ruling as a CORPUS REGISTRATION against the same resolver, never a second
+ * implementation of it. `CORPUS` below is a `defineCorpus` call and nothing else;
+ * the grammar, the extractor and the resolution rule live in
+ * `scripts/symbol-anchors.mjs`, whose header is authoritative.
+ *
+ * ⛔ The resolver is deliberately NOT widened to understand spans. That was the
+ * other option on the ruling (a span-aware resolver plus per-read disambiguation
+ * in the colliding files) and it is its own card, to be taken if the gap below is
+ * ever measured to have let a deletion through.
+ *
+ * ## ⚠️ What that costs, measured rather than asserted
+ *
+ * A symbol anchor cannot say WHICH read inside a symbol it means. Measured on the
+ * tree this migration ran against: 106 read sites live in 89 distinct symbols
+ * across 45 files, and 9 of those files hold more than one read inside a single
+ * symbol (`packages/objectql/src/engine.ts` and `packages/rest/src/rest-server.ts`
+ * are the widest, at 10
+ * reads in 9 symbols and 6 reads in 2). So:
+ *
+ * ⭐ Delete a whole symbol and this gate REDS -- twice over: the anchor stops
+ * 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.
+ *
+ * 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
+ * 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.
*
* ## The four checks
*
- * A RESOLUTION every anchor resolves to exactly one tracked file, at a line
- * that file has. Ambiguity is an error, never a guess: the
- * previous edition had 41 of 111 anchors whose bare basename
- * matched two files and could only be placed by reading the
- * row's prose.
- * B POPULATION every elevation read site the census finds is anchored at its
- * exact `file:line`. Zero omissions. ⭐ This is the mandatory one.
+ * A RESOLUTION delegated WHOLE to `sweepCorpus` over the `CORPUS`
+ * registration below: every anchor names a tracked file, every
+ * `#symbol` has a declaration site in it, and a surviving line
+ * number is a hard finding. ⛔ This gate re-implements none of
+ * that -- a sweep that could not run is a refusal here, never a
+ * skip.
+ * B POPULATION per FILE, at SYMBOL granularity: every file the census finds
+ * a read in carries at least one anchor here, and the set of
+ * symbols this page cites into that file EQUALS the set the
+ * census plus `NON_READ_ANCHORS` require -- so the two counts
+ * are equal by construction and a difference names the symbol
+ * rather than only the number. ⭐ This is the mandatory one.
* C COUNTS every CENSUS-DERIVED number the page states equals the census.
* A pattern that matches NOTHING is an error, so a reworded page
* cannot silently stop being checked. The page's whole-corpus
* TEXT counts are deliberately NOT compared -- see the next
* section -- but they are still required to be present and dated.
- * D CLASSIFICATION an anchor that is not a read site must be a declared
- * `NON_READ_ANCHORS` row, and that row must still locate the line.
+ * 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.
*
* ## ⭐ What is enforced, and why the text decomposition is NOT
*
@@ -83,42 +123,50 @@
* the page does not certify -- and whose churn, measured, was blocking the page
* from ever landing.
*
- * ## Why `NON_READ_ANCHORS` carries needles instead of line numbers
+ * ## Why `NON_READ_ANCHORS` is keyed by SYMBOL
*
- * 28 of the page's anchors are deliberately not read sites: the four unrelated
- * `isSystem` declarations, the `sys_`-prefix name helpers, a guard block a row
- * cites as the thing being skipped, and the prose targets in the "what it does NOT
- * do" table. They need an allow-list -- and an allow-list of LINE NUMBERS would rot
+ * Some of the page's anchors are deliberately not read sites: the four unrelated
+ * `isSystem` declarations, the `sys_`-prefix name helpers, a guard a row cites as
+ * the thing being skipped, and the prose targets in the "what it does NOT do"
+ * table. They need an allow-list -- and an allow-list of LINE NUMBERS would rot
* exactly like the anchors this gate exists to stop rotting, silently, because a
- * stale row still excuses an anchor.
- *
- * So each row carries a `needle`: a literal that must appear on exactly one line of
- * the file. The gate LOCATES the line and requires the page's anchor to name it.
- * That makes every anchor on the page enforced and mechanically repairable, and it
- * makes the ledger self-retiring -- a needle that matches zero lines, or more than
- * one, is an error naming the row.
- *
- * ## `--fix` repairs rot and REFUSES to repair population
- *
- * Per file, when the page's DISTINCT anchor count equals the number of lines the
- * file offers to be anchored -- its census read sites AND its `NON_READ_ANCHORS`
- * citations, as one union -- the two are mapped in line order and the numbers
- * rewritten: that is a pure shift, the shape an unrelated edit produces. When the
- * counts differ, the population changed -- a site arrived or vanished -- and no
- * mechanical mapping is honest. `--fix` leaves those alone and the gate stays red
- * until a human writes the row.
- *
- * ⭐ The union is load-bearing, not tidiness: subtracting the ledger by LINE
- * compares a pre-shift page with a post-shift ledger and reports a POPULATION
- * change over a population that never moved (#13490). `fixAnchors` carries the two
- * measured occurrences and why the union is the safer shape.
- *
- * ⇒ So the repair for a line-shift red is `--fix` and never a hand-edited line
- * number, and the repairing PR should state that `--fix` REFUSED ZERO files. That
- * sentence is what separates a pure re-anchor from a population change that
- * happened to be shifted at the same time: the refusal is the gate's only signal
- * that a site arrived or vanished, and a `--fix` run reporting refusals leaves
- * rows a human still has to write.
+ * stale row still excuses an anchor. So did the `needle` this ledger used before
+ * #15921: a literal of source text, which every reformatting moved.
+ *
+ * Each row now names `{ file, symbol }`, the same pair the page writes, and the
+ * gate asks the SHARED resolver whether that file still declares that symbol. A
+ * row whose symbol is gone is an error naming the row, so the ledger stays
+ * self-retiring; and there is nothing left in it that a whitespace change can
+ * break.
+ *
+ * `symbol: null` is the FILE-LEVEL row and it is honest, not a shrug: the citation
+ * lands in a module docblock with no declaration around it, and a file-level
+ * anchor is what the grammar provides for exactly that. One row is like this today
+ * (`plugin-auth/src/last-admin-guard.ts`).
+ *
+ * ⭐ `collapsesOntoRead` is the declaration this migration made necessary. Under
+ * symbol granularity a citation can share its symbol with a census read site -- the
+ * `owner_id` guard block and the short-circuit that skips it are both inside
+ * `packages/plugins/plugin-security/src/security-plugin.ts#start` -- so the row
+ * stops EXCUSING anything while its `why`
+ * and its `rowSeams` are still worth keeping. The field says so, and the gate
+ * refuses when the declaration and the census disagree in EITHER direction: an
+ * 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` no longer rewrites anything, and that is the point
+ *
+ * It used to re-anchor a pure line shift, which was the common repair: a file grew
+ * an import, every anchor into it moved by one, and a mechanical remap was both
+ * safe and necessary. Symbol anchors do not shift, so that repair has no subject.
+ * ⛔ The flag is NOT silently accepted -- a `--fix` that writes nothing and exits 0
+ * reads exactly like a repair that worked. It prints what it did not do and why,
+ * and then returns this gate's ordinary verdict, so `gen:system-context-census`
+ * stays wired and stays honest.
+ *
+ * ⇒ Every red here is now 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.
*
* ## Refusals, never quiet passes (#4690)
*
@@ -159,15 +207,22 @@
*/
import { createHash } from 'node:crypto';
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isEntrypoint } from './invoked-as.mjs';
-import { CORPUS_ROOTS, runCensus, siteKeys } from './isystem-census.mjs';
-import { extractLineAnchors, extractPathCitations, resolveAnchorFile } from './doc-line-anchors.mjs';
+import { CORPUS_ROOTS, runCensus, symbolPopulation } from './isystem-census.mjs';
+import {
+ ANCHOR_GRAMMAR,
+ defineCorpus,
+ extractAnchors,
+ formatFindings,
+ symbolResolutionClass,
+ sweepCorpus,
+} from './symbol-anchors.mjs';
// ── The self-test's own battery roster and floor (#13489) ──────────────────
//
@@ -187,20 +242,20 @@ import { extractLineAnchors, extractPathCitations, resolveAnchorFile } from './d
// remedy is to find what stopped registering.
const SELF_TEST_BATTERIES = Object.freeze({
'the GREEN control: a page that is correct': 2,
- '⭐ the RED that matters: a site the page never mentions': 1,
+ '⭐ the RED that matters: a site the page never mentions': 2,
'the deletion shape: the row stands, the site is gone': 3,
- 'resolution': 3,
- 'ledger': 3,
+ '⭐ RESOLUTION is delegated, and a sweep that did not run is a REFUSAL': 3,
+ 'ledger': 5,
'counts': 6,
'⭐ CRITERION: enforced means CENSUS-DERIVED, pinned over the REAL lists': 2,
'the same criterion, behaviourally, on one page': 3,
'⛔ and the half that must NOT have moved: the contract still reds': 2,
'absence is loud': 1,
- '--fix': 2,
- '⭐ #13490: the incident shape -- reads AND ledger citations BOTH shift': 2,
- '⛔ the dangerous direction: the citation crosses onto a read anchor\'s line ─': 1,
- '⭐ and the safety property, on the shape that now ACCEPTS': 2,
- 'the refusal has to SHOW its work (both counts, both classes, the diff)': 2,
+ '⛔ --fix rewrites NOTHING, and says so': 2,
+ '⭐ 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 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,
@@ -217,6 +272,46 @@ const UNATTRIBUTED_BATTERY = '(no battery open)';
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
export const PAGE = 'content/docs/permissions/system-context.mdx';
+const PAGE_DIR = 'content/docs/permissions';
+const PAGE_FILE = /^system-context\.mdx$/;
+
+/**
+ * ── The corpus registration (#15921) ────────────────────────────────────────
+ *
+ * ⚠️ THE MECHANISM IS NOT HERE. This is a `defineCorpus` call and nothing else,
+ * exactly like `scripts/check-adr-symbol-anchors.mjs#CORPUS`: the grammar, the
+ * extractor and the resolution rule are `scripts/symbol-anchors.mjs`'s, shared
+ * with `docs/adr/**` and with the `scripts/**` gate-header corpus. The ruling
+ * that put line anchors out of this repo said 「共享同一个 resolver,⛔ 不造第
+ * 二套」, and a corpus is how a body of documents joins it.
+ *
+ * `docPattern` names ONE file rather than the directory: the other 22 pages under
+ * `content/docs/permissions` are hand-written prose that nobody has migrated, and
+ * sweeping them here would red this gate for citations it was never given the
+ * ledger to explain. Widening the pattern is its own decision with its own
+ * cleanup, not a side effect of this one.
+ *
+ * `checkBarePaths` is ON, which `docs/adr/**` cannot afford (1,056 findings there)
+ * and this page can: it carries 45 anchored files and a handful of prose
+ * citations, every one of them spelled in full from the repository root, so a
+ * bare path that resolves to nothing is a real finding and not a corpus-wide
+ * cleanup.
+ *
+ * ⚠️ For anyone registering the NEXT corpus: `sweepCorpus` resolves through
+ * `git ls-files` and passes no environment of its own, so a sweep of a SYNTHETIC
+ * root inherits whatever `GIT_DIR` / `GIT_INDEX_FILE` the caller was launched
+ * with. In-repo callers are unaffected (the inherited values name this
+ * repository, which is the right answer); a test that builds a throwaway tree is
+ * not. This gate's self-test detaches from those variables before its first case
+ * — see `buildRedFirstCorpus`, which carries the measured incident.
+ */
+export const CORPUS = defineCorpus({
+ id: 'system-context',
+ label: 'content/docs/permissions/system-context.mdx (the isSystem census page)',
+ docRoots: [PAGE_DIR],
+ docPattern: PAGE_FILE,
+ checkBarePaths: true,
+});
/**
* ── The population this gate READS, declared where the dispatch tool looks ───
@@ -278,156 +373,146 @@ const ROOT_DIR_WATCH_HINTS = ['packages/**', 'examples/**'];
/**
* ⛔ SHRINK-ONLY. Anchors the page writes that are deliberately NOT elevation read
- * sites. `needle` must appear on exactly ONE line of `file`; that line is where the
- * page's anchor has to point.
+ * sites, keyed the way the page writes them: `{ file, symbol }`.
+ *
+ * `symbol` must have a declaration site in `file` by the SHARED resolver's rule
+ * (`scripts/symbol-anchors.mjs#symbolResolutionClass`), and the page must anchor
+ * exactly that pair. `symbol: null` is a FILE-LEVEL row -- the citation lands
+ * somewhere no declaration encloses, and the page anchors the bare path.
+ *
+ * `collapsesOntoRead: true` declares that this row's symbol is ALSO a symbol the
+ * census finds an elevation read in, so the row no longer excuses an anchor and is
+ * kept for its `why` and its `rowSeams`. The gate holds the declaration to the
+ * census in both directions.
*/
export const NON_READ_ANCHORS = [
// ── The four declarations that share the identifier ──────────────────────────
{
file: 'packages/spec/src/kernel/execution-context.zod.ts',
- needle: 'isSystem: z.boolean().default(false),',
+ symbol: 'isSystem',
why: 'the elevation flag itself -- a declaration, not a read',
},
{
file: 'packages/spec/src/data/object.zod.ts',
- needle: "isSystem: z.boolean().optional().default(false).describe('Is system object",
+ symbol: 'isSystem',
why: 'Object.isSystem -- an unrelated metadata field the page names to defuse the collision',
},
{
file: 'packages/spec/src/system/email-template.zod.ts',
- needle: 'isSystem: z.boolean().default(false),',
+ symbol: 'isSystem',
why: 'EmailTemplate.isSystem -- unrelated metadata field',
},
{
file: 'packages/spec/src/cloud/environment.zod.ts',
- needle: "isSystem: z.boolean().default(false).describe('Whether this is a system environment",
+ symbol: 'isSystem',
why: 'Environment.isSystem -- unrelated metadata field',
},
// ── The `sys_` name-prefix family, cited to keep it apart from the flag ──────
{
file: 'packages/runtime/src/action-execution.ts',
- needle: 'export function isSystemObjectName(name: string): boolean {',
+ symbol: 'isSystemObjectName',
why: 'keys on the `sys_` NAME PREFIX, not on any flag',
},
{
file: 'packages/mcp/src/mcp-http-tools.ts',
- needle: 'function isSystemObject(name: string): boolean {',
+ symbol: 'isSystemObject',
why: 'the same name-prefix helper, MCP side',
},
// ── Constructs a table row deliberately cites alongside its read ─────────────
{
file: 'packages/plugins/plugin-security/src/security-plugin.ts',
- needle: '3.5. [#3004]',
- why: 'row 2 -- the `owner_id` guard block that the row-1 short-circuit skips',
+ symbol: 'start',
+ collapsesOntoRead: true,
+ why: 'row 2 -- the `owner_id` guard block that the row-1 short-circuit skips; both are inside `start`',
rowSeams: ['`owner_id` is not auto-stamped on INSERT', 'The whole security middleware short-circuits'],
},
{
file: 'packages/objectql/src/engine.ts',
- needle: 'if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit) return base;',
- why: 'row 23 -- the early return the tenant-audit read feeds',
- rowSeams: ['Tenant-audit warning silenced'],
- },
- {
- file: 'packages/objectql/src/engine.ts',
- needle: 'if (isSystem && opts.bypassTenantAudit === undefined && !isTenantAuditInScope) {',
- why: 'row 23 -- where `bypassTenantAudit` is threaded to the driver',
+ symbol: 'buildDriverOptions',
+ collapsesOntoRead: true,
+ why: 'row 23 -- the early return the tenant-audit read feeds, and where `bypassTenantAudit` is threaded to the driver',
rowSeams: ['Tenant-audit warning silenced'],
},
{
file: 'packages/objectql/src/engine.ts',
- needle: 'if (options?.strictReadonlyWrites === true) {',
- why: 'row 21 -- the strict-drop refusal that never fires under elevation',
+ symbol: 'insert',
+ collapsesOntoRead: true,
+ why: 'row 21 -- the strict-drop refusal that never fires under elevation, and the strip-before-validation block the validation row cites',
rowSeams: ['Strict-drop refusal never fires'],
},
{
file: 'packages/objectql/src/readonly-strict-errors.ts',
- needle: 'const READONLY_CLASS_REASONS',
+ symbol: 'READONLY_CLASS_REASONS',
why: 'row 21 -- the reason set the silent refusal would have used',
rowSeams: ['Strict-drop refusal never fires'],
},
{
file: 'packages/plugins/plugin-security/src/system-write-guard.ts',
- needle: 'if (!isUserContextWrite(context)) return;',
+ symbol: 'assertEngineOwnedWriteAllowed',
why: 'row 24 -- the bypass expressed through a helper rather than a direct read',
rowSeams: ['append-only write guard bypassed'],
},
{
file: 'packages/plugins/plugin-sharing/src/sharing-service.ts',
- needle: "if (row.source != null && row.source !== 'manual') {",
- why: 'row 34 -- the CONFLICT guard `revoke()` deletes in front of',
+ symbol: 'revoke',
+ collapsesOntoRead: true,
+ why: 'row 34 -- the CONFLICT guard `revoke()` deletes in front of, in the same function',
rowSeams: ['`revoke()` deletes directly'],
},
{
file: 'packages/services/service-automation/src/builtin/crud-nodes.ts',
- needle: 'stampSystemInsertOwner(fields, dataCtx, data, objectName);',
+ symbol: 'registerCrudNodes',
why: 'row 60 -- the call site of the compensating owner stamp',
rowSeams: ['Automation flow data nodes re-add the `owner_id` stamp'],
},
{
file: 'packages/objectql/src/registry.ts',
- needle: 'export function applySystemFields(',
+ symbol: 'applySystemFields',
why: 'rough edge 5 -- named as if it read the flag; it reads it zero times',
},
// ── Prose targets: "what `isSystem` does NOT do", and the rough edges ────────
{
file: 'packages/metadata-protocol/src/seed-loader.ts',
- needle: 'so it must carry `skipTriggers` too.',
- why: 'the rationale comment the triggers row cites',
+ symbol: 'writeDeferredReference',
+ why: 'the rationale comment the triggers row cites -- `isSystem` does NOT suppress trigger dispatch',
},
{
file: 'packages/metadata-protocol/src/seed-loader.ts',
- needle: 'does NOT suppress trigger dispatch, only `skipTriggers` does',
- why: 'end of that rationale comment',
- },
- {
- file: 'packages/metadata-protocol/src/seed-loader.ts',
- needle: 'SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true',
+ symbol: 'SEED_OPTIONS',
why: 'the seed options that carry BOTH flags -- a producer, not a read',
},
{
file: 'packages/spec/src/automation/flow.zod.ts',
- needle: 'Declare `system` to make the elevation explicit.',
+ symbol: 'runAs',
why: 'the flow-side declaration of the same distinction',
},
- {
- file: 'packages/objectql/src/engine.ts',
- needle: '// Runs BEFORE validation on purpose: a value the caller was never',
- why: 'start of the strip-before-validation block the validation row cites',
- },
{
file: 'packages/spec/src/data/field.zod.ts',
- needle: "readonly: z.boolean().default(false).describe(",
+ symbol: 'readonly',
why: '`preserveAudit` is the separate opt-in -- this is the `readonly` declaration',
},
{
file: 'packages/services/service-automation/src/runtime-identity.ts',
- needle: 'const userId = (dataCtx as RunIdentityContext).userId;',
- why: 'audit stamping reads `userId`, not the flag',
- },
- {
- file: 'packages/services/service-automation/src/runtime-identity.ts',
- needle: 'if (!userId) return;',
- why: 'the user-less system write that stamps nothing',
+ symbol: 'stampSystemInsertOwner',
+ collapsesOntoRead: true,
+ why: 'audit stamping reads `userId`, not the flag, and the user-less system write stamps nothing',
},
{
file: 'packages/plugins/plugin-auth/src/last-admin-guard.ts',
- needle: 'applies to EVERY context, `isSystem` included',
- why: 'the guard that is NOT bypassed -- cited to refute "it bypasses every guard"',
+ symbol: null,
+ why: 'the guard that is NOT bypassed -- cited to refute "it bypasses every guard". The claim lives in the module docblock, which no declaration encloses, so this is the one FILE-LEVEL row',
},
{
file: 'packages/rest/src/rest-server.ts',
- needle: '"authenticated". `isSystem` flags are never set on inbound HTTP',
- why: 'inbound HTTP cannot set the flag',
- },
- {
- file: 'packages/rest/src/rest-server.ts',
- needle: '`isSystem` is never set on inbound HTTP, so it cannot bypass.',
- why: 'the second inbound seam',
+ symbol: 'enforceAuth',
+ collapsesOntoRead: true,
+ why: 'inbound HTTP cannot set the flag -- stated in the docblock and again inside the seam',
},
{
file: 'packages/runtime/src/domains/actions.ts',
- needle: '`isSystem` is never settable from the wire; internal',
- why: 'an action body cannot set the flag',
+ symbol: 'handleActionsRequest',
+ collapsesOntoRead: true,
+ why: 'nor can an action body',
},
];
@@ -794,7 +879,7 @@ export function checkRowReferences({ pageText, ledger = NON_READ_ANCHORS, pageRe
const seams = row.rowSeams ?? [];
if (mentions.length !== seams.length) {
problems.push(
- `[why-row-unkeyed] NON_READ_ANCHORS row for ${row.file} (needle \`${row.needle}\`) writes ` +
+ `[why-row-unkeyed] NON_READ_ANCHORS row for ${row.file} (\`#${row.symbol ?? ''}\`) writes ` +
`${mentions.length} row reference(s) in its \`why\` (${row.why}) but declares ${seams.length} ` +
'`rowSeams`. Every `row N` in a `why` needs the seam it is about, in the order it is ' +
'written -- an unkeyed number is held by nothing and reads as current forever.'
@@ -848,6 +933,30 @@ export function checkRowReferences({ pageText, ledger = NON_READ_ANCHORS, pageRe
* reworded out from under the check, which is how a counts gate goes quietly
* vacuous.
*/
+/**
+ * How many distinct symbols the census's read sites live in — the population the
+ * page can actually ANCHOR, which is smaller than the site count wherever several
+ * reads share a symbol.
+ */
+function distinctSymbolCount(census) {
+ let total = 0;
+ for (const entry of symbolPopulation(census).values()) total += entry.symbols.size;
+ return total;
+}
+
+/**
+ * How many files hold more than one read inside a single symbol — the size of the
+ * precision this page trades away, kept enforced so the sentence that prices it
+ * cannot quietly stop being true in either direction.
+ */
+function collapsingFileCount(census) {
+ let files = 0;
+ for (const entry of symbolPopulation(census).values()) {
+ if (entry.symbols.size + (entry.fileLevel ? 1 : 0) !== entry.sites) files += 1;
+ }
+ return files;
+}
+
export const DECLARED_COUNTS = [
{
id: 'headline-sites',
@@ -927,6 +1036,30 @@ export const DECLARED_COUNTS = [
value: (c) => c.files.length,
why: 'the decomposition table: file count',
},
+ {
+ id: 'table-symbols',
+ pattern: /\| — the distinct symbols those reads live in — what this page anchors \|\s*(\d+) \|/,
+ value: (c) => distinctSymbolCount(c),
+ why: 'the decomposition table: what this page can actually anchor, after the collapse',
+ },
+ {
+ id: 'table-collapsing-files',
+ pattern: /\| — of those files, the ones holding more than one read in one symbol \|\s*(\d+) \|/,
+ value: (c) => collapsingFileCount(c),
+ why: 'the decomposition table: the size of the declared precision loss',
+ },
+ {
+ id: 'precision-collapsing-files',
+ pattern: /\*\*(\d+)\*\* of the \*\*\d+\*\*\s*\n?\s*anchored files hold more than one read/,
+ value: (c) => collapsingFileCount(c),
+ why: 'the prose that prices the precision loss where a reader meets the anchors',
+ },
+ {
+ id: 'precision-anchored-files',
+ pattern: /\*\*\d+\*\* of the \*\*(\d+)\*\*\s*\n?\s*anchored files hold more than one read/,
+ value: (c) => c.files.length,
+ why: 'the denominator of that same sentence',
+ },
{
id: 'ruling-sites',
pattern: /`isSystem` is a published contract with (\d+) read sites/,
@@ -1064,25 +1197,23 @@ export function carryOnwardRowCount(pageText) {
return rows.length;
}
-/** Tracked files, for anchor resolution. */
-export function trackedFiles(root = ROOT) {
- const files = execFileSync('git', ['-C', root, 'ls-files'], {
- encoding: 'utf8',
- maxBuffer: 1 << 28,
- })
- .split('\n')
- .filter(Boolean);
- if (files.length === 0) throw new Error('check-system-context-census: `git ls-files` listed nothing');
- return files;
-}
-
/**
- * Locate every `NON_READ_ANCHORS` row by its needle.
- *
- * @returns {{ located: Map, problems: string[] }} keyed `file:line`
+ * Resolve every `NON_READ_ANCHORS` row against its file, through the SHARED
+ * resolution rule.
+ *
+ * A row is stale when its file cannot be read, or when the file no longer
+ * declares its symbol -- the same predicate `sweepCorpus` applies to the page's
+ * own anchors, so the ledger and the page can never mean different things by
+ * "the symbol is there". A `symbol: null` row only requires its file to exist.
+ *
+ * @param {{ file: string, symbol: string|null, why: string, collapsesOntoRead?: boolean }[]} rows
+ * @param {(relPath: string) => string} readFile
+ * @param {Map }>} population the census, by file
+ * @returns {{ declared: Map, problems: string[] }} keyed `file#symbol`, or `file`
*/
-export function locateNonReadAnchors(rows, readFile) {
- const located = new Map();
+export function resolveNonReadAnchors(rows, readFile, population = new Map()) {
+ /** @type {Map} */
+ const declared = new Map();
const problems = [];
for (const row of rows) {
let body;
@@ -1095,27 +1226,43 @@ export function locateNonReadAnchors(rows, readFile) {
);
continue;
}
- const hits = [];
- body.split('\n').forEach((line, i) => {
- if (line.includes(row.needle)) hits.push(i + 1);
- });
- if (hits.length === 0) {
- problems.push(
- `[ledger-stale] NON_READ_ANCHORS row for ${row.file} no longer finds its needle ` +
- `\`${row.needle}\` -- the construct it excuses is gone or reworded (${row.why}).`
- );
- continue;
- }
- if (hits.length > 1) {
- problems.push(
- `[ledger-ambiguous] NON_READ_ANCHORS needle \`${row.needle}\` matches ${hits.length} ` +
- `lines of ${row.file} (${hits.join(', ')}) -- lengthen it until it is unique.`
- );
- continue;
+ if (row.symbol !== null && row.symbol !== undefined) {
+ if (!symbolResolutionClass(body, row.file, row.symbol)) {
+ problems.push(
+ `[ledger-stale] NON_READ_ANCHORS row for ${row.file} names \`#${row.symbol}\`, which that ` +
+ `file no longer declares -- the construct it excuses was renamed or removed (${row.why}).`
+ );
+ continue;
+ }
+ /* ⭐ The overlap is DECLARED, never inferred. A row whose symbol is also a
+ * census read symbol excuses nothing (POPULATION already requires that
+ * anchor); saying so in the row is what stops the next reader from taking
+ * it for a live exclusion, and holding the declaration to the census in
+ * BOTH directions is what stops the declaration itself from rotting. */
+ const collapses = population.get(row.file)?.symbols.has(row.symbol) === true;
+ if (collapses && row.collapsesOntoRead !== true) {
+ problems.push(
+ `[ledger-undeclared-collapse] NON_READ_ANCHORS row for ${row.file}#${row.symbol} shares its ` +
+ 'symbol with a census elevation read, so it no longer excuses an anchor. Declare ' +
+ '`collapsesOntoRead: true` on the row, or re-key it to the symbol it is really about.'
+ );
+ continue;
+ }
+ if (!collapses && row.collapsesOntoRead === true) {
+ problems.push(
+ `[ledger-stale-collapse] NON_READ_ANCHORS row for ${row.file}#${row.symbol} declares ` +
+ '`collapsesOntoRead`, but the census finds no elevation read in that symbol any more -- ' +
+ 'the read moved or was deleted, and this row is excusing an anchor again without anyone ' +
+ 'having re-read it.'
+ );
+ continue;
+ }
}
- located.set(`${row.file}:${hits[0]}`, row);
+ const key = row.symbol === null || row.symbol === undefined ? row.file : `${row.file}#${row.symbol}`;
+ if (!declared.has(key)) declared.set(key, []);
+ declared.get(key).push(row);
}
- return { located, problems };
+ return { declared, problems };
}
/**
@@ -1126,8 +1273,8 @@ export function locateNonReadAnchors(rows, readFile) {
export function evaluate({
pageText,
census,
- tracked,
readFile,
+ sweep,
ledger = NON_READ_ANCHORS,
declaredCounts = DECLARED_COUNTS,
unenforcedCounts = UNENFORCED_TEXT_COUNTS,
@@ -1136,10 +1283,10 @@ export function evaluate({
}) {
const problems = [];
- const anchors = extractLineAnchors(pageText);
+ const { anchors } = extractAnchors(pageText);
if (anchors.length === 0) {
problems.push(
- '[no-anchors] the page yielded ZERO `file:line` anchors -- the reader stopped ' +
+ '[no-anchors] the page yielded ZERO anchors -- the reader stopped ' +
'recognising the page rather than the page being clean.'
);
return { problems, stats: { anchors: 0 } };
@@ -1155,86 +1302,125 @@ export function evaluate({
);
}
- // ── A. RESOLUTION ───────────────────────────────────────────────────────────
- /** @type {Map} `file:line` -> anchors pointing there */
- const anchored = new Map();
- const fileLengths = new Map();
+ // ── A. RESOLUTION — delegated whole to the shared resolver ──────────────────
+ //
+ // ⭐ Not re-implemented here, and not optional either. `sweepCorpus` over
+ // `CORPUS` is what decides that a path is tracked, that a `#symbol` has a
+ // declaration site, and that a surviving line number is a finding. A sweep this
+ // gate could not run is a REFUSAL: "could not check" reported as "checked and
+ // clean" is the silently-degrading verifier this repo refuses on principle.
+ if (!sweep || !Array.isArray(sweep.findings)) {
+ problems.push(
+ '[no-sweep] the shared symbol-anchor resolver was not run over this page, so NOTHING here ' +
+ 'resolved an anchor. Run `sweepCorpus(CORPUS, root)` and pass its result -- a missing sweep ' +
+ 'is a failure, never a skip.'
+ );
+ return { problems, stats: { anchors: anchors.length } };
+ }
+ for (const finding of sweep.findings) {
+ if (finding.soft) continue;
+ problems.push(`[${finding.kind}] ${finding.doc}:${finding.line} ${finding.raw} -- ${finding.detail}`);
+ }
+
+ // ── the page's own citations, as SETS ───────────────────────────────────────
+ /** @type {Map>} path -> the symbols the page cites into it */
+ const pageSymbols = new Map();
+ /** @type {Set} paths the page cites with NO symbol -- file-level anchors */
+ const pageFileLevel = new Set();
for (const anchor of anchors) {
- const resolved = resolveAnchorFile(anchor.spelling, tracked);
- if ('error' in resolved) {
- problems.push(
- resolved.error === 'ambiguous'
- ? `[ambiguous-anchor] ${PAGE}:${anchor.docLine} spells \`${anchor.spelling}\`, which ` +
- `matches ${resolved.matches.length} tracked files (${resolved.matches.join(', ')}) -- ` +
- 'lengthen the spelling until it is unique.'
- : `[unresolved-anchor] ${PAGE}:${anchor.docLine} spells \`${anchor.spelling}\`, which ` +
- 'matches no tracked file -- the file moved or was deleted.'
- );
+ if (anchor.repo) continue; // cross-repo: reported by the sweep, resolved nowhere here
+ if (!anchor.symbol) {
+ pageFileLevel.add(anchor.path);
continue;
}
- const path = resolved.path;
- if (!fileLengths.has(path)) {
- try {
- fileLengths.set(path, readFile(path).split('\n').length);
- } catch {
- fileLengths.set(path, -1);
- }
- }
- const length = fileLengths.get(path);
- if (length === -1) {
- problems.push(`[unreadable-anchor-target] ${path} cannot be read (anchored at ${PAGE}:${anchor.docLine}).`);
+ if (!pageSymbols.has(anchor.path)) pageSymbols.set(anchor.path, new Set());
+ pageSymbols.get(anchor.path).add(anchor.symbol);
+ }
+
+ const population = symbolPopulation(census);
+ const { declared, problems: ledgerProblems } = resolveNonReadAnchors(ledger, readFile, population);
+ problems.push(...ledgerProblems);
+
+ /** The symbols the page is REQUIRED to cite, per file: census ∪ ledger. */
+ /** @type {Map>} */
+ const required = new Map();
+ const requireSymbol = (file, symbol) => {
+ if (!required.has(file)) required.set(file, new Set());
+ required.get(file).add(symbol);
+ };
+ for (const [file, entry] of population) for (const symbol of entry.symbols) requireSymbol(file, symbol);
+ for (const key of declared.keys()) {
+ const at = key.indexOf('#');
+ if (at !== -1) requireSymbol(key.slice(0, at), key.slice(at + 1));
+ }
+
+ // ── B. POPULATION — ⭐ the mandatory direction, per FILE ─────────────────────
+ //
+ // Two halves, and the second is what makes the first more than "the file is
+ // mentioned somewhere": every file with a read must be anchored, and the SET of
+ // symbols cited into it must equal the set required. Reporting the set
+ // difference rather than only the counts is deliberate -- the counts are equal
+ // exactly when the sets are, and a count alone cannot tell an author WHICH
+ // symbol to write.
+ const missing = [];
+ for (const [file, entry] of [...population].sort()) {
+ const cited = pageSymbols.get(file) ?? new Set();
+ const need = required.get(file) ?? new Set();
+ if (cited.size === 0 && !pageFileLevel.has(file)) {
+ missing.push(file);
+ problems.push(
+ `[file-without-a-row] ${file} holds ${entry.sites} elevation read site(s) in ` +
+ `${entry.symbols.size} symbol(s) (${[...entry.symbols].join(', ') || 'none nameable'}) and NO ` +
+ 'anchor on the page points into it at all. Either the page is missing this file entirely, ' +
+ 'or every row that cited it rotted off.'
+ );
continue;
}
- if (anchor.line < 1 || anchor.line > length) {
+ for (const symbol of [...entry.symbols].sort()) {
+ if (cited.has(symbol)) continue;
+ const sites = census.sites.filter((site) => site.file === file && site.symbol === symbol);
+ missing.push(`${file}#${symbol}`);
problems.push(
- `[out-of-range-anchor] ${PAGE}:${anchor.docLine} anchors ${path}:${anchor.line}, ` +
- `but that file has ${length} lines.`
+ `[site-without-a-row] ${file}#${symbol} holds ${sites.length} elevation read(s) ` +
+ `(\`${(sites[0]?.text ?? '').slice(0, 90)}\`) and no row on the page anchors it. ` +
+ `This file cites ${cited.size} symbol(s), the census and the ledger require ${need.size}. ` +
+ 'Either the page is missing this elevation behaviour, or a row rotted off it.'
);
- continue;
}
- const key = `${path}:${anchor.line}`;
- if (!anchored.has(key)) anchored.set(key, []);
- anchored.get(key).push(anchor);
- }
-
- for (const citation of extractPathCitations(pageText)) {
- const resolved = resolveAnchorFile(citation.spelling, tracked);
- if ('error' in resolved) {
+ /* A read with no nameable enclosing declaration is anchored at FILE level --
+ * the grammar's own fallback, and the only honest anchor for it. Today the
+ * census produces none of these; the branch is here so that the first one to
+ * arrive is a named refusal rather than a shape nothing considered. */
+ if (entry.fileLevel && !pageFileLevel.has(file)) {
+ missing.push(file);
problems.push(
- `[unresolved-citation] ${PAGE}:${citation.docLine} cites \`${citation.spelling}\`, ` +
- `which ${resolved.error === 'ambiguous' ? 'matches several tracked files' : 'matches no tracked file'}.`
+ `[site-without-a-file-anchor] ${file} holds an elevation read inside no nameable declaration, ` +
+ 'so it needs a FILE-LEVEL anchor here (the bare path, no `#symbol`) and the page carries none.'
);
}
}
- // ── B. POPULATION — ⭐ the mandatory direction ───────────────────────────────
- const sites = siteKeys(census);
- const missing = [...sites].filter((key) => !anchored.has(key)).sort();
- for (const key of missing) {
- const site = census.sites.find((s) => `${s.file}:${s.line}` === key);
- problems.push(
- `[site-without-a-row] ${key} reads \`${site.receiver}.isSystem\` and NO row on the page ` +
- `anchors it — \`${site.text.slice(0, 90)}\`. ` +
- 'Either the page is missing this elevation behaviour, or an existing row rotted off it.'
- );
- }
-
// ── D. CLASSIFICATION ───────────────────────────────────────────────────────
- const { located, problems: ledgerProblems } = locateNonReadAnchors(ledger, readFile);
- problems.push(...ledgerProblems);
- const unexplained = [...anchored.keys()].filter((key) => !sites.has(key) && !located.has(key)).sort();
- for (const key of unexplained) {
- problems.push(
- `[anchor-is-not-a-read-site] the page anchors ${key}, which the census does not call an ` +
- 'elevation read and NON_READ_ANCHORS does not declare. Either the line rotted, or the ' +
- 'citation is deliberate and needs a ledger row with a needle.'
- );
+ const unexplained = [];
+ for (const [file, cited] of [...pageSymbols].sort()) {
+ const need = required.get(file) ?? new Set();
+ for (const symbol of [...cited].sort()) {
+ if (need.has(symbol)) continue;
+ unexplained.push(`${file}#${symbol}`);
+ problems.push(
+ `[anchor-is-not-a-read-site] the page anchors ${file}#${symbol}, which the census does not ` +
+ 'call an elevation read and NON_READ_ANCHORS does not declare. Either the symbol was ' +
+ 'renamed under the row, or the citation is deliberate and needs a ledger row.'
+ );
+ }
}
- const unusedLedger = [...located.entries()].filter(([key]) => !anchored.has(key));
- for (const [key, row] of unusedLedger) {
+ for (const [key, rows] of declared) {
+ const at = key.indexOf('#');
+ const used = at === -1 ? pageFileLevel.has(key) : pageSymbols.get(key.slice(0, at))?.has(key.slice(at + 1));
+ if (used) continue;
problems.push(
- `[ledger-row-unused] NON_READ_ANCHORS excuses ${key} (${row.why}) but no anchor on the page ` +
- 'points there -- the row outlived the citation, or the anchor rotted off it.'
+ `[ledger-row-unused] NON_READ_ANCHORS excuses ${key} (${rows.map((r) => r.why).join('; ')}) but no ` +
+ `anchor on the page points there -- the row outlived the citation, or the anchor was re-keyed.`
);
}
@@ -1294,202 +1480,65 @@ export function evaluate({
const rowRefs = checkRowReferences({ pageText, ledger, pageRefs: pageRowReferences });
problems.push(...rowRefs.problems);
+ let citedSymbols = 0;
+ for (const cited of pageSymbols.values()) citedSymbols += cited.size;
+ let requiredSymbols = 0;
+ for (const need of required.values()) requiredSymbols += need.size;
+ let censusSymbols = 0;
+ let collapsingFiles = 0;
+ for (const entry of population.values()) {
+ censusSymbols += entry.symbols.size;
+ if (entry.symbols.size + (entry.fileLevel ? 1 : 0) !== entry.sites) collapsingFiles += 1;
+ }
+
return {
problems,
stats: {
anchors: anchors.length,
- anchorTargets: anchored.size,
- sites: sites.size,
+ citedSymbols,
+ requiredSymbols,
+ censusSymbols,
+ collapsingFiles,
+ fileLevelAnchors: pageFileLevel.size,
+ sites: census.sites.length,
packages: census.packages.length,
files: census.files.length,
- nonReadAnchors: located.size,
+ nonReadAnchors: declared.size,
missing: missing.length,
+ unexplained: unexplained.length,
rowRefsHeld: rowRefs.held.page + rowRefs.held.why,
rowRefsUnheld: rowRefs.held.unheld,
},
};
}
-/** A line list for a refusal message, capped so one bad file cannot flood the log. */
-function fmtLines(lines, cap = 14) {
- if (lines.length === 0) return '(none)';
- const shown = lines.slice(0, cap).join(', ');
- return lines.length > cap ? `${shown}, … (+${lines.length - cap} more)` : shown;
+function readFileAt(root) {
+ return (relPath) => readFileSync(join(root, relPath), 'utf8');
}
/**
- * The refusal, with everything it compared -- BOTH counts, BOTH target classes,
- * and the set difference.
- *
- * ⭐ Why the sets and not just the counts. Twice now this refusal has been read as
- * "your diff added or removed an elevation read site" when nothing of the sort had
- * happened, and the output gave the author no way to tell which case they were in
- * short of running `isystem-census.mjs --json` in two trees by hand. The last line
- * settles it mechanically: if NOTHING is already anchored the page is uniformly
- * displaced and some citation is unaccounted for; if everything but one target is
- * already anchored, that one target is the site that arrived.
+ * ⛔ `--fix` has nothing to repair, and says so instead of exiting 0 in silence.
+ *
+ * Before #15921 this rewrote line numbers after a pure shift, which was the
+ * common repair and a real one. Symbol anchors encode no position, so the shift
+ * that repair existed for cannot happen: an edit above a site moves nothing this
+ * page writes. Every red is now a human edit -- a rename, an arrived read, a
+ * vanished one -- and none of them is mechanically derivable from the tree.
+ *
+ * ⭐ The flag stays RECOGNISED on purpose. `gen:system-context-census` and
+ * `scripts/regen-artifacts.mjs` both name it, and a flag that silently became a
+ * no-op would leave both reading as a working regeneration path. This prints what
+ * it did not do, then returns the ordinary verdict.
*/
-function describeRefusal({ path, pageLines, censusLines, ledgerLines, targets, located }) {
- const anchoredSet = new Set(pageLines);
- const targetSet = new Set(targets);
- const alreadyAnchored = targets.filter((line) => anchoredSet.has(line));
- const unanchored = targets.filter((line) => !anchoredSet.has(line));
- const stray = pageLines.filter((line) => !targetSet.has(line));
- const why = ledgerLines
- .map((line) => `${line} (${located.get(`${path}:${line}`)?.why ?? 'declared non-read'})`)
- .join('; ');
- return (
- `${path}: the page anchors ${pageLines.length} distinct line(s) into this file, but the tree ` +
- `holds ${targets.length} anchorable line(s) -- ${censusLines.length} census read site(s) plus ` +
- `${ledgerLines.length} NON_READ_ANCHORS citation(s). The POPULATION changed, this is not a ` +
- 'shift. A row has to be written or deleted by hand.\n' +
- ` page anchors ......... ${fmtLines(pageLines)}\n` +
- ` census read sites .... ${fmtLines(censusLines)}\n` +
- ` ledger-excused ....... ${why || '(none)'}\n` +
- ` already anchored ..... ${alreadyAnchored.length} of ${targets.length} target(s)\n` +
- ` target, NO anchor .... ${fmtLines(unanchored)}\n` +
- ` anchor, NO target .... ${fmtLines(stray)}`
+function reportNoFix() {
+ process.stdout.write(
+ 'check-system-context-census --fix: nothing to rewrite — this page carries no line numbers.\n' +
+ ' Anchors are `path#symbol` (scripts/symbol-anchors.mjs), so an unrelated edit above a site\n' +
+ ' cannot rot one and there is no mechanical repair to apply. A red below is a human edit:\n' +
+ ' a renamed symbol, a read that arrived, or a read that vanished.\n'
);
}
-/**
- * Rewrite rotted read-site anchors and ledger anchors in place.
- *
- * Only pure shifts. Per file the page's DISTINCT anchor lines are compared with the
- * union of the two classes of line this page is allowed to anchor -- the census's
- * read sites and the `NON_READ_ANCHORS` citations -- and rewritten by order when
- * the two counts agree. A population change is left for a human.
- *
- * ## ⛔ Why the ledger cannot be subtracted by LINE (#13490)
- *
- * The obvious partition -- "a page anchor is a read anchor unless it sits on a
- * ledger line" -- compares two DIFFERENT coordinate systems. The page's anchors are
- * pre-shift, by construction: rot is the only reason `--fix` is running. The ledger
- * lines are post-shift, because a row locates itself by NEEDLE in the current tree.
- * So a file whose ledger-excused citation also moved has that citation counted as a
- * read anchor, and the gate reports a POPULATION change over a population that
- * never moved. Measured twice, in two lanes, on two different files:
- *
- * security-plugin.ts 7 read sites + 1 ledger citation, all displaced +20/+19 by
- * an unrelated bootstrap edit; zero `isSystem` lines added or
- * removed. Refusal: "page anchors 8 distinct read line(s),
- * census finds 7". (PR #13514, cost a patch round.)
- * rest-server.ts 6 read sites + 2 ledger citations, displaced +3/+11 by a
- * merge. Refusal: "page anchors 7 ... census finds 6", with
- * the contradicting `[ledger-row-unused]` line in the SAME
- * run's output.
- *
- * ⚠️ And it is wrong in the other direction too, which is the dangerous one: a
- * stale READ anchor that happens to land on a line the ledger now occupies was
- * SUBTRACTED, so the counts could agree by cancellation and the rewrite would map
- * the surviving anchors onto each other's rows -- a page that is wrong and GREEN,
- * because both classes stay covered. That crossing is real: on the second
- * occurrence `rest-server.ts:1267` was simultaneously the second inbound seam's new
- * home and a read row's stale anchor.
- *
- * ⭐ Comparing the UNION removes both directions at once, and buys a postcondition
- * the per-class comparison cannot state: the rewrite is a BIJECTION from the page's
- * distinct anchor lines onto the file's anchorable lines, so every census site is
- * anchored, every ledger row is used and no anchor is unexplained -- for every file
- * `--fix` touches, `evaluate` is clean by construction. That is why the union is
- * the safer of the two shapes, and it is the one taken: it also refuses when a
- * ledger citation was added or dropped without the page following, which comparing
- * reads alone would have rewritten straight past.
- *
- * ⛔ What it still cannot see, stated rather than papered over: alignment is by
- * ORDER, so a pure displacement is reconstructed exactly, but a REORDERING that
- * moves a cited construct past another one inside the same file is indistinguishable
- * from a shift on line numbers alone. No line-only tool can tell those apart -- and
- * `evaluate` cannot either, since both classes stay covered. Rows are matched to
- * lines by a human there, as they always were.
- *
- * @returns {{ text: string, rewrites: string[], refused: string[] }}
- */
-export function fixAnchors({ pageText, census, tracked, readFile, ledger = NON_READ_ANCHORS }) {
- const anchors = extractLineAnchors(pageText);
- const { located } = locateNonReadAnchors(ledger, readFile);
- /** ledger target lines, per file */
- const ledgerByFile = new Map();
- for (const key of located.keys()) {
- const at = key.lastIndexOf(':');
- const file = key.slice(0, at);
- if (!ledgerByFile.has(file)) ledgerByFile.set(file, []);
- ledgerByFile.get(file).push(Number(key.slice(at + 1)));
- }
-
- /** @type {Map