Repository navigation
Commit 7dc7d22
fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read (#23628)
Fixes #23586
Clause-②: no
## What changes (function level)
`metadata-protocol`'s `updateData` and `deleteData` judge the caller's
version token on the row `probeRecord` reads with a system context. That
probe asks existence, not visibility, and it stays exactly as it is.
What changes is the mismatch branch of `assertVersionOf`:
- On a mismatch, the row is read again under the caller's own `context`
(`readAsCaller`, a by-id `engine.findOne` with the same context spelling
as the write it precedes), so every RLS and FLS rule of the read door
applies.
- **No row for the caller:** `assertVersionOf` throws
`recordNotFoundError(object, id)`. That is the same producer the
security layer's by-id write gate uses for a row the caller cannot read,
so the stale-token request gets exactly what the same request without a
token gets: `404 RECORD_NOT_FOUND`, with no `currentVersion` and no
`currentRecord`.
- **A row for the caller:** `ConcurrentUpdateError` as before, with
`currentRecord` set to the caller's read, so it is field-masked like the
caller's own read. `currentVersion` is still the token the comparison
ran on.
- **A token that matches, or no token:** unchanged. The extra read
happens only on a mismatch.
`assertVersionOf` became async and takes the request context. Both
callers (`updateData`, and `assertVersionMatch` for `deleteData`) pass
`request.context` and await it.
### The four `probeRecord` callers
- `updateData` and `assertVersionMatch` (`deleteData`): these are the
conflict path. Both are fixed by the one change in `assertVersionOf`.
- `assertRecordExists`, used by `updateManyData` and `batchData`'s
`update` branch: existence only. It never takes a version token, never
hands the probed row to the caller, and leaves the write to
`engine.update` under the caller's policy. Not this path.
- `batchData`'s `upsert` fork: existence only, deciding update versus
insert. It has no version token, and the response row is
`engine.update`'s readback under the caller's context, never the probed
row. Not this path.
## Pins
New file
`packages/runtime/src/domains/data-conflict-caller-visibility.test.ts`.
It uses the measured rig shape: a real `ObjectQL` over a real
`SqlDriver` (better-sqlite3), with the real `SecurityPlugin` started (an
RLS policy and an FLS rule on one object, nothing on a second). It runs
`PATCH` and `DELETE` through `RestServer`'s registered routes and
through the runtime `HttpDispatcher`'s `/data` domain. A dispatcher
throw is rendered by `declaredEnvelopeForThrow`, the exit the Hono hosts
use, and the thrown error is read too. Per door and verb:
1. **A row RLS hides:** the stale-token answer equals the control (the
same request without a token): same status, same body (`toEqual`), with
no `currentVersion` or `currentRecord` on the wire or on the thrown
error. The row is unchanged.
2. **A field FLS hides:** `409 CONCURRENT_UPDATE`, `currentVersion`
equal to the stored token, and `currentRecord` equal to the caller's own
read of the row, without the hidden field.
3. **Positive control, a row the caller reads whole:** `409` with the
whole row and its version, so conflict detection still works.
4. **A matching token:** `200`, and the engine's `findOne` is called the
same number of times as without a token (plus the one probe `DELETE`
already issues when a token is present).
The pins live in `runtime`, beside the existing both-door OCC pins
(`data-update-expected-version.test.ts`,
`data-delete-expected-version.test.ts`). It is the one package whose
test graph holds `RestServer`, the dispatcher, `plugin-security`, a SQL
driver and `declaredEnvelopeForThrow` together. They are in a new file.
## Verification
The readings below were taken at `7a4652fdb4`, before `main` was merged
in at `2a20fe2d80`. The re-runs on the merged head are in the
`os-dev-report` comment on #23586.
- **Red, before the fix** (pins on the unfixed `metadata-protocol`
dist): `8 failed | 8 passed (16)`. The eight failures are the RLS and
FLS rows on both doors and both verbs (a `409` carrying the hidden row
or field where the control answered `404`). The positive-control and
matching-token rows passed.
- **Green, after the fix** (`metadata-protocol` rebuilt): `16 passed
(16)`. The final verification runs are listed in the report comment on
#23586.
- `pnpm --filter @objectstack/metadata-protocol test`: 237 files, 28429
passed. `typecheck`: exit 0.
- `pnpm --filter @objectstack/runtime test`: 385 files, 5798 passed.
`typecheck` (with `check:test-typecheck`): exit 0, and the new file adds
no ledger entry.
- `pnpm --filter @objectstack/rest test`: 285 files, 5858 passed.
- Gates: the `dispatch-gates --commands` list for this diff, run one by
one. `check:query-options-erasure` caught an untyped options bag on the
first pass. It is now typed to `EngineQueryOptionsParsed` /
`EngineQueryOptions`. Per-gate readings are in the report.
## Acceptance notes
- **The Hono doors (`plugin-hono-server`) are not touched.** After this
change the producer's conflict answer holds only what the caller reads,
so if `declaredConflictContext` forwarded `currentRecord`, that record
would be safe to send. The hold #23587 placed there can be lifted on
that card. A row the caller cannot read now answers `404
RECORD_NOT_FOUND` through those doors as well.
- **`currentVersion` is the token the comparison ran on** (the system
probe's `updated_at`), so its meaning is unchanged. If an FLS rule ever
hid `updated_at` itself, the version token would still be published on a
conflict. A `git grep` over `examples/` and `packages/` finds no
field-permission entry keyed on `.updated_at` (0 hits), and this PR does
not change that case.
- **A caller-context read that throws** (the object's read grant
withheld, a store fault) propagates as raised. It is never relabelled as
a conflict or a not-found.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01YNquHRZKDpiKsgmHXSd9Jx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent edac218 commit 7dc7d22
3 files changed
Lines changed: 435 additions & 11 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- runtime/src/domains
| 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 | |
|---|---|---|---|
| |||
2798 | 2798 | | |
2799 | 2799 | | |
2800 | 2800 | | |
2801 | | - | |
| 2801 | + | |
| 2802 | + | |
| 2803 | + | |
2802 | 2804 | | |
2803 | 2805 | | |
2804 | 2806 | | |
| |||
14633 | 14635 | | |
14634 | 14636 | | |
14635 | 14637 | | |
14636 | | - | |
| 14638 | + | |
14637 | 14639 | | |
14638 | 14640 | | |
14639 | 14641 | | |
| |||
14729 | 14731 | | |
14730 | 14732 | | |
14731 | 14733 | | |
14732 | | - | |
| 14734 | + | |
14733 | 14735 | | |
14734 | 14736 | | |
14735 | 14737 | | |
| |||
14870 | 14872 | | |
14871 | 14873 | | |
14872 | 14874 | | |
14873 | | - | |
14874 | | - | |
14875 | | - | |
14876 | | - | |
| 14875 | + | |
| 14876 | + | |
| 14877 | + | |
| 14878 | + | |
| 14879 | + | |
14877 | 14880 | | |
14878 | 14881 | | |
14879 | 14882 | | |
| |||
14903 | 14906 | | |
14904 | 14907 | | |
14905 | 14908 | | |
| 14909 | + | |
| 14910 | + | |
| 14911 | + | |
| 14912 | + | |
| 14913 | + | |
| 14914 | + | |
| 14915 | + | |
| 14916 | + | |
| 14917 | + | |
| 14918 | + | |
| 14919 | + | |
| 14920 | + | |
| 14921 | + | |
| 14922 | + | |
| 14923 | + | |
| 14924 | + | |
| 14925 | + | |
| 14926 | + | |
| 14927 | + | |
| 14928 | + | |
| 14929 | + | |
14906 | 14930 | | |
14907 | | - | |
| 14931 | + | |
14908 | 14932 | | |
14909 | 14933 | | |
14910 | 14934 | | |
14911 | 14935 | | |
14912 | | - | |
| 14936 | + | |
| 14937 | + | |
14913 | 14938 | | |
14914 | 14939 | | |
14915 | 14940 | | |
| |||
14927 | 14952 | | |
14928 | 14953 | | |
14929 | 14954 | | |
| 14955 | + | |
| 14956 | + | |
14930 | 14957 | | |
14931 | 14958 | | |
14932 | | - | |
| 14959 | + | |
14933 | 14960 | | |
14934 | 14961 | | |
14935 | 14962 | | |
| |||
14956 | 14983 | | |
14957 | 14984 | | |
14958 | 14985 | | |
| 14986 | + | |
14959 | 14987 | | |
14960 | 14988 | | |
14961 | 14989 | | |
14962 | 14990 | | |
14963 | | - | |
| 14991 | + | |
| 14992 | + | |
| 14993 | + | |
| 14994 | + | |
| 14995 | + | |
| 14996 | + | |
| 14997 | + | |
| 14998 | + | |
| 14999 | + | |
| 15000 | + | |
| 15001 | + | |
| 15002 | + | |
| 15003 | + | |
14964 | 15004 | | |
14965 | 15005 | | |
14966 | 15006 | | |
| |||
0 commit comments