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
runtime: an engine DuplicateRecordError a sandboxed script body lets propagate leaves the VM as SandboxError, so no door recognises it and it answers 409 DUPLICATE_RECORD, not the ruled UNIQUE_VIOLATION #23702
Filing gate ①, class (b). reach:measured at a public door by #23686's dev, using a real QuickJSScriptRunner (through actionBodyRunnerFactory) over a real engine. POST /api/v1/actions/udoc/sbx ran a type: 'script' action whose sandboxed body calls ctx.api.object('udoc').insert(…), repeats a unique value, and does not catch. It answered 409 {"code":"DUPLICATE_RECORD","message":"DuplicateRecordError: Duplicate record refused on 'udoc': …","httpStatus":409}, both before and after PR #23700 (os-dev-report6197200915 on #23686, out-of-scope finding 2).
Filed by domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG, from that report. ⛔ Not a claim. Grade and lane are triage's.
Governing text, and the reading it needs
The maintainer ruling recorded in commit 65846bc46, restated at packages/metadata-protocol/src/protocol.ts (near :3384-:3398): "one wire spelling on every route, UNIQUE_VIOLATION". The ruling's own docblock adds: "a producer that merely SPEAKS DUPLICATE_RECORD — a hook throwing the registered code without being the engine's class — keeps its own code."
The question this card carries: is an UNCAUGHT relay of the engine's own refusal out of a sandboxed body "the engine's class" under that ruling, or a producer that speaks the code?
A, the dev's recommendation: yes, the engine's. Fix it upstream, so every door translates it with no door-level change: either the sandbox relay keeps the producer's class identity, or the shared key in @objectstack/types learns a relayed engine envelope. A body that deliberately throws the code keeps it.
Measured pull: sandboxed type: 'script' bodies are how metadata apps, AI-authored ones included, write. The uncaught relay is that path's common case.
B: no. A sandboxed relay is a different producer: keep the key as it is, and state the exception in the error pages and in DuplicateRecordError.developerMessage.
The reading is triage's to route. If it needs the maintainer, it is a needs-user-decision question.
What was measured, and where
The refusal leaves the VM as SandboxError (packages/runtime/src/sandbox/quickjs-runner.ts:1700). Its code and status are lifted, and the class name is gone, so isEngineDuplicateRecordEnvelope (code AND class name) matches at no door.
The client message also carries the DuplicateRecordError: class-name prefix.
By reading, not measured there: RestServer's arm and the Hono doors key the same way.
MCP search_issues on objectstack-ai/objectstack, closed included: sandbox SandboxError relay loses DuplicateRecordError class script action body DUPLICATE_RECORD message prefix gave 6 hits. The nearest are #11588 and #11683 (closed; the sandbox debug wrapper text reaching the client on REST write routes). Neither is the class identity of a relayed engine refusal.
Triage: first grade · bug · priority:p2 · domain:cli · pm:queue · direction A · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-15T16:56Z
Read fresh. A sandboxed type: 'script' action body that lets the engine's DuplicateRecordError propagate (an uncaught ctx.api.object(…).insert on a unique value) leaves the VM as SandboxError. No door recognises it, and it answers 409 DUPLICATE_RECORD, before and after PR #23700.
Direction A (triage routes it): an uncaught relay of the engine's own refusal is still the engine's refusal.
The ruling (65846bc46, its docblock near protocol.ts:3384–:3398) translates the engine's class, and keeps the code only for a producer that merely SPEAKS DUPLICATE_RECORD (a hook throwing it deliberately).
An uncaught relay is not a producer speaking; it is the engine's refusal crossing a VM boundary.
So A applies the ruling as written. No contract moves.
Grade p2: sandboxed script bodies are how metadata apps, AI-authored ones included, write their actions. Their duplicate refusals miss the catalog code.
Lane domain:cli (packages/runtime, the sandbox relay; or @objectstack/types' shared key, both domain:cli).
Scope:
Fix it upstream, so every door translates with no door-level change: either the relay keeps the producer's class identity, or the shared key learns the relayed engine envelope.
Pins:
the uncaught relay answers UNIQUE_VIOLATION;
CONTROL: a body that deliberately throws the code keeps DUPLICATE_RECORD.
The fork: if the relay cannot tell an uncaught engine refusal from a deliberate throw of the same code, the dev stops and returns it to the box. ⛔ It does not pick in code.
Claim: PM loop round 22 (#23702: an engine DuplicateRecordError that a sandboxed script body lets propagate stays the engine's refusal across the VM boundary, so every door answers the ruled UNIQUE_VIOLATION; a body that deliberately throws DUPLICATE_RECORD keeps it) · 2026-10-15T18:07Z
Session: session_01Q9PZ5Pzqcus4woVvTZwWNU
Account: sunhaolin (the seat's linked user as GET /user answers it; the card's assignee from this act)
Branch: claude/issue-23702-sandbox-relay-duplicate
Worktree: objectstack-issue-23702
Domain: domain:cli
Seat: domain:cli#2 (seat post #22648)
Scope: batch 3, by 「并发恢复3」. #23050 slice 4 (PR #23705) and #23703 (PR #23707) are in the queue path; this p2 takes the free slot.
Order, read at this act:
The fork is NOT the dev's: if the relay cannot tell an uncaught engine refusal from a deliberate throw of the same code, the dev stops and returns needs_decision (triage: "⛔ It does not pick in code").
Container & model: M (a VM-boundary identity or a shared key, with door-level pins), mode:subagent, model: default tier.
Clause-②: no
Responsibility: the sandbox relay / the shared duplicate key (this lane, packages/runtime and @objectstack/types) | the engine's DuplicateRecordError and ruling 65846bc46, read | every door a sandboxed script body's refusal crosses, and the metadata apps (AI-authored ones included) that write those bodies
Thread-read: 6197583568
Serial constraints cleared:
No open PR touches packages/runtime/src/sandbox/, packages/types/src/data-error-classification.ts or packages/mcp/src/mcp-http-tools.ts (read at this act).
os-dev-report
{
"issue": 23702,
"status": "done",
"branch": "claude/issue-23702-sandbox-relay-duplicate",
"pr": "#23717",
"head": "edf7ad87787b2a7f92c6e46e6c27bc2b9bed085c",
"session": "session_01Q9PZ5Pzqcus4woVvTZwWNU (this run's harness-stamped id; subagent, so it is the parent's)",
"premise_still_valid": true,
"claim_check": "newest Claim: on #23702 is 6198936128 (2 comments read: triage 6197583568, claim 6198936128); it names branch claude/issue-23702-sandbox-relay-duplicate, which this run used. No claim posted, no card-assignee write.",
"shared_key_changed": false,
"summary": "Premise holds, measured at base 69351e9: a type 'script' action body that lets the engine's DuplicateRecordError leave answered POST /api/v1/actions/udoc/:action with 409 DUPLICATE_RECORD, message 'DuplicateRecordError: Duplicate record refused on ...'. The fix is ONE upstream place, the sandbox relay (packages/runtime/src/sandbox/quickjs-runner.ts), option (i): hostErrorToVm holds a dup of the VM handle each engine refusal (the shared predicate isEngineDuplicateRecordEnvelope, asked on the HOST-side error) becomes, plus a snapshot of everything that crosses back out; the L2 wrapper's completion value is now the body's own promise (SETTLE, same handlers, source still on line 2); and when __error is set the pump loop reads that promise's rejection reason (getPromiseState) and, if it is vm.eq to a held refusal with an unchanged snapshot, throws the host's DuplicateRecordError itself. Every door then translates it with no door change: 409 UNIQUE_VIOLATION with details developerMessage + object. The shared key isEngineDuplicateRecordEnvelope is NOT changed; packages/mcp, errorFromThrown and metadata-protocol are untouched. The fork did not arise: identity tells the engine's object from any value the body makes.",
"hypotheses": {
"a": "HOLDS. The engine raises DuplicateRecordError host-side; installApiMethod's catch receives the instance before hostErrorToVm marshals it, so the relay keys on the VM object the host handed in, read off the body's own promise (no body-writable global consulted). Treated as the engine's: uncaught; catch (e) { throw e } (the same object in the same state, physically indistinguishable from uncaught); caught, held across another host call, thrown later. Treated as a speaker (relayed as before): throw { code: 'DUPLICATE_RECORD' }; its own Error carrying the code, including one named DuplicateRecordError; an Error copying the engine's message/code/status; the engine's object with name/message/code/status/fields/userMessage rewritten before the throw (the re-thrown-unchanged line the nested-refusal relay SANDBOX_REFUSAL_OF_PROP already draws in this file). Pinned by identity (toBe) in engine-refusal-relay.test.ts.",
"b": "HOLDS. Action door message before: 'DuplicateRecordError: Duplicate record refused on 'udoc': a unique constraint already holds these values. No record was written.'; after: 'Duplicate record refused on 'udoc': ...' (the engine's own message), with details.developerMessage the same sentence. The door pin asserts the wire carries neither 'DUPLICATE_RECORD' nor 'DuplicateRecordError'.",
"c": "Hooks CROSS and are pinned: a sandboxed beforeInsert hook body that lets its own write's refusal leave, behind a host handler and behind a script body (two VM hops), both answer 409 UNIQUE_VIOLATION object udoc at the action door (before: 409 DUPLICATE_RECORD 'DuplicateRecordError: ...'; the two-hop row's before is the ablation leg). The /data classification also crosses: mapDataError on engine.insert('hdoc') refused by that hook answered 409 DUPLICATE_RECORD object hdoc before and 409 UNIQUE_VIOLATION (curated sentence, developerMessage, object udoc) after, measured at function level, not through a booted RestServer, not pinned as a door. Flow script nodes do NOT cross (by reading: the node calls a registered host function, config.function, and no service-automation source constructs a QuickJSScriptRunner). Job bodies run in the same runner and have no HTTP door; the relay is origin-agnostic (hook origin pinned at unit level)."
},
"before_after": [
{
"producer": "script body, uncaught ctx.api insert repeating a unique value",
"door": "POST /api/v1/actions/udoc/:action",
"before": "409 DUPLICATE_RECORD, message 'DuplicateRecordError: Duplicate record refused on udoc ...', no details",
"after": "409 UNIQUE_VIOLATION, message 'Duplicate record refused on udoc ...', details {developerMessage, object: udoc}"
},
{
"producer": "script body, catch (e) { throw e }",
"door": "same",
"before": "409 DUPLICATE_RECORD",
"after": "409 UNIQUE_VIOLATION"
},
{
"producer": "script body, caught, held across a find, thrown later",
"door": "same",
"before": "409 DUPLICATE_RECORD",
"after": "409 UNIQUE_VIOLATION"
},
{
"producer": "host handler -> sandboxed beforeInsert hook body lets its refusal leave",
"door": "same",
"before": "409 DUPLICATE_RECORD 'DuplicateRecordError: ...'",
"after": "409 UNIQUE_VIOLATION object udoc"
},
{
"producer": "script body -> sandboxed hook (two VM hops)",
"door": "same",
"before": "409 DUPLICATE_RECORD (ablation leg)",
"after": "409 UNIQUE_VIOLATION"
},
{
"producer": "engine.insert('hdoc') refused by that hook body",
"door": "mapDataError (the /data classification, function level)",
"before": "409 DUPLICATE_RECORD, object hdoc",
"after": "409 UNIQUE_VIOLATION, curated sentence, developerMessage, object udoc"
},
{
"producer": "CONTROL: throw Object.assign(new Error('That address is already on file'), {code: 'DUPLICATE_RECORD', status: 409})",
"door": "action door",
"before": "409 DUPLICATE_RECORD, own sentence",
"after": "unchanged"
},
{
"producer": "CONTROL: own Error named DuplicateRecordError with the code",
"door": "action door",
"before": "409 DUPLICATE_RECORD",
"after": "unchanged"
},
{
"producer": "CONTROL: engine's object, message rewritten, thrown",
"door": "action door",
"before": "409 DUPLICATE_RECORD 'DuplicateRecordError: That address is already on file'",
"after": "unchanged"
},
{
"producer": "CONTROL: engine's object, userMessage added, thrown",
"door": "action door",
"before": "409 DUPLICATE_RECORD",
"after": "unchanged"
},
{
"producer": "CONTROL: own Error copying the engine's message, code and status",
"door": "action door",
"before": "409 DUPLICATE_RECORD",
"after": "unchanged"
},
{
"producer": "engine's object, code rewritten to EMAIL_TAKEN",
"door": "action door",
"before": "409 RESOURCE_CONFLICT",
"after": "unchanged"
},
{
"producer": "CONTROL: hook body speaking the code",
"door": "mapDataError",
"before": "409 DUPLICATE_RECORD, own sentence, object hdoc2",
"after": "unchanged"
}
],
"tests": "New pins: packages/runtime/src/sandbox/engine-refusal-relay.test.ts (11: identity toBe for uncaught / re-thrown / held-then-thrown / hook origin; speaker controls: deliberate throw, forged name, copied fields, rewritten message, rewritten userMessage, body writing __error while its promise fulfils; an unrelated RECORD_NOT_FOUND 404 host refusal still a SandboxError) and packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts (6 door rows, #23686's rig: real ObjectQL + better-sqlite3, actionBodyRunnerFactory/hookBodyRunnerFactory over a real QuickJSScriptRunner, dispatch() over a real Request): first run Test Files 2 passed (2), Tests 17 passed (17). Ablation 1 (scripts/ablation-replace.mjs wrap mode, outer trap EXIT INT TERM restore on the absolute path): anchor 'if (engineRefusal !== undefined) throw engineRefusal;' -> no-op marker ABLATION_23702_NO_IDENTITY_RELAY, anchor 1 -> 0, marker 0 -> 1, blob b9f665347f4e -> 39c74645fd41 -> Tests 8 failed | 9 passed (17): exactly the 8 positive rows red (4 door rows expected 'DUPLICATE_RECORD' to be 'UNIQUE_VIOLATION', 4 unit rows received SandboxError), every control green; restore: blob == HEAD b9f665347f4e, git diff HEAD empty, anchor 1 / marker 0 on disk. Ablation 2 (same tool): the unchanged-snapshot half of the match removed -> Tests 2 failed | 9 passed (11), exactly the rewritten-message and rewritten-userMessage controls red; restore proven the same way. Pins import the runner from src by relative path (no build in the resolution path). At final head edf7ad8 (origin/main 8c85141 merged, rest + runtime rebuilt): pnpm --filter @objectstack/runtime test -> Test Files 392 passed (392), Tests 5895 passed | 19 skipped (5914) (RUNTIME_LOCAL_EXIT=0); test:repo -> Test Files 3 passed (3), Tests 808 passed (808) (RUNTIME_REPO_EXIT=0); the same counts pre-merge at 12ef9ba. pnpm --filter @objectstack/runtime typecheck -> exit 0, check:test-typecheck: OK 27 files / 190 errors / 68 pinned signatures held, no new debt, both new test files in the program (tsc --listFilesOnly -p tsconfig.test.json: 2 hits). By path at edf7ad8, the two new files + dispatcher-unique-violation-wire-code, error-passthrough, nested-refusal-sentence-relay, capability-denial-is-a-fault: Test Files 6 passed (6), Tests 57 passed (57).",
"gates": "Local, at edf7ad8: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 61 commands from 4 changed paths (merge base 8c85141); all 61 ran, exit codes captured before any pipe: 60 exit 0, check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (no workspace dist) on the first pass, then exit 0 after the turbo build (75 tasks, 74 cached; 110 published require entry point(s) across 68 package(s) load). --ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, exit 0 (a derived zero). Notable exit 0: check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:nul-bytes, check:doc-authoring, check:issue-citations, check:type-check-coverage, check:type-check-debt, check-changeset-no-major --base origin/main, check-adr-0087-registration --base origin/main, check-empty-changeset --base origin/main. Lint (narrowed, the scoped form eslint.config.mjs's header prescribes; three pieces of evidence): (1) node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config packages/runtime, the pnpm lint binary and flags -> exit 0; (2) --format json over the three changed .ts files -> 3 files linted, 0 errors, 0 warnings (the changeset .md is ignored by config, as under pnpm lint); (3) positive control: the same command reds on a restricted root @objectstack/spec import piped on stdin at a packages/runtime path -> exit 1, no-restricted-imports. Local-only families (check:dev-prereqs, check:skill-frame-freshness) not owed: no packages/spec or frame file touched. CI on PR #23717 at edf7ad8, one read at report time: 23 check-runs; No other open PR may claim the same issue success, Packed-tarball smoke (opt-in) skipped, the other 21 in_progress (Lint & Repo Gates lanes, Type Check lanes, Governed Surface Queue Guard, Check Changeset, Check PR Size and the rest); CI's verdicts are the PR's check-runs, not re-run locally.",
"files_changed": [
"packages/runtime/src/sandbox/quickjs-runner.ts (the one upstream change: hold engine refusals by VM identity, SETTLE wrapper, relayedEngineRefusal in the pump loop, release before vm.dispose)",
"packages/runtime/src/sandbox/engine-refusal-relay.test.ts (new, 11 unit pins beside the change)",
"packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts (new, 6 door-level rows on POST /api/v1/actions/udoc/:action)",
".changeset/23702-sandbox-relay-duplicate.md (new, @objectstack/runtime patch, fix(runtime): FROM 409 DUPLICATE_RECORD TO 409 UNIQUE_VIOLATION at the action door, the deliberate-throw control, the in-process note, Clause-②: no)"
],
"deviations": [
"Lint: the dispatch says this lane always adds pnpm lint or the proven narrowing; os-dev.md says repo-wide scans are CI's and never owed. Reported, not picked silently: the narrowing was taken, with its three pieces of evidence in gates.",
"Commit trailers are the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude); the harness reminder's model-named Co-Authored-By form was not used. The PR body ends with the os-dev session-URL footer form, not the harness reminder's alternative.",
"Scratch measurement rigs were placed temporarily, uncommitted, under packages/runtime/src (zz-scratch-23702-*.test.ts) to use the package's vitest aliases; deleted before the gate derivation, never pushed; copies in the session scratchpad.",
"check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) and exited 0 after the full build; recorded as such, not as a failed measurement.",
"The two full runtime suite runs chained their parts with ';', so the lock's VERDICT is batch-last-exit; each part's own exit was captured by an echo line (RUNTIME_LOCAL_EXIT=0, RUNTIME_REPO_EXIT=0).",
"The gate loop outlived the tool's 600 s foreground cap and was moved to the background by the harness; it was then waited on with a foreground tail --pid on its recorded PID, and every exit code is in the record.",
"A second ablation leg (the unchanged-snapshot gate) was run beyond the one the dispatch asked for.",
"origin/main moved after the pre-PR merge (cd86e41 touches packages/runtime/src/app-plugin.ts and objectql, 53e3638 console pin); not merged again, since there is no conflict and no overlap with this diff."
],
"mcp_calls": "0",
"api_writes": "2 relay strokes through scripts/pm/with-fleet.sh --via dispatch (each a POST /repos/objectstack-ai/objectstack/dispatches by the session, executed as objectstack-fleet[bot]): (1) pr_create, run 38792515734 -> POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23717/assignees ([sunhaolin]); body read back 10585 bytes sent = stored, assignees sunhaolin; (2) this os-dev-report comment -> POST /repos//issues/23702/comments. Plus 5 git pushes (empty-branch probe, runner fix, pins, changeset, merge of origin/main). No label write, no PR-body PATCH, no card write.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door POST /api/v1/actions/udoc/:action, a type 'script' action whose sandboxed body throws a plain object: throw { code: 'EMAIL_TAKEN', message: 'That address is already on file' } answered 400 VALIDATION_ERROR with message 'undefined: That address is already on file', and throw { code: 'DUPLICATE_RECORD' } answered 400 DUPLICATE_RECORD with message '[object Object]' (measured: real QuickJSScriptRunner via actionBodyRunnerFactory over a real engine, dispatcher at this PR's tree; pre-existing, unchanged by this PR) · evidence: the wrapper's reject handler in packages/runtime/src/sandbox/quickjs-runner.ts flattens a rejection as e.name + ': ' + e.message when e.message is set, else String(e), and that string becomes the innerMessage every door serves as the client sentence; a plain object has no name · Seam: spec:ScriptBody source (a thrown value) → runtime:packages/runtime/src/sandbox/quickjs-runner.ts REJECT_HANDLER | consumer: action door client message · dedupe words: sandbox reject handler plain object throw 'undefined:' '[object Object]' innerMessage action body flattened message"
],
"pr_body_changes": [],
"cleanup": "Worktree ../objectstack-issue-23702 removed after deleting its node_modules (tree clean, local head == origin/claude/issue-23702-sandbox-relay-duplicate == edf7ad8); no dev server or monitor started; every background run this session started has exited.",
"labels": "none written: the dispatch expected none and a changeset is present, so skip-changeset does not apply."
}
domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of claim 6198936128, released by this landing. Review: ACCEPT 6200635689 on the PR (no contract review owed).
Reading
Value
PR
merged: true, merged_at 2026-10-15T20:00Z, through the merge queue (added_to_merge_queue 2026-10-15T19:35Z)
Queue branch gh-readonly-queue/main/pr-23717-*
absent (git ls-remote, 0 refs)
origin/main
9e0945cf45 is an ancestor; one parent (c03077e294); 4 files, +579 / −4, as reviewed at edf7ad8778
Closing keywords in the landed commit
Fixes #23702 only
Lane reconciliation
13 open domain:cli cards (read at this stamp), #23702 no longer among them
State change: #23702 closed completed by the merge; pm:dispatched removed in this act. A sandboxed body that lets the engine's DuplicateRecordError leave unchanged (uncaught, re-thrown, or held and thrown later) is now answered 409 UNIQUE_VIOLATION at every door, by the runner's identity relay. A body that throws its own DUPLICATE_RECORD keeps it. The shared key isEngineDuplicateRecordEnvelope is unchanged, so seat 1's #23701 is unaffected.
Filed from this card's report:#23718 (a body throwing a plain object is answered undefined: … / [object Object]).
Activity
objectstack-fleet commentedon Oct 15, 2026
Triage: first grade ·
bug·priority:p2·domain:cli·pm:queue· direction A · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-15T16:56ZRead fresh. A sandboxed
type: 'script'action body that lets the engine'sDuplicateRecordErrorpropagate (an uncaughtctx.api.object(…).inserton a unique value) leaves the VM asSandboxError. No door recognises it, and it answers409 DUPLICATE_RECORD, before and after PR #23700.Direction A (triage routes it): an uncaught relay of the engine's own refusal is still the engine's refusal.
65846bc46, its docblock nearprotocol.ts:3384–:3398) translates the engine's class, and keeps the code only for a producer that merely SPEAKSDUPLICATE_RECORD(a hook throwing it deliberately).Grade p2: sandboxed script bodies are how metadata apps, AI-authored ones included, write their actions. Their duplicate refusals miss the catalog code.
Lane
domain:cli(packages/runtime, the sandbox relay; or@objectstack/types' shared key, bothdomain:cli).Scope:
UNIQUE_VIOLATION;throws the code keepsDUPLICATE_RECORD.Order: it lands after PR #23700 (#23686).
findingleaves on grading.objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 22 (#23702: an engine
DuplicateRecordErrorthat a sandboxed script body lets propagate stays the engine's refusal across the VM boundary, so every door answers the ruledUNIQUE_VIOLATION; a body that deliberately throwsDUPLICATE_RECORDkeeps it) · 2026-10-15T18:07ZSession:
session_01Q9PZ5Pzqcus4woVvTZwWNUAccount:
sunhaolin(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23702-sandbox-relay-duplicateWorktree:
objectstack-issue-23702Domain:
domain:cliSeat:
domain:cli#2(seat post #22648)Scope:
batch3, by 「并发恢复3」. #23050 slice 4 (PR #23705) and #23703 (PR #23707) are in the queue path; this p2 takes the free slot.Order, read at this act:
6197583568(direction A) orders it after PR fix(runtime): the dispatcher answers the engine's DuplicateRecordError as 409 UNIQUE_VIOLATION, with the arm's fields (#23686) #23700 (runtime: the dispatcher's own catch arms answer the engine's DuplicateRecordError as409 DUPLICATE_RECORD, not the ruled wire spellingUNIQUE_VIOLATION(measured atPOST /api/v1/actions/:object/:action) #23686), which merged.DUPLICATE_RECORDin its error result, not the ruled wire spellingUNIQUE_VIOLATION(create_record,run_action,resume_run) #23701, the MCP twin, is seat 1's (pm:dispatched).File surface (read on
origin/main69351e985; stop on a breach and explain it in the report):packages/runtime/src/sandbox/quickjs-runner.ts'sSandboxErrorrelay (about:1700), so it keeps the producer's identity;@objectstack/types'isEngineDuplicateRecordEnvelope(data-error-classification.ts:906), so it learns the relayed engine envelope.POST /api/v1/actions/:object/:action)..changeset/23702-*.md.packages/mcp(mcp: a tool call that repeats a unique value answers the engine's in-processDUPLICATE_RECORDin its error result, not the ruled wire spellingUNIQUE_VIOLATION(create_record,run_action,resume_run) #23701, seat 1's);errorFromThrown(PR fix(runtime): the dispatcher answers the engine's DuplicateRecordError as 409 UNIQUE_VIOLATION, with the arm's fields (#23686) #23700, landed);metadata-protocol.needs_decision(triage: "⛔ It does not pick in code").Container & model:
M(a VM-boundary identity or a shared key, with door-level pins),mode:subagent,model: default tier.Clause-②: no
Responsibility:
the sandbox relay / the shared duplicate key (this lane, packages/runtime and @objectstack/types) | the engine's DuplicateRecordError and ruling 65846bc46, read | every door a sandboxed script body's refusal crosses, and the metadata apps (AI-authored ones included) that write those bodiesThread-read: 6197583568
Serial constraints cleared:
packages/runtime/src/sandbox/,packages/types/src/data-error-classification.tsorpackages/mcp/src/mcp-http-tools.ts(read at this act).DUPLICATE_RECORDin its error result, not the ruled wire spellingUNIQUE_VIOLATION(create_record,run_action,resume_run) #23701 (packages/mcp, which reuses it) is told on [PM seat] domain:cli — 🟢 marchtian · session_01PFB9ZGRWYvjhTXsd8i6KBp · concurrency 2 (maintainer) #6024 before the PR leaves draft.Generated by Claude Code
fix(runtime): the sandbox relay hands back the engine's own Duplicate…
test(runtime): pin the sandbox relay's engine-refusal identity and th…
chore(changeset): runtime patch for the sandbox relay's engine-refusa…
objectstack-fleet commentedon Oct 15, 2026
os-dev-report
{
"issue": 23702,
"status": "done",
"branch": "claude/issue-23702-sandbox-relay-duplicate",
"pr": "#23717",
"head": "edf7ad87787b2a7f92c6e46e6c27bc2b9bed085c",
"session": "session_01Q9PZ5Pzqcus4woVvTZwWNU (this run's harness-stamped id; subagent, so it is the parent's)",
"premise_still_valid": true,
"claim_check": "newest Claim: on #23702 is 6198936128 (2 comments read: triage 6197583568, claim 6198936128); it names branch claude/issue-23702-sandbox-relay-duplicate, which this run used. No claim posted, no card-assignee write.",
"shared_key_changed": false,
"summary": "Premise holds, measured at base 69351e9: a type 'script' action body that lets the engine's DuplicateRecordError leave answered POST /api/v1/actions/udoc/:action with 409 DUPLICATE_RECORD, message 'DuplicateRecordError: Duplicate record refused on ...'. The fix is ONE upstream place, the sandbox relay (packages/runtime/src/sandbox/quickjs-runner.ts), option (i): hostErrorToVm holds a dup of the VM handle each engine refusal (the shared predicate isEngineDuplicateRecordEnvelope, asked on the HOST-side error) becomes, plus a snapshot of everything that crosses back out; the L2 wrapper's completion value is now the body's own promise (SETTLE, same handlers, source still on line 2); and when __error is set the pump loop reads that promise's rejection reason (getPromiseState) and, if it is vm.eq to a held refusal with an unchanged snapshot, throws the host's DuplicateRecordError itself. Every door then translates it with no door change: 409 UNIQUE_VIOLATION with details developerMessage + object. The shared key isEngineDuplicateRecordEnvelope is NOT changed; packages/mcp, errorFromThrown and metadata-protocol are untouched. The fork did not arise: identity tells the engine's object from any value the body makes.",
"hypotheses": {
"a": "HOLDS. The engine raises DuplicateRecordError host-side; installApiMethod's catch receives the instance before hostErrorToVm marshals it, so the relay keys on the VM object the host handed in, read off the body's own promise (no body-writable global consulted). Treated as the engine's: uncaught; catch (e) { throw e } (the same object in the same state, physically indistinguishable from uncaught); caught, held across another host call, thrown later. Treated as a speaker (relayed as before): throw { code: 'DUPLICATE_RECORD' }; its own Error carrying the code, including one named DuplicateRecordError; an Error copying the engine's message/code/status; the engine's object with name/message/code/status/fields/userMessage rewritten before the throw (the re-thrown-unchanged line the nested-refusal relay SANDBOX_REFUSAL_OF_PROP already draws in this file). Pinned by identity (toBe) in engine-refusal-relay.test.ts.",
"b": "HOLDS. Action door message before: 'DuplicateRecordError: Duplicate record refused on 'udoc': a unique constraint already holds these values. No record was written.'; after: 'Duplicate record refused on 'udoc': ...' (the engine's own message), with details.developerMessage the same sentence. The door pin asserts the wire carries neither 'DUPLICATE_RECORD' nor 'DuplicateRecordError'.",
"c": "Hooks CROSS and are pinned: a sandboxed beforeInsert hook body that lets its own write's refusal leave, behind a host handler and behind a script body (two VM hops), both answer 409 UNIQUE_VIOLATION object udoc at the action door (before: 409 DUPLICATE_RECORD 'DuplicateRecordError: ...'; the two-hop row's before is the ablation leg). The /data classification also crosses: mapDataError on engine.insert('hdoc') refused by that hook answered 409 DUPLICATE_RECORD object hdoc before and 409 UNIQUE_VIOLATION (curated sentence, developerMessage, object udoc) after, measured at function level, not through a booted RestServer, not pinned as a door. Flow script nodes do NOT cross (by reading: the node calls a registered host function, config.function, and no service-automation source constructs a QuickJSScriptRunner). Job bodies run in the same runner and have no HTTP door; the relay is origin-agnostic (hook origin pinned at unit level)."
},
"before_after": [
{
"producer": "script body, uncaught ctx.api insert repeating a unique value",
"door": "POST /api/v1/actions/udoc/:action",
"before": "409 DUPLICATE_RECORD, message 'DuplicateRecordError: Duplicate record refused on udoc ...', no details",
"after": "409 UNIQUE_VIOLATION, message 'Duplicate record refused on udoc ...', details {developerMessage, object: udoc}"
},
{
"producer": "script body, catch (e) { throw e }",
"door": "same",
"before": "409 DUPLICATE_RECORD",
"after": "409 UNIQUE_VIOLATION"
},
{
"producer": "script body, caught, held across a find, thrown later",
"door": "same",
"before": "409 DUPLICATE_RECORD",
"after": "409 UNIQUE_VIOLATION"
},
{
"producer": "host handler -> sandboxed beforeInsert hook body lets its refusal leave",
"door": "same",
"before": "409 DUPLICATE_RECORD 'DuplicateRecordError: ...'",
"after": "409 UNIQUE_VIOLATION object udoc"
},
{
"producer": "script body -> sandboxed hook (two VM hops)",
"door": "same",
"before": "409 DUPLICATE_RECORD (ablation leg)",
"after": "409 UNIQUE_VIOLATION"
},
{
"producer": "engine.insert('hdoc') refused by that hook body",
"door": "mapDataError (the /data classification, function level)",
"before": "409 DUPLICATE_RECORD, object hdoc",
"after": "409 UNIQUE_VIOLATION, curated sentence, developerMessage, object udoc"
},
{
"producer": "CONTROL: throw Object.assign(new Error('That address is already on file'), {code: 'DUPLICATE_RECORD', status: 409})",
"door": "action door",
"before": "409 DUPLICATE_RECORD, own sentence",
"after": "unchanged"
},
{
"producer": "CONTROL: own Error named DuplicateRecordError with the code",
"door": "action door",
"before": "409 DUPLICATE_RECORD",
"after": "unchanged"
},
{
"producer": "CONTROL: engine's object, message rewritten, thrown",
"door": "action door",
"before": "409 DUPLICATE_RECORD 'DuplicateRecordError: That address is already on file'",
"after": "unchanged"
},
{
"producer": "CONTROL: engine's object, userMessage added, thrown",
"door": "action door",
"before": "409 DUPLICATE_RECORD",
"after": "unchanged"
},
{
"producer": "CONTROL: own Error copying the engine's message, code and status",
"door": "action door",
"before": "409 DUPLICATE_RECORD",
"after": "unchanged"
},
{
"producer": "engine's object, code rewritten to EMAIL_TAKEN",
"door": "action door",
"before": "409 RESOURCE_CONFLICT",
"after": "unchanged"
},
{
"producer": "CONTROL: hook body speaking the code",
"door": "mapDataError",
"before": "409 DUPLICATE_RECORD, own sentence, object hdoc2",
"after": "unchanged"
}
],
"tests": "New pins: packages/runtime/src/sandbox/engine-refusal-relay.test.ts (11: identity toBe for uncaught / re-thrown / held-then-thrown / hook origin; speaker controls: deliberate throw, forged name, copied fields, rewritten message, rewritten userMessage, body writing __error while its promise fulfils; an unrelated RECORD_NOT_FOUND 404 host refusal still a SandboxError) and packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts (6 door rows, #23686's rig: real ObjectQL + better-sqlite3, actionBodyRunnerFactory/hookBodyRunnerFactory over a real QuickJSScriptRunner, dispatch() over a real Request): first run
Test Files 2 passed (2),Tests 17 passed (17). Ablation 1 (scripts/ablation-replace.mjs wrap mode, outer trap EXIT INT TERM restore on the absolute path): anchor 'if (engineRefusal !== undefined) throw engineRefusal;' -> no-op marker ABLATION_23702_NO_IDENTITY_RELAY, anchor 1 -> 0, marker 0 -> 1, blob b9f665347f4e -> 39c74645fd41 ->Tests 8 failed | 9 passed (17): exactly the 8 positive rows red (4 door rowsexpected 'DUPLICATE_RECORD' to be 'UNIQUE_VIOLATION', 4 unit rows received SandboxError), every control green; restore: blob == HEAD b9f665347f4e, git diff HEAD empty, anchor 1 / marker 0 on disk. Ablation 2 (same tool): the unchanged-snapshot half of the match removed ->Tests 2 failed | 9 passed (11), exactly the rewritten-message and rewritten-userMessage controls red; restore proven the same way. Pins import the runner from src by relative path (no build in the resolution path). At final head edf7ad8 (origin/main 8c85141 merged, rest + runtime rebuilt): pnpm --filter @objectstack/runtime test ->Test Files 392 passed (392),Tests 5895 passed | 19 skipped (5914)(RUNTIME_LOCAL_EXIT=0); test:repo ->Test Files 3 passed (3),Tests 808 passed (808)(RUNTIME_REPO_EXIT=0); the same counts pre-merge at 12ef9ba. pnpm --filter @objectstack/runtime typecheck -> exit 0,check:test-typecheck: OK27 files / 190 errors / 68 pinned signatures held, no new debt, both new test files in the program (tsc --listFilesOnly -p tsconfig.test.json: 2 hits). By path at edf7ad8, the two new files + dispatcher-unique-violation-wire-code, error-passthrough, nested-refusal-sentence-relay, capability-denial-is-a-fault:Test Files 6 passed (6),Tests 57 passed (57).","gates": "Local, at edf7ad8: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 61 commands from 4 changed paths (merge base 8c85141); all 61 ran, exit codes captured before any pipe: 60 exit 0, check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (no workspace dist) on the first pass, then exit 0 after the turbo build (75 tasks, 74 cached;
110 published require entry point(s) across 68 package(s) load). --ran:61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, exit 0 (a derived zero). Notable exit 0: check:test-source-alias, check:cross-package-test-inputs, check:engine-double-contract, check:nul-bytes, check:doc-authoring, check:issue-citations, check:type-check-coverage, check:type-check-debt, check-changeset-no-major --base origin/main, check-adr-0087-registration --base origin/main, check-empty-changeset --base origin/main. Lint (narrowed, the scoped form eslint.config.mjs's header prescribes; three pieces of evidence): (1) node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config packages/runtime, the pnpm lint binary and flags -> exit 0; (2) --format json over the three changed .ts files -> 3 files linted, 0 errors, 0 warnings (the changeset .md is ignored by config, as under pnpm lint); (3) positive control: the same command reds on a restricted root @objectstack/spec import piped on stdin at a packages/runtime path -> exit 1, no-restricted-imports. Local-only families (check:dev-prereqs, check:skill-frame-freshness) not owed: no packages/spec or frame file touched. CI on PR #23717 at edf7ad8, one read at report time: 23 check-runs;No other open PR may claim the same issuesuccess,Packed-tarball smoke (opt-in)skipped, the other 21 in_progress (Lint & Repo Gates lanes, Type Check lanes, Governed Surface Queue Guard, Check Changeset, Check PR Size and the rest); CI's verdicts are the PR's check-runs, not re-run locally.","files_changed": [
"packages/runtime/src/sandbox/quickjs-runner.ts (the one upstream change: hold engine refusals by VM identity, SETTLE wrapper, relayedEngineRefusal in the pump loop, release before vm.dispose)",
"packages/runtime/src/sandbox/engine-refusal-relay.test.ts (new, 11 unit pins beside the change)",
"packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts (new, 6 door-level rows on POST /api/v1/actions/udoc/:action)",
".changeset/23702-sandbox-relay-duplicate.md (new, @objectstack/runtime patch, fix(runtime): FROM 409 DUPLICATE_RECORD TO 409 UNIQUE_VIOLATION at the action door, the deliberate-throw control, the in-process note, Clause-②: no)"
],
"deviations": [
"Lint: the dispatch says this lane always adds pnpm lint or the proven narrowing; os-dev.md says repo-wide scans are CI's and never owed. Reported, not picked silently: the narrowing was taken, with its three pieces of evidence in gates.",
"Commit trailers are the model-free pair AGENTS.md requires (Claude-Session + Co-authored-by: Claude); the harness reminder's model-named Co-Authored-By form was not used. The PR body ends with the os-dev session-URL footer form, not the harness reminder's alternative.",
"Scratch measurement rigs were placed temporarily, uncommitted, under packages/runtime/src (zz-scratch-23702-*.test.ts) to use the package's vitest aliases; deleted before the gate derivation, never pushed; copies in the session scratchpad.",
"check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) and exited 0 after the full build; recorded as such, not as a failed measurement.",
"The two full runtime suite runs chained their parts with ';', so the lock's VERDICT is batch-last-exit; each part's own exit was captured by an echo line (RUNTIME_LOCAL_EXIT=0, RUNTIME_REPO_EXIT=0).",
"The gate loop outlived the tool's 600 s foreground cap and was moved to the background by the harness; it was then waited on with a foreground tail --pid on its recorded PID, and every exit code is in the record.",
"A second ablation leg (the unchanged-snapshot gate) was run beyond the one the dispatch asked for.",
"origin/main moved after the pre-PR merge (cd86e41 touches packages/runtime/src/app-plugin.ts and objectql, 53e3638 console pin); not merged again, since there is no conflict and no overlap with this diff."
],
"mcp_calls": "0",
"api_writes": "2 relay strokes through scripts/pm/with-fleet.sh --via dispatch (each a POST /repos/objectstack-ai/objectstack/dispatches by the session, executed as objectstack-fleet[bot]): (1) pr_create, run 38792515734 -> POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23717/assignees ([sunhaolin]); body read back 10585 bytes sent = stored, assignees sunhaolin; (2) this os-dev-report comment -> POST /repos//issues/23702/comments. Plus 5 git pushes (empty-branch probe, runner fix, pins, changeset, merge of origin/main). No label write, no PR-body PATCH, no card write.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door POST /api/v1/actions/udoc/:action, a type 'script' action whose sandboxed body throws a plain object:
throw { code: 'EMAIL_TAKEN', message: 'That address is already on file' }answered 400 VALIDATION_ERROR with message 'undefined: That address is already on file', andthrow { code: 'DUPLICATE_RECORD' }answered 400 DUPLICATE_RECORD with message '[object Object]' (measured: real QuickJSScriptRunner via actionBodyRunnerFactory over a real engine, dispatcher at this PR's tree; pre-existing, unchanged by this PR) · evidence: the wrapper's reject handler in packages/runtime/src/sandbox/quickjs-runner.ts flattens a rejection as e.name + ': ' + e.message when e.message is set, else String(e), and that string becomes the innerMessage every door serves as the client sentence; a plain object has no name · Seam: spec:ScriptBody source (a thrown value) → runtime:packages/runtime/src/sandbox/quickjs-runner.ts REJECT_HANDLER | consumer: action door client message · dedupe words: sandbox reject handler plain object throw 'undefined:' '[object Object]' innerMessage action body flattened message"],
"pr_body_changes": [],
"cleanup": "Worktree ../objectstack-issue-23702 removed after deleting its node_modules (tree clean, local head == origin/claude/issue-23702-sandbox-relay-duplicate == edf7ad8); no dev server or monitor started; every background run this session started has exited.",
"labels": "none written: the dispatch expected none and a changeset is present, so skip-changeset does not apply."
}
Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
ACCEPT → PR #23717 comment
6200635689(no contract review owed) ·domain:cliseat 2 · 2026-10-15T19:34ZGenerated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
Landed: PR #23717 →
9e0945cf45· 2026-10-15T20:01Zdomain:cliseat 2 (#22648) ·sunhaolin· sessionsession_01Q9PZ5Pzqcus4woVvTZwWNU· holder of claim6198936128, released by this landing. Review: ACCEPT6200635689on the PR (no contract review owed).merged: true,merged_at2026-10-15T20:00Z, through the merge queue (added_to_merge_queue2026-10-15T19:35Z)gh-readonly-queue/main/pr-23717-*git ls-remote, 0 refs)origin/main9e0945cf45is an ancestor; one parent (c03077e294); 4 files, +579 / −4, as reviewed atedf7ad8778Fixes #23702onlydomain:clicards (read at this stamp), #23702 no longer among themState change: #23702 closed
completedby the merge;pm:dispatchedremoved in this act. A sandboxed body that lets the engine'sDuplicateRecordErrorleave unchanged (uncaught, re-thrown, or held and thrown later) is now answered409 UNIQUE_VIOLATIONat every door, by the runner's identity relay. A body that throws its ownDUPLICATE_RECORDkeeps it. The shared keyisEngineDuplicateRecordEnvelopeis unchanged, so seat 1's #23701 is unaffected.Filed from this card's report: #23718 (a body throwing a plain object is answered
undefined: …/[object Object]).Generated by Claude Code