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
{{ message }}
Repository navigation
Commit 706ad0f
Browse filesBrowse the repository at this point in the historyBrowse files
fix(objectql): name the withheld read-only key when a hook faults reaching through it (#17327)
* fix(objectql): name the withheld read-only key when a hook faults reaching through it
Since #16344 the update path hides a caller-supplied static `readonly` value
from `before*` hooks. A hook reaching THROUGH such a key dereferences
`undefined` and throws, and a `body` hook's default `onError: abort` refuses
the caller's whole write.
The refusal is correct and is not touched here. The diagnostic was the defect.
Measured at both doors before this change:
direct SandboxError: hook 'guard_task_body' threw:
TypeError: cannot set property 'who' of undefined
REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"}
The REST reading is the worse of the two and it is the door an author authors
against: a leading `TypeError:` is correctly classified as a script fault and
sanitised (#7543), so nothing reached the author -- not the key, not the
reason, not the remedy.
The engine is the only actor that can tell "the platform took this away" from
"nobody sent it": the sandbox face is a plain JSON snapshot, so a withheld key
is simply absent there. So the explanation is composed at the hide pass, and
all three `beforeUpdate` dispatch sites inside the hide window share one
wrapper. Nothing is marshalled onto the sandbox face.
The composed error names the withheld key, says the platform withheld it
because the field is `readonly: true`, points at `ctx.previous`, carries the
original fault text, and declares HTTP 400 -- which is what carries it past
`mapDataError`'s sanitiser instead of into a blank 500. No error code is
registered and no published payload gains a key.
Three declines keep it from repeating the card's own defect in the opposite
direction: nothing withheld, an authored refusal, and a non-object throw all
rethrow the original error untouched.
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-authored-by: Claude <noreply@anthropic.com>
* fix(objectql): declare `cause` on the fault error instead of passing it through `super()`
`pnpm --filter @objectstack/objectql typecheck` exited 2 on the previous
commit:
src/hook-withheld-readonly-fault.ts(140,20): error TS2554: Expected 0-1 arguments, but got 2.
src/hook-withheld-readonly-fault.ts(140,31): error TS2304: Cannot find name 'ErrorOptions'.
This repo compiles against `lib: ES2020`, where `Error` has neither a `cause`
member nor the `ErrorOptions` constructor overload that carries one. The
precedent is in this same package: `duplicate-record-error.ts` declares
`readonly cause: unknown` on the class and assigns it by hand, and its comment
already records why -- an undeclared assignment would be invisible to every
TypeScript consumer of the field. Followed verbatim rather than reinvented.
No behaviour change: the same original error is attached under the same name,
and both suites plus the reverse verification were re-run against a forced
rebuild after the edit.
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219)
6
+
7
+
Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write.
8
+
9
+
**The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors:
10
+
11
+
```
12
+
direct SandboxError: hook 'guard_task_body' threw:
13
+
TypeError: cannot set property 'who' of undefined
14
+
REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"}
15
+
```
16
+
17
+
The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead.
18
+
19
+
### Who is affected
20
+
21
+
Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.<field>`** — the same remedy PR #17195's changeset documents.
22
+
23
+
### What the message says now
24
+
25
+
```
26
+
A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is
27
+
`readonly: true`, and the engine withholds a caller-supplied value for a read-only field
28
+
from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the
29
+
platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta`
30
+
instead. Original fault: TypeError: cannot set property 'who' of undefined
31
+
```
32
+
33
+
The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced.
34
+
35
+
### Deliberate limits
36
+
37
+
No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched.
0 commit comments