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
A declarative update action answers 404 RECORD_NOT_FOUND where the data update on the same row answers 403 PERMISSION_DENIED #30848
Filing gate: ③ an out-of-scope finding (class b) from the #30802 dev's measurement (os-dev-report 6678640069, PR #30846). ⛔ Not a claim. Suggested grade: p2. Routing is triage's.
What happens
A caller who can read an object but has no edit grant on it, writing one row they cannot see (server 18.3.0, mainc17719dd, measured on a booted app):
Door
Answer
MCP update_record, REST PATCH /api/v1/data/...
403 PERMISSION_DENIED
MCP run_action on a declarative update action (decide_approval_step)
404 RECORD_NOT_FOUND
MCP get_record
404 RECORD_NOT_FOUND
The same holds for an id that does not exist (as_nope): the data door says 403, the action door 404. With an edit grant, both doors answer 404 on the unreadable row. On a row the caller can see but not edit, both answer 403.
Evidence: /mnt/project-files/pm-devx-seat3-30802/measurement/measure.out (project files; the probe script sits beside it).
Why it matters
content/docs/protocol/objectui/actions.mdx: a declarative update fires with the caller's permissions "exactly as for a user edit". The two doors disagree, so that sentence is false today.
content/docs/protocol/kernel/http-protocol.mdx: "Refusals come before the record lookup". The action door looks the record up first.
spec: action operation: 'update' → runtime:packages/runtime/src/action-execution.tsinvokeBusinessAction (loadActionSubjectRecord + refuseDeniedSubjectLoad before the write).
Dedupe words: declarative update action, run_action, refuseDeniedSubjectLoad, RECORD_NOT_FOUND vs PERMISSION_DENIED, action door permission order. Not searched beyond the dev's words: triage to confirm.
A declarative update action answers 404 RECORD_NOT_FOUND where the data update on the same row answers 403 PERMISSION_DENIED. Both refuse, so no write gets through; the two doors disagree on the code. Align the action door with the data door. p3, below the suggested p2. finding is removed.
Claim: PM loop patrol · 2026-11-06T07:27Z
Session: session_016WV4cytLmJxfAmgPVjUbbW
Account: os-elon-musk (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-30848-action-update-refusal-order (placeholder; the card thread's harness-pinned branch replaces it by a full re-post of this claim before any push)
Worktree: objectstack-issue-30848
Domain: domain:server
Seat: domain:server#1
File surface:
packages/runtime/src/action-execution.ts: the declarative update path of invokeBusinessAction, with loadActionSubjectRecord / refuseDeniedSubjectLoad and the refusal order before the subject load.
Its tests under packages/runtime/, and an MCP or REST level test that drives both doors on the same row.
content/docs/**, only if a sentence has to change.
One root .changeset/ file.
⛔ packages/spec/**, packages/plugins/plugin-security/** (stop and ask: the fix belongs in the action door, not the evaluator), ui/**, docs/adr/**, skills/**, .claude/**. Stop on breach and explain in the report
Container & model: M, mode:card-thread, model: default, effort: high
Clause-②: no, expected: a refusal's code is aligned with the data door, and no shape changes. The dev re-derives it from the diff
Metadata-protocol: none
Responsibility: the action door's refusal order for a declarative update (@objectstack/runtime) | none | MCP run_action and REST action callers, and scenario cards that state one expected refusal (#30802)
Thread-read: 6679109504
Premise: as measured by the filer (/mnt/project-files/pm-devx-seat3-30802/measurement/measure.out, on mainc17719dd). For a caller with read but no edit grant, writing a row they cannot see or an id that does not exist, update_record / PATCH answer 403 PERMISSION_DENIED, while run_action on a declarative update answers 404 RECORD_NOT_FOUND. With an edit grant both answer 404; on a visible row without edit both answer 403. The dev re-measures on current main first. ⛔ The fix must not create an existence oracle: the answer for an unreadable row and for a missing id stays the same within each door
Serial constraints cleared: no open PR touches packages/runtime/src/action-execution.ts; #30846 (devx#3, card #30802) consumes this behaviour but does not edit it
Claim: PM loop patrol · 2026-11-06T07:30Z (re-post of 6679639964 with the card thread's branch; it supersedes that comment)
Session: session_016WV4cytLmJxfAmgPVjUbbW (seat); dev session session_016WV4czxVZYV4CcMiCbofw3
Account: os-elon-musk (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/project-thread-y8qx4d (the card thread's harness-pinned branch, objectstack-ai/objectstack)
Worktree: objectstack-issue-30848
Domain: domain:server
Seat: domain:server#1
File surface:
packages/runtime/src/action-execution.ts: the declarative update path of invokeBusinessAction, with loadActionSubjectRecord / refuseDeniedSubjectLoad and the refusal order before the subject load.
Its tests under packages/runtime/, and an MCP or REST level test that drives both doors on the same row.
content/docs/**, only if a sentence has to change.
One root .changeset/ file.
⛔ packages/spec/**, packages/plugins/plugin-security/** (stop and ask: the fix belongs in the action door, not the evaluator), ui/**, docs/adr/**, skills/**, .claude/**. Stop on breach and explain in the report
Container & model: M, mode:card-thread, model: default, effort: high
Clause-②: no, expected: a refusal's code is aligned with the data door, and no shape changes. The dev re-derives it from the diff
Metadata-protocol: none
Responsibility: the action door's refusal order for a declarative update (@objectstack/runtime) | none | MCP run_action and REST action callers, and scenario cards that state one expected refusal (#30802)
Thread-read: 6679109504
Premise: as measured by the filer (/mnt/project-files/pm-devx-seat3-30802/measurement/measure.out, on mainc17719dd). For a caller with read but no edit grant, writing a row they cannot see or an id that does not exist, update_record / PATCH answer 403 PERMISSION_DENIED, while run_action on a declarative update answers 404 RECORD_NOT_FOUND. With an edit grant both answer 404; on a visible row without edit both answer 403. The dev re-measures on current main first. ⛔ The fix must not create an existence oracle: the answer for an unreadable row and for a missing id stays the same within each door
Serial constraints cleared: no open PR touches packages/runtime/src/action-execution.ts; #30846 (devx#3, card #30802) consumes this behaviour but does not edit it
{
"issue": 30848,
"status": "done",
"branch": "claude/project-thread-y8qx4d",
"pr": "https://github.com/objectstack-ai/objectstack/pull/30878",
"head": "2dc4bdfa7e",
"session": "session_016WV4czxVZYV4CcMiCbofw3",
"premise_still_valid": true,
"summary": "Re-measured on main a2f523aa through a booted-kernel pin: a reader (read, no edit) writing a row its private-OWD scope hides or a missing id got 404 RECORD_NOT_FOUND from both action doors (REST /actions, MCP run_action bridge) and 403 PERMISSION_DENIED from both data doors; every other cell already agreed. Fix in the shared declarative-update executor (executeDeclarativeUpdateAction): on a denied subject load, before refuseDeniedSubjectLoad, a new refuseUngrantedDeclarativeUpdate asks the security service explain({object, operation:'update'}) with no row named, and a denial (or a throwing explain) refuses 403 PERMISSION_DENIED with the middleware's own permission_denied sentence. A caller who may update keeps one 404 for hidden and missing; no plugin-security change. The line the card asked for: on main, invokeBusinessAction loads the subject at action-execution.ts:4406 and the declarative branch refuses on its verdict at :4169 before the write is asked.",
"tests": "New packages/runtime/src/declarative-update-refusal-order.pin.test.ts (booted ObjectKernel: ObjectQL, sqlite-wasm, Hono, AppPlugin, platform objects, auth, security, sharing, REST, dispatcher; MCP leg through the real /mcp route into the runtime bridge's update and runAction): 20/20 on 2dc4bdfa. Baseline with main's action-execution.ts: 2 failed / 14 passed (reader x hidden and reader x missing, action REST + action MCP answered 404 RECORD_NOT_FOUND). Ablation via scripts/ablation-replace.mjs (verdict consumed before the ask; anchor 1 to 0, blob 97cc3fe3 to 7e1ef3a8; suite imports runtime src, no dist): 4 failed / 16 passed, all reader no-grant rows; restored, blob equals HEAD 97cc3fe3, git diff HEAD empty. A first ablation attempt was a no-op (replacement contained the anchor; ablation-replace refused, anchor 1 to 1) and is not counted. Focused suites (pin + action-declarative-update + action-invocation-audit + action-declarative-update-stale-summary): 4 files, 90 passed on 2dc4bdfa. Whole runtime local project: 550 files, 8801 passed, 19 skipped on ad748b98 (docblock/changeset-only and one removed isSystem line behind the final head).",
"gates": "Local: dispatch-gates --commands derived 60 families; all 60 run on 2dc4bdfa with exit 0; --ran: 60 derived, 60 run, 0 NOT-MEASURED (record: /mnt/project-files/server-lane-seat1/30848/gates-ran-2dc4bdfa.txt). First pass on ad748b98 was red on check:system-context-census (a new isSystem read in an unregistered symbol), cleared by dropping that line so explain answers a system context as the middleware does. pnpm --filter @objectstack/runtime typecheck exit 0, pnpm lint exit 0 (2dc4bdfa). CI: in_progress at report time; check-runs not yet read.",
"dogfood_battery": "n/a: no ! in the PR title and Clause-② has no (narrowing) arm",
"line_budget": "+472 / -0 over 3 files, under the 3000 human-merge threshold",
"files_changed": [
".changeset/30848-declarative-update-refusal-order.md",
"packages/runtime/src/action-execution.ts",
"packages/runtime/src/declarative-update-refusal-order.pin.test.ts"
],
"clause_2": "no: no request that succeeded is refused and no response shape changes; for a caller who may not update the object, one refusal code on a served door moves from 404 RECORD_NOT_FOUND to the data door's 403 PERMISSION_DENIED (both refusals), so it is not a narrowing",
"metadata_protocol": "none",
"deviations": [
"Placement: the ask sits in executeDeclarativeUpdateAction before the subject-load VERDICT is consumed, not before the load itself in invokeBusinessAction. One call site serves both action doors (the REST door's own load is in domains/actions.ts, outside the claimed surface); contract points 7 and 4 keep their located 400s first; the load is a side-effect-free caller-scope read whose result no longer decides the no-grant answer.",
"Re-measure: through the booted pin against main's action-execution.ts instead of the filer's measure.ts probe, which imports PR #30846's scenario harness worktree.",
"MCP leg: the pin drives the runtime's MCP bridge members (update, runAction) through the real /mcp route via a stand-in mcp service; @objectstack/mcp's tool envelope rendering is not booted (the runtime package does not depend on it)."
],
"mcp_calls": "0",
"api_writes": "2: fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches, run 42189154343, PR #30878); this os-dev-report comment via post-stamped on #30848. git push is not a REST write.",
"open_questions": [],
"security_findings": "none",
"out_of_scope_findings": [
"noted, not filed (unmeasured, in PR Acceptance notes): a caller holding the update grant whom the data door refuses 403 before the lookup for a payload field (FLS write gate) or a read-only external datasource still gets 404 from the action door on a hidden row or missing id; same answer for both, no oracle. carrier: none",
"noted, not filed (unmeasured, in PR Acceptance notes): a caller with edit but a refused object read gets the read refusal at the data door and 404 at the action door. carrier: none",
"checked: other refuseDeniedSubjectLoad callers (flow, script/body, api forward needing the row, flow-start record keys) are read admissions with no as-the-caller data-door write to disagree with; no declarative delete exists (ActionSchema.operation is z.enum(['update'])). No follow-up."
]
}
Landed · 2026-11-06T10:41Z · PR #30878 (a declarative update action refuses a caller with no edit grant 403 PERMISSION_DENIED before its record lookup, as the data door does; the dogfood pin for the showcase member moved with it) merged by the queue as 60dac8bb13 (2026-11-06T10:41:03Z). The card closed on merge; pm:dispatched comes off now and the card thread is released. The consumer, #30802 / PR #30846, can now state the one expected refusal.
Activity
objectstack-fleet commentedon Nov 6, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: audit · p3 ·
domain:server· 2026-11-06T06:54Z⛔ Not a claim.
404 RECORD_NOT_FOUNDwhere the data update on the same row answers403 PERMISSION_DENIED. Both refuse, so no write gets through; the two doors disagree on the code. Align the action door with the data door. p3, below the suggested p2.findingis removed.Generated by Claude Code
objectstack-fleet commentedon Nov 6, 2026
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW
Claim: PM loop patrol · 2026-11-06T07:27Z
Session:
session_016WV4cytLmJxfAmgPVjUbbWAccount:
os-elon-musk(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-30848-action-update-refusal-order(placeholder; the card thread's harness-pinned branch replaces it by a full re-post of this claim before any push)Worktree:
objectstack-issue-30848Domain:
domain:serverSeat:
domain:server#1File surface:
packages/runtime/src/action-execution.ts: the declarativeupdatepath ofinvokeBusinessAction, withloadActionSubjectRecord/refuseDeniedSubjectLoadand the refusal order before the subject load.packages/runtime/, and an MCP or REST level test that drives both doors on the same row.content/docs/**, only if a sentence has to change..changeset/file.⛔
packages/spec/**,packages/plugins/plugin-security/**(stop and ask: the fix belongs in the action door, not the evaluator),ui/**,docs/adr/**,skills/**,.claude/**. Stop on breach and explain in the reportContainer & model:
M,mode:card-thread,model: default,effort: highClause-②: no, expected: a refusal's code is aligned with the data door, and no shape changes. The dev re-derives it from the diff
Metadata-protocol: none
Responsibility: the action door's refusal order for a declarative
update(@objectstack/runtime) | none | MCPrun_actionand REST action callers, and scenario cards that state one expected refusal (#30802)Thread-read: 6679109504
Premise: as measured by the filer (
/mnt/project-files/pm-devx-seat3-30802/measurement/measure.out, onmainc17719dd). For a caller with read but no edit grant, writing a row they cannot see or an id that does not exist,update_record/PATCHanswer 403PERMISSION_DENIED, whilerun_actionon a declarative update answers 404RECORD_NOT_FOUND. With an edit grant both answer 404; on a visible row without edit both answer 403. The dev re-measures on currentmainfirst. ⛔ The fix must not create an existence oracle: the answer for an unreadable row and for a missing id stays the same within each doorSerial constraints cleared: no open PR touches
packages/runtime/src/action-execution.ts; #30846 (devx#3, card #30802) consumes this behaviour but does not edit itobjectstack-fleet commentedon Nov 6, 2026
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW
Claim: PM loop patrol · 2026-11-06T07:30Z (re-post of 6679639964 with the card thread's branch; it supersedes that comment)
Session:
session_016WV4cytLmJxfAmgPVjUbbW(seat); dev sessionsession_016WV4czxVZYV4CcMiCbofw3Account:
os-elon-musk(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/project-thread-y8qx4d(the card thread's harness-pinned branch, objectstack-ai/objectstack)Worktree:
objectstack-issue-30848Domain:
domain:serverSeat:
domain:server#1File surface:
packages/runtime/src/action-execution.ts: the declarativeupdatepath ofinvokeBusinessAction, withloadActionSubjectRecord/refuseDeniedSubjectLoadand the refusal order before the subject load.packages/runtime/, and an MCP or REST level test that drives both doors on the same row.content/docs/**, only if a sentence has to change..changeset/file.⛔
packages/spec/**,packages/plugins/plugin-security/**(stop and ask: the fix belongs in the action door, not the evaluator),ui/**,docs/adr/**,skills/**,.claude/**. Stop on breach and explain in the reportContainer & model:
M,mode:card-thread,model: default,effort: highClause-②: no, expected: a refusal's code is aligned with the data door, and no shape changes. The dev re-derives it from the diff
Metadata-protocol: none
Responsibility: the action door's refusal order for a declarative
update(@objectstack/runtime) | none | MCPrun_actionand REST action callers, and scenario cards that state one expected refusal (#30802)Thread-read: 6679109504
Premise: as measured by the filer (
/mnt/project-files/pm-devx-seat3-30802/measurement/measure.out, onmainc17719dd). For a caller with read but no edit grant, writing a row they cannot see or an id that does not exist,update_record/PATCHanswer 403PERMISSION_DENIED, whilerun_actionon a declarative update answers 404RECORD_NOT_FOUND. With an edit grant both answer 404; on a visible row without edit both answer 403. The dev re-measures on currentmainfirst. ⛔ The fix must not create an existence oracle: the answer for an unreadable row and for a missing id stays the same within each doorSerial constraints cleared: no open PR touches
packages/runtime/src/action-execution.ts; #30846 (devx#3, card #30802) consumes this behaviour but does not edit itobjectstack-fleet commentedon Nov 6, 2026
domain:server#1 · os-elon-musk · session_016WV4czxVZYV4CcMiCbofw3
os-dev-report
{ "issue": 30848, "status": "done", "branch": "claude/project-thread-y8qx4d", "pr": "https://github.com/objectstack-ai/objectstack/pull/30878", "head": "2dc4bdfa7e", "session": "session_016WV4czxVZYV4CcMiCbofw3", "premise_still_valid": true, "summary": "Re-measured on main a2f523aa through a booted-kernel pin: a reader (read, no edit) writing a row its private-OWD scope hides or a missing id got 404 RECORD_NOT_FOUND from both action doors (REST /actions, MCP run_action bridge) and 403 PERMISSION_DENIED from both data doors; every other cell already agreed. Fix in the shared declarative-update executor (executeDeclarativeUpdateAction): on a denied subject load, before refuseDeniedSubjectLoad, a new refuseUngrantedDeclarativeUpdate asks the security service explain({object, operation:'update'}) with no row named, and a denial (or a throwing explain) refuses 403 PERMISSION_DENIED with the middleware's own permission_denied sentence. A caller who may update keeps one 404 for hidden and missing; no plugin-security change. The line the card asked for: on main, invokeBusinessAction loads the subject at action-execution.ts:4406 and the declarative branch refuses on its verdict at :4169 before the write is asked.", "tests": "New packages/runtime/src/declarative-update-refusal-order.pin.test.ts (booted ObjectKernel: ObjectQL, sqlite-wasm, Hono, AppPlugin, platform objects, auth, security, sharing, REST, dispatcher; MCP leg through the real /mcp route into the runtime bridge's update and runAction): 20/20 on 2dc4bdfa. Baseline with main's action-execution.ts: 2 failed / 14 passed (reader x hidden and reader x missing, action REST + action MCP answered 404 RECORD_NOT_FOUND). Ablation via scripts/ablation-replace.mjs (verdict consumed before the ask; anchor 1 to 0, blob 97cc3fe3 to 7e1ef3a8; suite imports runtime src, no dist): 4 failed / 16 passed, all reader no-grant rows; restored, blob equals HEAD 97cc3fe3, git diff HEAD empty. A first ablation attempt was a no-op (replacement contained the anchor; ablation-replace refused, anchor 1 to 1) and is not counted. Focused suites (pin + action-declarative-update + action-invocation-audit + action-declarative-update-stale-summary): 4 files, 90 passed on 2dc4bdfa. Whole runtime local project: 550 files, 8801 passed, 19 skipped on ad748b98 (docblock/changeset-only and one removed isSystem line behind the final head).", "gates": "Local: dispatch-gates --commands derived 60 families; all 60 run on 2dc4bdfa with exit 0; --ran: 60 derived, 60 run, 0 NOT-MEASURED (record: /mnt/project-files/server-lane-seat1/30848/gates-ran-2dc4bdfa.txt). First pass on ad748b98 was red on check:system-context-census (a new isSystem read in an unregistered symbol), cleared by dropping that line so explain answers a system context as the middleware does. pnpm --filter @objectstack/runtime typecheck exit 0, pnpm lint exit 0 (2dc4bdfa). CI: in_progress at report time; check-runs not yet read.", "dogfood_battery": "n/a: no ! in the PR title and Clause-② has no (narrowing) arm", "line_budget": "+472 / -0 over 3 files, under the 3000 human-merge threshold", "files_changed": [ ".changeset/30848-declarative-update-refusal-order.md", "packages/runtime/src/action-execution.ts", "packages/runtime/src/declarative-update-refusal-order.pin.test.ts" ], "clause_2": "no: no request that succeeded is refused and no response shape changes; for a caller who may not update the object, one refusal code on a served door moves from 404 RECORD_NOT_FOUND to the data door's 403 PERMISSION_DENIED (both refusals), so it is not a narrowing", "metadata_protocol": "none", "deviations": [ "Placement: the ask sits in executeDeclarativeUpdateAction before the subject-load VERDICT is consumed, not before the load itself in invokeBusinessAction. One call site serves both action doors (the REST door's own load is in domains/actions.ts, outside the claimed surface); contract points 7 and 4 keep their located 400s first; the load is a side-effect-free caller-scope read whose result no longer decides the no-grant answer.", "Re-measure: through the booted pin against main's action-execution.ts instead of the filer's measure.ts probe, which imports PR #30846's scenario harness worktree.", "MCP leg: the pin drives the runtime's MCP bridge members (update, runAction) through the real /mcp route via a stand-in mcp service; @objectstack/mcp's tool envelope rendering is not booted (the runtime package does not depend on it)." ], "mcp_calls": "0", "api_writes": "2: fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches, run 42189154343, PR #30878); this os-dev-report comment via post-stamped on #30848. git push is not a REST write.", "open_questions": [], "security_findings": "none", "out_of_scope_findings": [ "noted, not filed (unmeasured, in PR Acceptance notes): a caller holding the update grant whom the data door refuses 403 before the lookup for a payload field (FLS write gate) or a read-only external datasource still gets 404 from the action door on a hidden row or missing id; same answer for both, no oracle. carrier: none", "noted, not filed (unmeasured, in PR Acceptance notes): a caller with edit but a refused object read gets the read refusal at the data door and 404 at the action door. carrier: none", "checked: other refuseDeniedSubjectLoad callers (flow, script/body, api forward needing the row, flow-start record keys) are read admissions with no as-the-caller data-door write to disagree with; no declarative delete exists (ActionSchema.operation is z.enum(['update'])). No follow-up." ] }Generated by Claude Code
objectstack-fleet commentedon Nov 6, 2026
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW
Landed · 2026-11-06T10:41Z · PR #30878 (a declarative update action refuses a caller with no edit grant
403 PERMISSION_DENIEDbefore its record lookup, as the data door does; the dogfood pin for the showcase member moved with it) merged by the queue as60dac8bb13(2026-11-06T10:41:03Z). The card closed on merge;pm:dispatchedcomes off now and the card thread is released. The consumer, #30802 / PR #30846, can now state the one expected refusal.