You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(sharing): stamp organization_id on every sys_record_share write, backfill the stranded rows, admit the object to the tenancy ledger (#14484) (#14726)
* wip(sharing): stamp organization_id on sys_record_share writes; ledger row; backfill module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* feat(sharing): stamp organization_id on every sys_record_share write, backfill legacy rows, admit the object to the tenancy ledger
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* chore(census): re-derive the tenant-audit and system-context censuses on the merged tree (#14484)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* docs(census): restate the tenant-audit prose figures at the new population (#14484)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* fix(sharing): keep tracker ids out of operator-facing strings; give the backfill test double the precedent's shape (#14484 gates)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* chore(census): re-anchor the system-context census after the gate fixes (#14484)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* chore(census): re-derive the generated artefacts on the merged tree (#14484)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* fix(sharing): a failed record read never stamps the acting session's organization on a direct grant (#14484)
Patch round 1 of the 2026-09-02 contract review (BLOCKING 1): `recordOrganization`
returned the same `null` for "the record carries no organization" and for "the
read threw", so `resolveDirectGrantOrganization` fell through to the acting
session's organization on a transient read failure — a wrong stamp on a
permission-boundary column that the backfill (`WHERE organization_id IS NULL`)
could never repair.
The reading is now a three-way `RecordOrganizationReading` (`organization` /
`none` / `read-failed`). The direct path substitutes the session's organization
for `none` only; `read-failed` yields `null` to the engine's ruled
derive-or-refuse (#8844). The warn text now states what the failed read does
NOT do. Pinned on the double (insert half, update half, and the `none` control)
and on a real SqlDriver + ObjectQL: `single` with no organization -> NULL,
`single` holding two organizations -> refused as ambiguous, `isolated`/`group`
-> refused, plus the read-working control on both.
Review §5: note 3 pinned (the scoped update half's silent no-op on a row
stamped with another organization — `grant`'s return value, the two-rules
`grantsCreated` path and the re-homed-record `grantsUpdated` path); note 1 —
the changeset now states the two shipped paths that meet the walled-install
refusal (platform-global rule x organization-less record, with the mid-loop
reconcile abort; and a failed direct-grant read); note 2 — objectql graded
`minor`. Census page re-anchored with `--fix` (8 line-rot anchors, 0 refused).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* test(sharing): pin the reachable failed-read shapes and the measured RECORD_NOT_FOUND abort (#14484)
CI on the previous head measured two things the first spelling of these pins
assumed wrongly.
1. A plain `{ userId, tenantId: ORG_B }` session never reaches an ORG_A record
through `grant`: `assertCanManageShares` -> `isRecordVisible` reads under
the CALLER's context and the driver scopes it to `organization_id = :active
OR IS NULL` on every posture. The reachable wrong-stamp shape is the `group`
one — a multi-member owner active in the sibling organization, whose
membership set the engine threads as `tenantIds` — plus the `single`
install holding several organizations under a session carrying no active
organization (nothing to stamp wrongly, but the failed read must end in the
ambiguity refusal, never a NULL row). Both pinned with their read-working
controls; `isolated` is unreachable (one organization per session).
2. Review §5 note 3 read the scoped update half's unreachable-row case as a
SILENT no-op. Measured on the real engine it is loud: `ObjectQL.update`
reports the unreachable row as RECORD_NOT_FOUND (404), `grant` throws it,
and `evaluateRule`'s pass aborts there — grants before it stay, the ones
after it and the stale-row revocation never run. Pinned as measured (the
direct grant, the two-organizations path with a stale-row witness, the
re-homed-record path); the implementation is unchanged, and the `grant`
comment that called it silent now says what was measured.
Census page re-anchored with `--fix` (4 line-rot anchors, 0 refused).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
* chore(census): re-derive the generated artefacts on the merged tree (#14484)
The merge of origin/main conflicted in two generated census artefacts and
silently dropped one side of a third. All three are resolved by regeneration,
never by hand:
node scripts/tenant-audit-census.mjs --write
pnpm gen:system-context-census
The tenant-audit population is the joint one: main's suspended-run-store row
and this branch's backfill row both land, moving the census 218 -> 219 write
call sites. The nine hand-written prose figures the gate holds to that census
(check B) follow it; `scripts/engine-double-contract.pinned.json` needed no
regeneration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
---------
Co-authored-by: Claude <noreply@anthropic.com>
`sys_record_share` is tenant-scoped: every grant row now carries `organization_id`, and the rows written before it can be backfilled from the record they grant access to (#14484).
7
+
8
+
Every `sys_record_share` row on every deployment was written with `organization_id = NULL`: `SharingService.grant` wrote under a bare system context and the row literal never carried the column, so neither the driver's `injectTenantOnInsert` nor the engine's system-write organization rule had anything to stamp from. Reads agreed with writes — the service's own reads are bare-context too — so nothing was visibly broken; what the NULL cost was the cliff: the first tenant-facing read of the table inherits `plugin-security`'s Layer 0, whose strict `organization_id = :tenant` AND-composes over the driver's NULL-tolerant arm and wins, and every existing grant silently disappears — not refused, simply "this person was never granted access". Maintainer ruling 2026-09-02 (decision batch #11 item 3, A adopted — 「#13564 转维护者处理;其他同意」): tenant-scoped, writer-repaired, existing rows backfilled from the record they reference. The per-table order the `sys_file` precedent requires; it covers `sys_record_share` and no other table.
9
+
10
+
**Writer.** `SharingService.grant` stamps `organization_id` on both halves of its upsert. A rule-materialised grant carries the granting RULE's organization — `SharingRuleService.reconcile` / `reconcileForRecord` now hand `grant` the rule's own `criteriaContext` (`{ isSystem, tenantId: rule.organization_id }`), the same context the rule's criteria sweep ran under, so the grant lands in the organization whose records the rule was allowed to sweep. A direct grant carries the shared RECORD's organization, read off the column its object is walled by (`resolveTenantFieldName`: ADR-0066 opt-out → declared `tenancy.tenantField` → injected `organization_id`), with the acting session's organization as the fallback for a record that carries none — and only for one that carries none: a record whose organization could not be READ is unknown, not organization-less, so that grant carries `null` for the engine's rule below rather than the session's organization (which may not be the record's). The organization rides the write context as `tenantId` as well as the row — the `{ isSystem, tenantId }` shape the #8844 refusal prescribes — so the driver's tenant audit is satisfied and the update half lands through the driver's scope (`organization_id = ? OR IS NULL`, which keeps a pre-repair NULL row in reach). Nothing resolvable ⇒ an explicit `null` on the row, for the engine's ruled rule to decide (below). The service's eleven bare-context READS are unchanged by this change; whether they become tenant-scoped is #13564's question.
11
+
12
+
**Ledger (`@objectstack/objectql`).** `sys_record_share` leaves `unclassified` in the #13491 per-object tenancy ledger as `tenant-scoped`, with the ruling as the cited fact. Consequence, per the ledger's own admission semantics: an organization-less SYSTEM insert on `sys_record_share` is now derived on a `single` install with exactly one organization and REFUSED loudly on a walled one (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500) — and the engine no longer auto-mutes the driver's tenant-audit warning for elevated writes on it. The only writer in this repository is repaired in the same change and carries the organization on every path that can resolve one — but two shipped paths still resolve none, and on a walled install they now meet that refusal instead of writing a NULL row. **(a)** A platform-global sharing rule (`organization_id = null`; its sweep runs unscoped) matching an organization-less record: the grant resolves `null`, the engine refuses it, and because `reconcile` has no per-grant catch that rule's reconcile aborts mid-loop — grants already written in the pass stay, the remaining grants and the stale-row revocations of that pass do not happen (the boot backfill logs the rule and continues; the write hooks catch). **(b)** A direct grant whose read of the shared record's organization failed: the acting session's organization is deliberately not substituted, so the grant is refused with the same error rather than written into an organization that may not be the record's. On a `single` install with exactly one organization both derive it; with several, the same `null` is refused as `ambiguous-organization`. A third-party writer that inserts `sys_record_share` under a bare system context on a walled install meets the refusal too, and the refusal message says how to carry the organization.
13
+
14
+
**Ops: the backfill — dry run first, and by default.** `packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts` scans only rows whose organization column is unset, re-reads each row's record (`object_name` + `record_id`) at repair time, and stamps the row with the record's own organization off the column that object is walled by. `planSysRecordShareOrganizationBackfill(engine)` reads only and returns a report naming every row it would touch; `runSysRecordShareOrganizationBackfill(engine, { dryRun: false })` writes. Nothing runs at boot and nothing is scheduled: this is an operator-invoked module, run once against an affected install, the posture of both precedents. **Orphans — grant rows whose record no longer exists — are left NULL, counted (`totals.orphans`) and logged, never deleted here:** the "record gone ⇒ the row cannot describe any access" invariant is already owned by the `kernel:bootstrapped` orphan sweep (`sweepOrphanedRecordShares`, #5103), which reclaims exactly that population on the next boot; a second deleter would be the fork `record-orphan-cleanup.ts` exists to prevent. Every other row that cannot be derived — an object with no organization column, a record that carries none, a record whose read failed — stays NULL and is reported by reason, for a dry run too. Idempotent by construction: every scan is `WHERE <organization column> IS NULL` and every write fills that column, so the test suite runs the sweep twice and pins the second run at zero writes.
15
+
16
+
Publishes no runtime code for the backfill: the module is not exported from the package index and not bundled into `dist` (`tsup` builds `src/index.ts`). It is graded rather than skipped because the release notes are where an operator of an affected install learns the repair exists, what it will and will not touch, and that the dry run comes first.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+5-5Lines changed: 5 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -129,11 +129,11 @@ The largest single consumer — **20 of the 109 sites**.
129
129
| # | Behaviour when `isSystem`| What you get / what you lose | Anchor |
130
130
|:--|:---|:---|:---|
131
131
| 30 |**Sharing-rule grant materialisation is skipped on all four record-write hooks**| Lose: **no `sys_record_share` rows are created**. A fully configured sharing rule grants **nothing** on seeded data until a rule is re-evaluated or the boot backfill runs. This is the behaviour that motivated #4707. Since #6783 the skip is no longer silent — it emits an INFO notice (rough edge 2) |`rule-hooks.ts:250`, `:274`, `:293`, `:322`|
132
-
| 31 | Sharing write verdict short-circuits to `allow`| Get: writes pass the sharing gate unconditionally |`plugin-sharing/src/sharing-service.ts:654`|
133
-
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement |`plugin-sharing/src/sharing-service.ts:920`, `:1007`, `:1597`|
134
-
| 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:1208`|
135
-
| 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:1286` (guard at `:1311`) |
136
-
| 35 |`listShares()` skips the management gate | Get: full enumeration of who can see a record |`plugin-sharing/src/sharing-service.ts:1338`|
132
+
| 31 | Sharing write verdict short-circuits to `allow`| Get: writes pass the sharing gate unconditionally |`plugin-sharing/src/sharing-service.ts:677`|
133
+
| 32 | Record visibility / manage-shares checks return true | Get: no ownership or Modify-All requirement |`plugin-sharing/src/sharing-service.ts:943`, `:1030`, `:1787`|
134
+
| 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`|
135
+
| 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`) |
136
+
| 35 |`listShares()` skips the management gate | Get: full enumeration of who can see a record |`plugin-sharing/src/sharing-service.ts:1528`|
137
137
| 36 |`sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing`|`sharing-plugin.ts:1077`|
138
138
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off |`plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610`|
139
139
| 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:47`|
Copy file name to clipboardExpand all lines: content/docs/permissions/tenant-audit-census.mdx
+17-17Lines changed: 17 additions & 17 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.
98
98
99
99
The same holds twice over for the context. An options argument spelled as a
100
100
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
101
-
forwarding shim cannot, and **67 of the 218 sites are spelled that way**. A
101
+
forwarding shim cannot, and **67 of the 219 sites are spelled that way**. A
102
102
context resolved from an inline literal or a local `const` can be tested for
103
103
`isSystem`; one arriving from a helper call cannot.
104
104
@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
147
147
148
148
| carried figure | where it survives | this census |
149
149
| :--- | :--- | ---: |
150
-
| 175 write call sites | quoted in the merged changeset |**218**|
150
+
| 175 write call sites | quoted in the merged changeset |**219**|
151
151
| 24 carrying no tenant context | quoted in the merged changeset |**9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
152
-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**146 of 218** decidable, **72** undecidable |
153
-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 100 decidably elevated, 0 decidably not, 101 undecidable |
152
+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**147 of 219** decidable, **72** undecidable |
153
+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 101 decidably elevated, 0 decidably not, 101 undecidable |
154
154
| 141 and 132, two independent re-derivations | the card that filed this work | — |
155
155
156
156
**The differences are not reconciled, and deliberately so.** The old census's
@@ -161,17 +161,17 @@ at any commit.
161
161
162
162
Two structural facts do plausibly widen this reading against any hand or regex
163
163
one, and both are counted in the generated tables below: the 45 sites reached
164
-
through an erased (`any`) receiver, and the 38 that name their object through a
164
+
through an erased (`any`) receiver, and the 39 that name their object through a
165
165
`const` rather than inline. An instrument that read either the way a person does
166
166
would report a smaller number and would not say so.
167
167
168
168
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
169
169
figure has no surviving corroboration anywhere in the tree.** This census reads
170
-
100 of 218 (46%) as decidably elevated, with 101 more whose elevation is a
170
+
101 of 219 (46%) as decidably elevated, with 101 more whose elevation is a
171
171
run-time fact — so the claim is neither confirmed nor refuted, and the honest
172
172
answer is that a static reading cannot settle it.
173
173
174
-
⇒ **Cite `9 / 218`, and say what it is**: the sites whose options argument was
174
+
⇒ **Cite `9 / 219`, and say what it is**: the sites whose options argument was
175
175
READ and holds no tenant context, against a decidably tenancy-enabled object.
176
176
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
177
177
without tenant context" — **32 further sites** have an options argument this
@@ -183,29 +183,29 @@ cannot read, and they are neither in nor out.
183
183
184
184
| what | count |
185
185
| :--- | ---: |
186
-
| write call sites on the application surface |**218**|
187
-
| …whose object name is statically decidable |146|
186
+
| write call sites on the application surface |**219**|
187
+
| …whose object name is statically decidable |147|
188
188
| …whose object name is chosen at run time | 72 |
189
-
| …against an object with tenancy ENABLED |146|
189
+
| …against an object with tenancy ENABLED |147|
190
190
| …against an object that declares tenancy off | 0 |
191
-
| threading a tenant context |134|
191
+
| threading a tenant context |135|
192
192
| PROVABLY carrying none (options read, no context key) |**17**|
193
193
| …of those, against a decidably tenancy-enabled object |**9**|
194
194
| options argument UNREADABLE — may or may not carry one | 67 |
195
195
| …of those, against a decidably tenancy-enabled object | 32 |
196
-
| threading a decidably ELEVATED (`isSystem`) context |100|
196
+
| threading a decidably ELEVATED (`isSystem`) context |101|
197
197
| threading a context that is decidably NOT elevated | 0 |
198
198
| threading a context whose elevation is a run-time fact | 101 |
199
199
200
200
| how the instrument reached the site | count |
201
201
| :--- | ---: |
202
-
| receiver carried a readable engine type |173|
202
+
| receiver carried a readable engine type |174|
203
203
| receiver erased, placed by the object NAME | 19 |
204
204
| receiver erased, placed by an `object: string` PARAMETER | 15 |
205
205
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |
206
206
207
207
| object name spelled inline | 108 |
208
-
| object name spelled through a `const`|38|
208
+
| object name spelled through a `const`|39|
209
209
| object name is an `object: string` parameter | 19 |
210
210
| object name is some other run-time expression | 53 |
211
211
@@ -224,12 +224,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
224
224
their values are not compared. The reasoning, and the measurement behind it,
225
225
are in `scripts/check-tenant-audit-census.mjs`.
226
226
227
-
Measured on 2026-09-02 at `5daab8df0`.
227
+
Measured on 2026-09-03 at `98b1cf0b7`.
228
228
229
229
| corpus scale (not enforced) | count |
230
230
| :--- | ---: |
231
-
| tracked non-test sources scanned |539|
232
-
| engine-shaped types recognised |56|
231
+
| tracked non-test sources scanned |540|
232
+
| engine-shaped types recognised |57|
233
233
| declared objects in the registry | 297 |
234
234
| same-named calls subtracted as non-engine | 130 |
0 commit comments