Skip to content

Commit e2debee

Browse files
fix(metadata-protocol): compare OCC version tokens as instants, not spellings (#13382) (#13569)
* fix(metadata-protocol): compare OCC version tokens as instants, not spellings (#13382) On Postgres — the production default driver — every guarded save answered 409 CONCURRENT_UPDATE, including on records nobody had ever touched. The OCC gate read `updated_at` through `String(v)`; on a Date-returning driver that is `Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)` — milliseconds dropped, process timezone baked in — compared as a string against the `2026-08-30T10:19:25.947Z` the client echoed back from its own GET. One instant, two spellings. SQLite returns canonical ISO text, so both sides matched by accident and development environments stayed green. Both tokens are now normalised to one representation — a canonical absolute instant — before comparison, and the `currentVersion` a 409 publishes is that same canonical instant: what the wire format documents, and the token the conflict dialog echoes back as its next If-Match. Strictly widening: when either side is not an instant the verbatim comparison still runs, so no token accepted before is refused now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * chore: record the new OCC engine double in the pinned ledger (#13382) `check:engine-double-contract` retains a pin per (file, verb). The regression suite added for #13382 carries a fake engine whose delete/update/findOne route through the producer's own dispatch predicates, so the ledger has to learn about it or it never protects the file. `--write`, 3 rows added, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * docs(permissions): re-anchor the isSystem census row onto the shifted line (#13382) `content/docs/permissions/system-context.mdx` anchors elevation reads by line number. The OCC fix added 158 lines above `stripReadonlyForInsert`, so row 21's anchor into metadata-protocol rotted: the census found an unanchored read at :1566 and an anchor at :1451 that is no longer a read site. A PURE SHIFT, verified before repairing rather than assumed — the gate refuses to guess a population change and rewrites only line rot: base :1451 and head :1566 are the byte-identical `if (context?.isSystem) return data;` (sha256 db885f75…), the `isSystem` occurrence count in the file is 9 on both trees, and :1566 is the first line of `stripReadonlyForInsert`, which is exactly what row 21 describes. Repaired with the gate's own `--fix`; one anchor rewritten, no prose touched, nothing baselined or exempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * fix(metadata-protocol): keep an empty If-Match entity-tag opting OUT of OCC (#13382) Contract review caught a regression this PR introduced. `If-Match: ""` is empty only AFTER the RFC-7232 quotes come off, and the pre-fix seam returned the bare string, so that case handed every caller the falsy `''` and they short-circuited into "no token supplied". Wrapping the result in an object made it always truthy, so the check began running and `''` mismatched verbatim: an accept-to-refuse flip on a shipped API, falsifying this PR's own strictly- widening claim. Remedy is the conservative one — re-apply the emptiness test after the strip, so the behaviour is byte-identical to `70fe54891e`. Whether an empty entity-tag SHOULD be able to disable OCC is a contract question, filed separately; a p1 bug-fix PR does not silently install a new rejection. The claim is no longer left as prose. A new block sweeps a corpus of 8 stored shapes x 15 client tokens against the pre-fix comparison reproduced verbatim and fails on any pair that was accepted before and is refused now. Run against the unfixed tree it reports exactly the 5 `""` pairs and nothing else, so the review finding was the whole regression rather than one instance of a class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09b0d7b commit e2debee

5 files changed

Lines changed: 674 additions & 14 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
Fix optimistic concurrency raising a false `409 CONCURRENT_UPDATE` on every guarded save against a `Date`-returning driver (Postgres, MySQL, MongoDB).
6+
7+
The OCC gate read the record's `updated_at` through `String(v)`. On Postgres that value is a JS `Date`, so the comparison ran against `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` — milliseconds dropped, process timezone baked in — while the client echoed back the `"2026-08-30T10:19:25.947Z"` its own GET had served. One instant, two spellings, strict string compare: every guarded `PATCH` / `DELETE` conflicted, including on records nobody had ever touched, which made the Console's record-edit dialog unusable on the production default driver. SQLite stores and returns canonical ISO text, so both sides matched by accident and development environments never saw it.
8+
9+
Both tokens are now normalised to one representation — a canonical absolute instant — before they are compared, and the `currentVersion` a 409 publishes is that same canonical instant, which is what `content/docs/api/wire-format.mdx` documents the field to be and what the conflict dialog echoes back as its next `If-Match`.
10+
11+
The change is strictly widening: a token accepted before is still accepted (a pair whose verbatim spellings were equal still compares equal, and when either side is not an instant the verbatim comparison is what runs), so a client mid-upgrade still holding a pre-fix 409's token is not locked out. Only two spellings of the same instant change verdict — from conflict to match. Conflicts between genuinely different versions are unchanged, down to the millisecond, and the verdict no longer depends on the process `TZ`.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ that silently does not happen.
112112
| 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:10581` |
113113
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10743` |
114114
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9474` |
115-
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1451` |
115+
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1576` |
116116
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9511`, `readonly-strict-errors.ts:66` |
117117
| 23 | **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:5603` |
118118
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3571`, `:3573`, `:3600` |

0 commit comments

Comments
 (0)