Repository navigation
Commit 9340181
fix(objectql,plugin-security): the write preview answers a row's organization from the tenant wall's own verdict (#25215)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgULzXHHREefG78bonpN6vqt)_
Fixes #25062
Clause-②: yes (widening): one additive registration member on
@objectstack/objectql's plugin-facing API
Metadata-protocol: none
Builds ruling 6336640643 (letter A, confirmed by the maintainer in
6336790186). Claim 6339442996, `Ruling-ref: 6336640643`. The
`plugin-security` half is the cross-lane touch declared on #21118
(comment 6339470244).
## Summary
`ObjectQL.validate()` (the import dry run) runs no middleware, so the
Layer 0 tenant write wall never judged a preview row. On a walled
deployment the preview reported `organization_id` as dropped where the
write stores it as sent (`group`, a sister organization), and answered
`valid` for a row the write refuses with 403 (`isolated`, a foreign
organization).
Now the wall's own verdict travels to the engine as a value, per row,
and the preview reads that value and nothing else:
- **`plugin-security`.** Step 3.7's judgement of a write is composed by
ONE method, `composeWriteTenantWall`, and the middleware itself calls
it. That covers the scope pre-test with its ADR-0090 D10 delegator leg,
the supplied-value forge guard, the emptied organization on update, the
#24784 / #24821 no-active-organization insert arms, and whether the wall
judges the organization. The payload half and the stored-image seam both
read it. `tenantParts` and `clearsWall` are each declared once, inside
it. `SecurityPlugin.writeWallVerdict(object, operation, context, rows)`
answers each row from that same method, over the principals the
middleware's own prologue resolves (`resolveOperationPrincipals`). It is
registered on the engine as `registerWriteWallVerdict`.
- **`objectql`.** `registerWriteWallVerdict(fn)` is new and optional,
beside `registerWriteGateProbe`, with the types `WriteWallVerdict` and
`WriteWallRowVerdict`. `validate()` asks for verdicts after the
defaults:
- **Admitted, organization judged:** `organization_id` is spared from
the static `readonly` strip exactly as `insert()` / `update()` spare it,
so `droppedFields` does not name it.
- **Refused:** the row answers `valid: false` with the write's code
`PERMISSION_DENIED` and the write's message. `field` is
`organization_id` for a refused value and `_record` for refused caller
state. The row runs no strip and reports no drop. Rows stay independent,
and the set's `valid` stays the conjunction.
- **Unregistered, or `null`:** today's strip, unchanged.
- The engine reads no posture, `tenantId` or `accessible_org_ids`. No
`OperationContext` member is added, no middleware is ridden, and no
contract text moves.
- **Second ask, for the stored-image half.** A row the strips changed is
asked about again, as the write's seam judges the row it stores. This is
the #24821 crossing operator: a platform operator with no active
organization names one on a private object. The wall does not judge the
organization, so the strip takes it, and the stored row has nowhere to
land. The first ask admits the row, and only the stripped row shows the
403 the write answers.
- **Docs.** The `registerWriteGateProbe` docblock is corrected (see
Acceptance notes). The `validate()` docblock names the new behaviour and
its limit. `content/docs/permissions/system-context.mdx` gains row 6c
for the verdict's system-context exit, which the system-context census
requires.
## Pins
| Pin | File | What it holds |
|:--|:--|:--|
| The two cells + caller state + controls, in process |
`packages/plugins/organizations/src/validate-preview-wall-verdict.test.ts`
(10) | Real `ObjectQL`, `SqliteWasmDriver`, `OrganizationsPlugin` and
`SecurityPlugin`. Each case runs the write and the preview on the same
row. Cell (a): `group` sister and `isolated` own organization, stored as
sent and no drop in the preview. Cell (b): `isolated` foreign and
`group` outside the membership set, write 403 and preview `valid: false`
with the write's code and message under `organization_id`. Caller state
under `_record`: ADR-0123 D2, #24784, #24821. Controls: `single`
posture, a non-tenant object (`tenancy.enabled: false`), and nothing
registered (`security: false`) |
| The HTTP import door, M5a / M5b |
`packages/rest/src/import-dryrun-tenant-wall-parity.test.ts` (4) | The
real route `POST /api/v1/data/:object/import`, the same rows dry then
committed, and the row reports must agree: M5a, M5b, a mixed batch, and
the `single` control |
| Equivalence: seam = middleware |
`packages/plugins/plugin-security/src/write-wall-verdict-equivalence.test.ts`
(21) | 19 table cases across `isolated` / `group` / `single`, member /
orgless / group-without-active / operator, insert / by-id update. The
real middleware, with the installed seam evaluated by the terminal,
gives the same admit / refuse / `judgesOrganization` / field as
`writeWallVerdict`. Plus a bulk case and the `null` exits |
| Engine contract |
`packages/objectql/src/validate-write-wall-verdict.test.ts` (14) | A
stand-in verdict on a fake driver: unregistered / `null` = today's
strip, admitted judged and unjudged, an authored `readonly`
`organization_id` keeps its strip, a refused row mid-batch, `_record`,
asked once after defaults with no hook run, asked again for a stripped
row, update mode, and a wrong-length or throwing verdict rejects |
`engine-insert-wall-judged-organization.test.ts` keeps its assertion;
its last title now says "with no write-wall verdict registered", which
is what it pins.
## Ablation (one-off, not kept)
The implementation was committed first (`3d071598`). Then the plugin's
feature-detect was renamed on disk to `registerWriteWallVerdictAblated`,
so the seam is never registered, and `@objectstack/plugin-security` was
rebuilt. `scripts/ablation-dist-preflight.mjs` confirmed the marker was
in `dist`.
- Organizations pins went 7 red (both cells and the three caller-state
cases) and 3 green (the three controls).
- REST pins went 3 red (M5a, M5b, the mixed batch) and 1 green (the
`single` control).
- Every failure was an `AssertionError`.
Restored with `git checkout HEAD --`. `git diff HEAD` is empty, the
package was rebuilt, and the preflight's `--absent` leg passed.
## Verification
All at head `0cc6f290a` unless noted. Every heavy command ran under
`os-verify-lock`.
- **Pins:** objectql `validate-write-wall-verdict`,
`engine-insert-wall-judged-organization` and
`federated-injected-column-readers` 32/32; plugin-security
`write-wall-verdict-equivalence` 21/21; organizations
`validate-preview-wall-verdict` 10/10; rest
`import-dryrun-tenant-wall-parity` 4/4. Lock `VERDICT command-exit 0`.
- **Package suites:**
- `@objectstack/plugin-security` full: 207 files, 4253 passed, 45
skipped, after the extraction. This is the behaviour-preservation check
for the moved step 3.7.
- `@objectstack/objectql` full: 8662/8663 at `3d071598`. The one red was
`federated-injected-column-readers.test.ts`: the new
`WriteWallRowVerdict` type names `organization_id` at module scope. It
got its `not-a-read` row in `0cc6f290` and is green at head.
- **Typecheck:** `typecheck` including `check:test-typecheck` for
objectql, plugin-security, organizations and rest. All OK, with no new
test-typecheck debt (the shrink-only ledger held for objectql; 0 entries
for the other three).
- **Derived gates:** `dispatch-gates.mjs --commands` derives 106
families. All 106 ran, and `--ran` with exit codes recorded reads `106
derived, 106 run, 0 NOT-MEASURED, 0 UNRUN`. Every recorded exit is 0.
Some needed a rerun for reasons outside this diff:
- `check-engine-split-ratio` and the `check-plugin-teardown-shape`
self-test refused on the shallow clone and passed once it was deepened.
- `check:i18n`, `check:dual-build-cjs-loads` and `check:skill-examples`
refused for missing `dist/` and passed after their named build closures.
- `check:query-options-erasure` once raced a concurrent
`.examples-build` writer and passed alone.
- `check-system-context-census` was a real finding, fixed by row 6c.
- The ratchet and census families were re-run at `0cc6f290a`.
- **Full dogfood:** n/a. The title has no `!` and the `Clause-②` arm is
`widening`.
- **CI:** pending at open; read from this PR's check-runs.
## Acceptance notes
- **The probe docblock finding (from report 6336244213).** The
`registerWriteGateProbe` docblock listed "its ADR-0123 D2 organization
wall (step 3.7)" among the arms the probe runs. The probe only asks step
3.7's scope pre-test, with no row. It does not ask the
supplied-organization forge guard, and it answers `true` for a foreign
organization (measurement M3). The docblock now says so, and points to
`registerWriteWallVerdict` for the row judgement. Two questions, two
members: the probe's boolean is unchanged.
- **Changeset level: `patch`, not the `minor` the dispatch named.** The
ruling sets the level "per AGENTS.md Post-Task Checklist step 3", and
`check-changeset-no-major` holds every entry of a `Metadata-protocol:
none` changeset to `patch`. The new member is optional and additive.
- **The dry run names the field; the commit does not.** A refused
dry-run row carries `field: organization_id` (or `_record`). The
commit's row is built from a thrown 403, which has no field on it. Code
and message are identical on both doors (M5b). The HTTP pin compares
everything but `field`, and pins the dry run's `field` on its own.
- **Named limit, kept from the ruling.** The preview judges the row as
it holds it, after defaults and before hooks. An organization written by
a `beforeInsert` / `beforeUpdate` hook is judged by the write's
stored-image half alone. This is named in the `validate()` docblock.
- **`null` exits.** `writeWallVerdict` answers "not asked" for a system
context, a context with no user, and a caller the context prologue
refuses before the wall is reached: principal-less, guest without an
organization, a subsystem failure, or a missing or non-member delegator.
The write refuses those callers before its wall, and the preview runs
none of the write's caller gates.
- **File surface.** The measurement probe
`validate-preview-wall-verdict.probe.test.ts` is deleted (`941fa4e1`),
and its readings are now the pins above. The surface also grew by two
ledger rows the repo's own guards require, beyond the claim's list. One
is `system-context.mdx` row 6c: `check-system-context-census` refuses an
elevation read that no row anchors, and `writeWallVerdict` reads
`isSystem`. The other is a `federated-injected-column-readers.test.ts`
READERS row for the verdict type's `organization_id` literal.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0157EiYDGYnrsXE9aHv1uMxU)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3e07389 commit 9340181
13 files changed
Lines changed: 1861 additions & 332 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- objectql/src
- plugins
- organizations/src
- plugin-security/src
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| 106 | + | |
106 | 107 | | |
107 | 108 | | |
108 | 109 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
25 | | - | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
26 | 27 | | |
27 | 28 | | |
28 | 29 | | |
| |||
263 | 264 | | |
264 | 265 | | |
265 | 266 | | |
266 | | - | |
| 267 | + | |
267 | 268 | | |
268 | 269 | | |
269 | 270 | | |
| |||
0 commit comments