Skip to content

No refusal test in this repo runs on the shipped path: every guard assertion uses the in-process handler, and all 37 hooks ship body-only through QuickJS #1167

Description

@os-zhuang

Observation-class finding, measured while answering question 1 of #1075. Filed unassigned for triage; nothing here is currently red.

What was measured

pnpm build on origin/main (6bf8504) reports Skipping legacy runtime bundle (all 37 callables are body-only), and 37 of 37 hooks in dist/objectstack.json carry a body.source. So every hook in this app ships as a lowered, metadata-only body evaluated inside QuickJS.

Every refusal assertion in test/ runs the other path. test/cascade-guard-messages.test.ts, test/hooks-runtime*.test.ts, test/freeze-guard-reference-cleanup.test.ts and the rest all reach the guard through objectstack.config.ts, where handler is a live function and body is undefined — so resolveHandler returns the closure and the sandbox is never entered.

The two paths produce measurably different error objects for the same guard. account_protection, unchanged, refusing the same delete on the same real kernel:

in-process (what the tests measure)
  ctor      : Error
  message   : "Cannot delete customer account: 1 open opportunity still references it. Close or reassign it first."
  own props : ["stack","message"]

shipped body through QuickJS (what users get)
  ctor         : SandboxError
  message      : "hook 'account_protection' threw: Error: Cannot delete customer account: 1 open opportunity still references it. Close or reassign it first."
  innerMessage : "Cannot delete customer account: 1 open opportunity still references it. Close or reassign it first."
  own props    : ["stack","message","name","innerMessage"]

Full measurement, both legs and the rig used: #1075 (comment).

Why it is worth recording

What is NOT established

Whether a REST consumer actually sees the wrapped sentence. @objectstack/rest reads err.innerMessage in at least one place, so it may well surface the unwrapped one. No HTTP server was booted for this — see the "does not establish" section of the #1075 comment. This finding is about test coverage, not about a known user-visible defect. If the REST hop turns out to unwrap, the coverage gap is still real and the wording pins still measure a path that does not ship.

Suggested shape (not decided)

Cheapest useful step is one refusal case per guard class re-taken through runHookBody from test/helpers/action-sandbox.ts, asserting the sentence the shipped path produces. That overlaps #1075's question 3 (sweep scope) and should probably be sequenced with it rather than ahead of it.

Activity

  1. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    ContributorAuthor

    Third sighting today of the same root fact — worth recording here, since this card is where it lives

    This card's measurement is that all 37 hooks ship body-only through QuickJS while every refusal test runs the in-process handler. That boundary has now changed the correct implementation of three unrelated cards in one session, which is a stronger signal than a coverage gap alone:

    1. No refusal in this app carries an error envelope: every business rejection reaches REST as a bare Error with code and status undefined #1075 (the measurement that spawned this card) — the sandbox marshals an allowlist of exactly code / status / fields and re-throws everything as SandboxError, so any error vocabulary riding on a fourth key or on instanceof would pass in test/hooks-runtime*.test.ts and be silently dead in production.
    2. The engine's reference-cleanup marker IS reachable from a hook on 17.0.0 GA — #720's "no marker reaches a hook" note is falsified, and three copies of a shape predicate rest on it #1165 — ctx.api inside a body-only hook is not the object the handler was written against.
    3. A lead already disqualified as a duplicate still blocks deleting the record it named — duplicate_disqualification_requires_survivor is the second rule on the same erasure path #1164 (PR fix(lead): tombstone a confirmed duplicate claim when its survivor is erased (#1164) #1175, merging now) — a module constant referenced from an L2 hook handler arrives undefined, because the body is lowered without its imports. src/objects/lead.hook.ts therefore has to spell 'erased' inline rather than importing DUPLICATE_OF_TYPE_ERASED from _picklists.ts, with the two spellings pinned to each other to stop them drifting. The repo already knew this — it is documented on the SLA matrix in case.hook.ts — but nothing pointed a hook author at it from lead.hook.ts, and the dispatch brief for that card did not know to mention it.

    The pattern: the in-process path is what this repo tests, reads and reasons about; the body-only path is what it ships. Each of the three cases was caught by someone measuring rather than by a gate, and in each the wrong answer would have looked correct in a diff and green in the suite.

    Not proposing anything here — that is still this card's open "suggested shape" and #1075's question 3. Recording it because three independent hits in one session is the kind of evidence that should be attached to the card before it is triaged, not rediscovered a fourth time.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendServer-side behaviour — hooks, flows, actionspm:dispatchedDispatched to a dev agent by /pm-dispatch

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions