Repository navigation
docs(skills): a hook that enforces a business rule declares refusals and refuses by code - #33488
objectstack-fleet[bot] wants to merge 4 commits into
Conversation
…red code Co-Authored-By: Claude <noreply@anthropic.com>
…ap the validation pointer Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:skills#1 · os-project-manager · session_01FEkQ5pjSPT7ui2EUcunaVb Contract reviewServed-tier: The diff holds no code, gate or schema file, so no implementing file is in scope. The changeset is the lane seat's ACCEPT at Inputs: card #33257 body and all 3 comments (triage, claim, os-dev-report); PR #33488 body, file list (3 files, +47 / -29) and net diff against Check-runs on the head at render time: 46 runs. 24 completed success, 18 completed skipped, 4 in progress ( ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Findings:
Implemented-by: VERDICT: PASS Adopted by the seat 2026-11-14T22:36Z · isolated sub-agent at CONTRACT_REVIEW_TIER · Ruling-ref: Leehom card cmsg_01TenzsRopZj4jMbvjX8uz1XFhuiZmTH8VFgMmHRmpiGAb, option A, tapped 2026-11-14T15:37Z |
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
|
domain:skills#1 · os-project-manager · session_01FEkQ5pjSPT7ui2EUcunaVb Contract reviewServed-tier: This is a delta review. The previous record, comment 6885289276, was a PASS on head Code read on Inputs: comment 6885289276; card #33257 body and all 4 comments (triage 6880063659, claim 6884900591, os-dev-report 6885205230, seat surface-widening record 6885297883); PR #33488 body and file list (3 files, +54 / -37); Check-runs on the head at render time: 42 runs. 22 completed success, 16 completed skipped, 4 in progress ( ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
New findings:
Delta
Implemented-by: VERDICT: PASS Adopted by the seat 2026-11-14T23:05Z · isolated sub-agent at CONTRACT_REVIEW_TIER · Ruling-ref: Leehom card cmsg_01TenzsRopZj4jMbvjX8uz1XFhuiZmTH8VFgMmHRmpiGAb, option A, tapped 2026-11-14T15:37Z |
|
domain:skills#1 · os-project-manager · session_01FEkQ5pjSPT7ui2EUcunaVb 维护者速读
席位核对Verdict: ACCEPT, held for the maintainer · head
|
Requested by Leehom · project thread
Fixes #33257
domain:skills#1 · os-project-manager · session_01FEkQ5njvc1ya6PypTHRBe5
Clause-②: no
Metadata-protocol: none
维护者速读
objectstack-data技能的 hook 参考文档「Declared refusals」一节,开头改为一条规则:执行业务规则的 hook 声明refusals目录、每条规则一个代码、body 抛出该代码;没有目录时随手抛的文字错误会被平台答成HOOK_ABORTED,只适合意料之外的失败。示例换成跨对象状态守卫「只有在库资产才能借出」(ASSET_NOT_IN_STOCK,409),并写明今天只有沙箱jsbody 能声明refusals,handler写法(含内联 TS)保存时被拒。rules/validation.md里指向该文档的税号 hook 示例同步改为声明TAX_ID_BLOCKED并抛代码。HOOK_ABORTED,用户与 agent 都拿不到有意义的代码和译文;技能原文的验证规则示例恰好教的就是这种写法。skills/objectstack-data/references/data-hooks.md的「Declared refusals」一节;再看skills/objectstack-data/rules/validation.md的税号 hook 示例。git diff origin/main -- skills/,预期新示例声明refusals: { ASSET_NOT_IN_STOCK: … status: 409 }并throw Object.assign(new Error(), { code: 'ASSET_NOT_IN_STOCK' }),税号示例不再有throw new Error('Tax ID is blocked')。skills/**):请批准或合并。What changes
skills/objectstack-data/references/data-hooks.md§ Declared refusals opens with the rule the card asks for: a hook that enforces a business rule declares arefusalscatalogue, one SCREAMING_SNAKE code per rule, and its body throws the code. Without a catalogue, a throw is answeredHOOK_ABORTED, the platform's code, which suits a failure nobody planned and not a rule the user can act on.CREDIT_LIMIT/ON_HOLD, as an unchecked fragment) is replaced by the card's case as a fullos:checkhook:checkout_requires_stock, abeforeInsertonasset_checkoutthat reads the asset throughctx.api(api.read) and throwsASSET_NOT_IN_STOCK(declared at 409) unless the asset's status isin_stock. It fails closed: a checkout with noasset_idor an unknown id is refused too (probed below); an asset the saver cannot read finds nothing, so it is refused the same way (read, not probed).refusalsonmaintoday: only a sandboxedjsbody; ahandlerhook, an inline TS function included, is refused (bullet replaces "Refused at save without ajsbody (e.g. a barehandler)"). Nothing hooks: the refusal catalogue (refusals) cannot translate a handler-authored hook's refusals, and holds one sentence per code — a TS hook has no way to refuse in the user's language #33214 would add is taught.code(default 400), with the hook's code asdeclaredCode(bullet replaces "the envelope carries one asdeclaredCode").onErrorgives no code; Best Practices' "give a thrown error a message the caller can act on" becomes "refuse a business rule by a declared code", linking the section.skills/objectstack-data/rules/validation.md§ External / Custom Validation → Lifecycle Hooks, the hook rule that points todata-hooks.md: the pointer sentence says the body "throws a declared code" (linked to the section) instead of "throws on failure", and itsos:checktax-ID hook declaresrefusals: { TAX_ID_BLOCKED: { message: 'This tax ID is blocked.' } }and throws that code instead ofthrow new Error('Tax ID is blocked'), which is exactly theHOOK_ABORTEDshape the card measured. This file was outside the order's expected surface; the seat approved it in its thread before the edit (pointer sentence plus the example's two lines)..changeset/33257-skills-hook-refuses-by-declared-code.md:'@objectstack/skills': patch, carrying both declaration lines.Why
The card's measured build (gallery debut refresh 2026-11-14, card
asset_lifecycle, recorded build2026-11-14-scenario-asset_lifecycle.json, cited and not re-run: a live run needs the maintainer's word, per the order) enforced "only an in-stock asset can be checked out" with a sandboxed hook that declared norefusals; the probe write was refusedHOOK_ABORTED(400), a platform code. The skill taught the catalogue only as a mechanism, not as the rule for business refusals, andrules/validation.mdtaught the free-text throw outright.Mechanism, read on
origin/mainb5f2f516:packages/spec/src/data/hook.zod.ts:HookSchema.refusalsis a record keyed by a SCREAMING_SNAKE code ofHookRefusalSchema(message: plain text, a{{ }}hole refused;status: 400 to 499, absent means 400).refuseUndeclaredLiteralRefusalCoderefusesrefusalson a hook whosebody.languageis notjs(message fromhookRefusalsWithoutSandboxedBody,packages/spec/src/data/hook-refusal-codes.ts:201), and refuses a literally thrown code the catalogue does not declare.packages/runtime/src/sandbox/hook-refusal-catalogue.tsresolveHookRefusal: a body throw carrying a declared code is answered with the entry's message (translated throughhooks.HOOK.refusals.CODE.messagein the caller's locale) asuserMessage, the entry's status (defaultDEFAULT_HOOK_REFUSAL_STATUS400) and the code; an undeclared code, or none, becomes a sandbox fault (500); host-relayed errors and crashes pass through.packages/types/src/thrown-http-error.tsresolveThrownHttpError: a code outside the ADR-0112 ledger resolves the wirecodefrom the status (standardErrorCodeForHttpStatus: 400VALIDATION_ERROR, 409RESOURCE_CONFLICT) and keeps the thrown code asdeclaredCode. A sandbox business throw with no code at a 4xx is answeredUNDECLARED_HOOK_REFUSAL_CODE=HOOK_ABORTED(ledger row inpackages/spec/src/api/error-code-ledger.zod.ts). SoHOOK_ABORTEDdoes not come fromonError: 'abort'as such:onError: 'abort'(the default) only makes the throw abort the write; the code comes from the throw carrying none.Acceptance notes
refusals) is not built here; the order rules it a proposal in the report. Proposed there with the four-axis reading.data-hooks.mdis left at 10011 with 2 tokens of headroom andvalidation.mdat 2761 with 1; no ceiling moved in either direction (scripts/is outside this card's surface).skills/was not swept beyond the two files the card names; the order's serial constraints keepobjectstack-data/SKILL.md(PR docs(skills): the object enable table names aiReadable #33458), the data evals file (PR docs(skills): parent-path record-currency amounts admit no grouping; data eval for the parent-path currencyField #33394) andrules/security.md(card skills: the security rules do not teach a position'srenamedFrom(a renamed position carries its holders) — PR #33008's Skills-docs follow-up #33044) untouched.Verification
Head
47af59e9, baseorigin/mainb5f2f516.Both new examples run in the real runtime (scratch probe, not committed)
A throwaway vitest file in
packages/runtime/src/sandbox/, on the harness ofoverlap-hook-template.integration.test.ts(realObjectQL, SQLite driver,QuickJSScriptRunner,bindHooksToEngine, answers read offmapDataError), each hook passed throughHookSchema.parsefirst. Run after building runtime's dependency closure under the verify lock (VERDICT command-exit 0); then deleted (git statusclean). Result: Test Files 1 passed, Tests 3 passed, exit 0.checkout_requires_stock: an in-stock asset checks out; anin_repairasset, a checkout with noasset_id, and an unknown id each answer409 {"error":"Only an in-stock asset can be checked out.","code":"RESOURCE_CONFLICT","declaredCode":"ASSET_NOT_IN_STOCK","userMessage":"Only an in-stock asset can be checked out."}; one checkout row stored.crm_tax_id_blocklist_check(new shape):400 {"error":"This tax ID is blocked.","code":"VALIDATION_ERROR","declaredCode":"TAX_ID_BLOCKED","userMessage":"This tax ID is blocked."}.400 {"error":"Tax ID is blocked","code":"HOOK_ABORTED"}, the card's symptom.HookSchema.safeParseof a hook withrefusalsand a stringhandler, and with an inline functionhandler: bothsuccess: false, one issue atrefusals("refusalsis answered only for a sandboxedbody(language: 'js') ...").Line and token budget (skills/**)
skills/objectstack-data/references/data-hooks.md: 884 to 893 lines; tokens 9962 to 10009 (ceiling 10011, headroom 2). Paid in the same file: the protocol-17 history sentence under "An unevaluable condition ABORTS" (its point is restated by the paragraph after it), that paragraph shortened, and the old fragment example.skills/objectstack-data/rules/validation.md: 331 to 330 lines; tokens 2754 to 2760 (ceiling 2761, headroom 1). Paid by deleting the "Date range validation" line from Common Script Patterns, which restates thedate_rangecross_fieldexample in the same file.skills/*/SKILL.md: 4603 to 4603 lines (no SKILL.md touched). Bundle total (whole shipped tree): 244659 to 244712 tokens.node scripts/check-skills-token-ratchet.mjs: exit 0.node scripts/pm/check-skill-line-ratchet.mjs: exit 0 (neither file is on its register). No ceiling raised.Derived gates: 45 derived, 44 run green, 1 NOT MEASURED
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 45 families for the three paths. All run on head47af59e9, exit captured before any pipe, after building@objectstack/lint...,@objectstack/client-react...and@objectstack/runtime^...under the verify lock (VERDICT command-exit 0, 320s):check-skills-token-ratchet(scan and--self-test),check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:doc-authoring,check:skill-links,check:skill-identifier-liveness,check:skill-frame-sync,check:corpus-claim-drift,check:nul-bytes,check:pm-skill-id-lint,@objectstack/lint check:doc-formula-expressions,@objectstack/spec check:skill-refs,check:dts-closure,check:lean-entry-closure,check:sourcemap-no-sources-content.check:dual-build-cjs-loads(reads every package's dist; no package source is touched, so no full build was paid locally; CI builds and runs it).--ranreconciliation: "45 derived famil(ies) accounted for — 44 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".pnpm --filter @objectstack/spec check:skill-examplesexit 0, "346 prose examples type-check across 3 surface(s)", which covers the new and the editedos:checkblocks.Tier and governed readings
dispatch-gates --tier: MANDATORY contract-review tier, bothskills/**paths under clause ① (the published catalog); no one-line exit for this surface.node scripts/pm/check-governed-merges.mjs --branch claude/project-thread-g605co: exit 3, GOVERNED, landing tier H (人合). 2 of 3 paths hit the register (skills/**x2); the changeset is not on it. Size +47 / -29, under the 3000 threshold.🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5njvc1ya6PypTHRBe5
Round 1 (head
652a4a09, after contract review 6885289276)data-hooks.mdCommon Patterns "1. Preventing Deletion (sandboxedbody)" now declaresrefusalsSTRATEGIC_ACCOUNT(400) andOPEN_OPPORTUNITIES(409) and throws each by code. The second message drops the count, because a catalogue message takes no parameters.body's throw is answeredHOOK_ABORTED";onError"gives no code of the hook's own".ctx.sessionrow.652a4a09: 45 derived gates, 44 exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads). Scratch runtime probe, 4 passed: the guard answers 400VALIDATION_ERRORwithdeclaredCode: STRATEGIC_ACCOUNT, then 409RESOURCE_CONFLICTwithdeclaredCode: OPEN_OPPORTUNITIES, and deletes an account whose only opportunity is closed.