Skip to content

skills(objectstack-data): an eval asks for a hook's refusals catalogue instead of a thrown English sentence - #27088

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-26834-hook-refusals-eval
Oct 28, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-26834-hook-refusals-eval

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #26834

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:objectstack-data 技能的 eval 文件 evals/hooks-security-seeds-datasources.json 新增第 7 题:多语言应用里一个 hook 拒绝保存并给用户看一句话,期望答案在 hook 上声明 refusals: { CREDIT_LIMIT: { message } }、沙箱 js body 只抛 { code: 'CREDIT_LIMIT' }、中文句子放进翻译包的 hooks 组;禁止 body 里抛英文句子(new Error(')和用 handler:(没有 js body 的目录会在保存时被拒)。同文件先删掉 5 句只是复述自身 must_not_contain 断言的话(453 字节 = 114 token),再按实测把该文件的 token 上限 1404 → 1573(净 +169,席位预算 ≤ +180);@objectstack/skills 补一条 patch changeset。
  • 为什么改:PR feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831 给 hook 加了 refusals 目录并在 references/data-hooks.md 教了它,但没有任何 eval 检查 AI 作者真会用它;照旧习惯直接 throw new Error('...') 的英文句子会原样到达 zh-CN 用户,而且没有东西在测,技能会悄悄漂移。
  • 风险与回滚:只改一个已发布 eval 夹具、棘轮脚本的一行上限(及其注释)和一条 changeset,不动 schema、键或运行时;回滚即 revert 本 PR(上限随之回落)。删掉的 5 句话每一句都仍由同一题的 must_not_contain 机械断言覆盖,事实与断言一个不少。
  • 先看:① skills/objectstack-data/evals/hooks-security-seeds-datasources.json 第 7 题(题面、期望、六条 must_contain、两条 must_not_contain)与第 1–5 题的删句;② scripts/check-skills-token-ratchet.mjs 该行注释里的算术;③ .changeset/26834-hook-refusals-eval.md。
  • 两分钟验证:node scripts/check-skills-token-ratchet.mjs,预期看到 hooks-security-seeds-datasources.json is 1573 tokens (ceiling 1573; headroom 0) 与末行 82 authored bundle file(s) within their ceilings;node -e "JSON.parse(require('fs').readFileSync('skills/objectstack-data/evals/hooks-security-seeds-datasources.json','utf8'))" 无输出即解析通过。
  • 需要你做的:批准本 PR(skills/** 受管面,Tier H;席位不 ready / 不 arm / 不入队)。

What changes

skills/objectstack-data/evals/hooks-security-seeds-datasources.json — case 7 joins the six. Prompt: an app serving English and Chinese users; an order whose amount exceeds its account's credit_limit must not be saved; API clients branch on the code CREDIT_LIMIT; a zh-CN rep reads 超出账户信用额度. Expected: a beforeInsert / beforeUpdate hook on order declaring refusals: { CREDIT_LIMIT: { message: 'Over the account credit limit.' } }, a sandboxed body: { language: 'js', source } that reads the account through ctx.api and refuses by code only — throw Object.assign(new Error(), { code: 'CREDIT_LIMIT' }), the body's own text never shown — and the zh-CN sentence under the bundle's nested hooks: { HOOK: { refusals: { CREDIT_LIMIT: { message } } } }, served whole in the caller's locale; a catalogue on a hook without a js body is refused at save.

  • must_contain: refusals:, CREDIT_LIMIT: {, code: 'CREDIT_LIMIT', language: 'js', hooks: {, 超出账户信用额度.
  • must_not_contain: new Error(' (a sentence on the throw — the old habit, throw new Error('Email is required') as references/data-hooks.md still spells its pre-catalogue examples — and also the half-habit Object.assign(new Error('text'), { code }), whose text is dead), handler: (an in-process handler cannot carry a catalogue; refusals without a js body is refused at save).

Every spelling asserted is the spec's, read on origin/main f708cf22: HookSchema.refusals is z.record(SCREAMING_SNAKE code, HookRefusalSchema { message, status? }) (packages/spec/src/data/hook.zod.ts), answered only on a language: 'js' body; the body's literal code: 'X' / e.code = 'X' throws are read at save by hookRefusalCodesThrownIn (hook-refusal-codes.ts); the translation key is hookRefusalMessageKey(hook, code) = hooks.HOOK.refusals.CODE.message (system/i18n-resolver.ts), and the bundle declares it as a nested hooks record (system/translation.zod.ts) — which is why the eval pins the nested hooks: { and not a dotted path a correct bundle would never contain (the stack's own hooks: is an array, so hooks: { cannot match it). The teaching in references/data-hooks.md agrees with the spec on every spelling; no disagreement to report.

Paid down first (the standing ruling, 「A:先削重复,余额按实测提高」). Five sentences in cases 1–5 that restated their own must_not_contain assertions are gone — the shape the landed objectstack-formula precedent cut: case 1's "Never a dotted path (… 400 INVALID_FIELD) and never a formula mirror (…)", case 2's "(an object-level permissions key is rejected)", case 3's "— there is no field.columnName on an external object", case 4's three "never" clauses (its one non-asserted clause, "Parents before children in the exported array", stays as its own sentence), case 5's "No validation rule and no readonlyWhen, …". Each cut spelling remains a mechanical must_not_contain on the same case; no assertion changed. 453 bytes = 114 units cut; case 7 adds 1129 bytes = 283 units.

scripts/check-skills-token-ratchet.mjs — the one row for this file moves 1404 → 1573, pinned AT the measurement (zero headroom), with the arithmetic and the ruling citation beside it as its siblings carry: triage 6450886908 (room inside the package first, no raise) read together with the maintainer's standing ruling 2026-10-27 on the decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」, tap A, verbatim and untranslated: 「A:先削重复,余额按实测提高」. Strictest first: the room was made inside the file; the raise is only the balance trimming could not cover. No other row moved (the open PR #26739 edits other rows; origin/main was merged before this PR opened and the row region is disjoint). evals/README.md untouched (142 / 143, headroom 1 — a mention of refusals does not fit, see Acceptance notes).

.changeset/26834-hook-refusals-eval.md — '@objectstack/skills': patch, both declaration lines. Why a changeset and not skip-changeset: the two latest skills/**-only PRs on this eval path (bb60b86 #26416, 7c70f91 #26393) carried skip-changeset, but the three token-ceiling precedents this card names (b1b153a #26774, 40264be #26810, d7dd47a #26852) each added a @objectstack/skills patch changeset, and the changeset gate's own text in .github/workflows/pr-automation.yml says skills/** "ships in '@objectstack/skills', whose build copies it into the 'dist' that package's 'files' field publishes … never the label". Measured here: packages/skills/scripts/sync-catalog.mjs copied 93 files into dist/skills/** byte-identical; dist/skills/objectstack-data/evals/hooks-security-seeds-datasources.json carries the new CREDIT_LIMIT (3 hits; positive control membershipKeys 2 hits; absent-symbol control 0). The eval publishes, so the published-face rule owes the changeset.

Two readings for a skills/** diff

reading before (origin/main f708cf2) after (this head)
evals/hooks-security-seeds-datasources.json — bytes 5616 6292
same file — tokens (gate convention, ceil(bytes / 4)) 1404 / ceiling 1404 1573 / ceiling 1573
same file — lines 65 75
skills/objectstack-data/** — sum of the 17 priced files the gate lists 43289 43458 (+169)
catalog-wide — sum of every priced SKILL.md 56410 56410 (no SKILL.md touched)

ceil($(wc -c < skills/objectstack-data/evals/hooks-security-seeds-datasources.json) / 4) == ceil(6292 / 4) == 1573.

Tests

Head measured: 1a0fb94a.

Token ratchet — before, mid (the unpaid reading that proves the gate read the file), after
before (origin/main f708cf22):
✓ check-skills-token-ratchet: skills/objectstack-data/evals/hooks-security-seeds-datasources.json is 1404 tokens (ceiling 1404; headroom 0).
✓ check-skills-token-ratchet: 82 authored bundle file(s) within their ceilings; 10 generator-owned file(s) measured, not ratcheted.   exit 0

mid (case 7 applied, row not yet moved):
✗ check-skills-token-ratchet: skills/objectstack-data/evals/hooks-security-seeds-datasources.json is 1573 tokens; the ratchet ceiling is 1404 (over by 169). …   exit 1

after (row 1404 -> 1573):
✓ check-skills-token-ratchet: skills/objectstack-data/evals/hooks-security-seeds-datasources.json is 1573 tokens (ceiling 1573; headroom 0).
✓ check-skills-token-ratchet: 82 authored bundle file(s) within their ceilings; 10 generator-owned file(s) measured, not ratcheted.   exit 0
node scripts/check-skills-token-ratchet.mjs --self-test → ✓ check-skills-token-ratchet self-test: 79 cases pass.   exit 0
Published-face probe — `@objectstack/skills` build
node packages/skills/scripts/sync-catalog.mjs --self-test → ✓ sync-catalog self-test: 9 cases pass.   exit 0
node packages/skills/scripts/sync-catalog.mjs → ✓ @objectstack/skills: 93 file(s) across 11 skill(s) copied from skills/** into dist/skills and read back byte-identical.   exit 0
grep -c CREDIT_LIMIT   dist/skills/objectstack-data/evals/hooks-security-seeds-datasources.json → 3
grep -c membershipKeys dist/…/hooks-security-seeds-datasources.json → 2   (positive control)
grep -c NOT_A_SYMBOL_XYZ dist/…/hooks-security-seeds-datasources.json → 0   (absent control)
cmp dist copy vs source → byte-identical
Derived local gate families — `dispatch-gates --commands` (60 commands at this head), each exit captured before any pipe, `--ran` reconciled
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack   → 60 commands (derived from the tree at 1a0fb94a; change set: 3 paths, 52 changed lines vs the 3000 human-merge threshold)
runner: eval "$cmd" > log 2>&1; status=$?; printf '%s :: exit %d\n' → ran.list   (57 × exit 0, 3 × exit 3)
node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack
✓ dispatch-gates --ran: 60 derived famil(ies) accounted for — 57 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).   exit 0
  NOT-MEASURED (exit 3 = EXIT_PREREQUISITE_NOT_MET, an unbuilt dist/; the diff touches no workspace package, so no build closure is owed — CI measures them on its fresh build):
    pnpm --filter @objectstack/lint run check:doc-formula-expressions   (PREREQUISITE NOT MET — @objectstack/formula is not built)
    pnpm check:dual-build-cjs-loads   (self-test 93 cases pass; gate body: some package has no dist/)
    pnpm check:lean-entry-closure   (packages/objectql/dist absent)
exit 0 (57): check:adr-0087-registration (+ --self-test), check:changeset-no-major, check:closing-keyword-parity (+ --self-test), check:comment-mask-corpus, check:declaration-mirrors (+ --self-test), check:doc-route-spelling --advisory (+ --self-test), check:dts-emitted --self-test, check:empty-changeset (+ --self-test), check:self-test-wired (+ --self-test), check:self-test-workflow-commands (+ --self-test), check:skills-token-ratchet (+ --self-test), check:whole-set-label-write (+ --self-test), ci/assert-turbo-remote-cache --self-test, ci/plan-test-core-shards --self-test, ci/prune-turbo-cache --self-test, pm/bare-root-worklist --self-test, pm/release-rehearsal-clone --self-test, release-pending-publish --self-test, check:agent-test-spelling, check:bash32-floor, check:changeset-gate-self-tests, check:ci-filter-parity, check:cli-command-ids, check:closed-ceremony-text, check:corpus-claim-drift, check:cross-package-test-inputs, check:doc-authoring, check:driver-memory-census, check:dts-closure, check:entry-guard, check:gitlink-declared, check:nul-bytes, check:parse-guard, check:pm-changeset-deadline-census, check:pm-dispatch-gates, check:pm-governed-merges, check:pnpm-filter-targets, check:pnpm-flag-spellings, check:published-files, check:ratchet-remedy-authority, check:refd-timer-probe, check:role-word, check:scripts-symbol-anchors, check:skill-compatibility, check:skill-frame-sync, check:skill-identifier-liveness, check:sourcemap-no-sources-content, check:watch-hint-literal

Edited gate script's own suite (clause ⑤): scripts/check-skills-token-ratchet.mjs has no *.test.* naming it (git grep -l check-skills-token-ratchet -- '*.test.*' is empty); its suite is its --self-test, run above (79 cases pass). The local-only families check:dev-prereqs / check:skill-frame-freshness are not owed: no packages/spec build and no four-axis frame file touched. test:repo is CI's (Test Core); not run locally — the diff touches no vitest repo project test.

Every family CI runs on the PR is cited from the PR's check-runs by the seat, not re-run here; at report time they are in_progress.

No package build closure (①) is owed: the diff touches no workspace package source (skills/** at the repo root, one scripts/ row, one changeset). No ablation: no tested runtime behaviour changed (eval data and a ratchet constant). No dogfood battery: no ! in the title and no (narrowing) arm.

Acceptance notes

  • Observation, not filed — skills/objectstack-i18n names no hooks translation group. content/docs/protocol/kernel/i18n-standard.mdx lists hooks.{hook}.refusals.{code} among the translation targets (PR feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831 added it), and translation.zod.ts declares the nested hooks record, but skills/objectstack-i18n/SKILL.md's roster of top-level groups ("apps, messages, globalActions, picklists, dashboards, datasets, reports, pages, flows, positions, permissions, metadataForms, settingsCommon") does not name hooks, and nothing under that skill mentions it (git grep -c hooks origin/main -- skills/objectstack-i18n/ is empty). An AI author writing the zh-CN bundle from that skill alone would not know where the refusal sentence goes. A coverage gap in a published skill, no runtime defect; the skills seat decides whether it rides a follow-up (a SKILL.md row at its own ceiling, same ratchet shape). Out of this card's file surface.
  • evals/README.md still describes this file as "search mirrors via hooks, permission-set scopes, external datasources, seeds (…)"; a mention of the refusal case does not fit its headroom of 1 token and the seat set README at +0. Noted, not changed.
  • references/data-hooks.md still spells its three pre-catalogue examples as throw new Error('Email is required') etc. (lines 163, 780, 785) beside the new refusals section — the file is out of this card's surface (the teaching landed with feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831) and the examples are legal where no catalogue is declared ("A hook that declares no refusals is untouched"); the eval now scores the habit where a user-visible, translated message is asked for.

Verification

  • node scripts/check-skills-token-ratchet.mjs → hooks-security-seeds-datasources.json is 1573 tokens (ceiling 1573; headroom 0), last line 82 authored bundle file(s) within their ceilings.
  • node -e "const f=require('./skills/objectstack-data/evals/hooks-security-seeds-datasources.json'); console.log(f.evals.length, f.evals[6].assertions)" → 7 and the two assertion arrays above.
  • ceil($(wc -c < skills/objectstack-data/evals/hooks-security-seeds-datasources.json) / 4) → 1573.

Generated by Claude Code

…atalogue instead of a thrown English sentence

One eval case in hooks-security-seeds-datasources.json: a save refused over
the account's credit limit in an app serving English and Chinese users —
expected `refusals: { CREDIT_LIMIT: { message } }` on the hook, a
`language: 'js'` body throwing `{ code: 'CREDIT_LIMIT' }`, and the zh-CN
sentence under the bundle's `hooks: { HOOK: { refusals } }`; refused: a
sentence on the throw (`new Error('`) and a `handler:`.

Paid down first: five sentences in cases 1-5 that restated their own
`must_not_contain` assertions (453 bytes = 114 units); the case adds
1129 bytes = 283 units; the file is 5616 -> 6292 bytes, 1404 -> 1573
tokens. The ratchet row moves 1404 -> 1573, pinned at the measurement
under the standing ruling quoted beside it. Patch changeset for
`@objectstack/skills`, whose build copies skills/** into its published dist.

Claude-Session: https://claude.ai/code/session_01QSeKYmhU5KEj1nukYhp1Tg
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tooling labels Oct 28, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review October 28, 2026 04:58
@os-zhuang
os-zhuang enabled auto-merge October 28, 2026 04:58
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1a0fb94a5
Local-runs: none
Scope: skills/objectstack-data/evals/hooks-security-seeds-datasources.json, scripts/check-skills-token-ratchet.mjs, .changeset/26834-hook-refusals-eval.md + packages/spec/src/data/hook.zod.ts, packages/spec/src/system/i18n-resolver.ts (hookRefusalMessageKey), skills/objectstack-data/references/data-hooks.md §Declared refusals (read, not reviewed)

Read at 2026-10-28T05:04Z by the domain:skills#2 seat on the fetched head (merge-base 5cba8fc2): three files, +46/−6. Tier H by path (skills/**); the approval of record is os-zhuang's APPROVED review on this head (2026-10-28T04:58Z), who also readied and armed the PR — the seat flipped nothing. This record is the surface-owed review, rendered after that approval.

① Derived judgments

  • No accept set and no runtime surface moves: eval data, one ratchet constant, one changeset. @objectstack/skills ships skills/** byte-identical into dist/ (packages/skills/package.json is not private, files: [dist, …]; pr-automation.yml L837–840 says a skills/** change releases that package), so the eval is a published face and the patch changeset is owed — the two precedents' skip-changeset (bb60b86f, 7c70f91c) were the wrong declaration, not a rule; Check Changeset is green on this head.
  • Eval 7's assertions against the contract on origin/main: the teaching (data-hooks.md L941–959) spells the catalogue as refusals: { CODE: { message, status? } }, the body refusing by code with throw Object.assign(new Error(), { code: 'CREDIT_LIMIT' }), codes SCREAMING_SNAKE, 「Refused at save without a js body (e.g. a bare handler)」, translation under hooks.HOOK.refusals.CODE.message (hookRefusalMessageKey in i18n-resolver.ts L3686; pinned by hook-refusals.test.ts L194). must_contain [refusals:, CREDIT_LIMIT: {, code: 'CREDIT_LIMIT', language: 'js', hooks: {, 超出账户信用额度] and must_not_contain [new Error(', handler:] each name a spelling the contract makes right or wrong; new Error() (no quote) in the correct answer is not caught by new Error(' — the negative pin is exact. Correct.
  • The five cuts in cases 1–5 remove sentences that restated each case's own must_not_contain; every assertion array is unchanged (diff read). Correct.
  • Ratchet: file 5616 → 6292 B, ceil(6292/4) = 1573 tokens (recomputed on the fetched blob); the one row moved 1404 → 1573 with the arithmetic and the standing ruling 「A:先削重复,余额按实测提高」 quoted in the map's comment, as its siblings do; README untouched (142/143). Within the seat's ≤ +180 budget (+169). Correct.

② Semver level

  • patch on @objectstack/skills: eval data, no key or behaviour change; Clause-②: no, Metadata-protocol: none on the PR body, the changeset body and the claim agree; no Skills-docs: line owed.

③ Boundary flags

  • Dev flag: skills/objectstack-i18n names no hooks translation group, so a bundle author learns the refusal-message key from data-hooks.md only — a coverage gap on a sibling package (the i18n card skills(objectstack-i18n): the group list and the bundles eval do not teach the books translation group (follow-up owed by #26690) #26820, seat 1's in flight, teaches the groups roster); the seat routes it as a note to that card's thread, not a new card.
  • Dev flags: README text without the new case (headroom 1, budget +0) and three pre-catalogue throw new Error('…') examples in data-hooks.md where no catalogue is declared — legal, noted.
  • Triage's 「⛔ does not raise the ceiling」 vs the maintainer's standing ruling: the dev paid down first (114 units) and raised the balance pinned at the measurement; the stricter reading honoured as far as the package allows. Judged consistent.
  • Author objectstack-fleet[bot] through the relay; mcp_calls: 0; the ready flip and auto-merge are os-zhuang's acts, not a seat's.

④ Design judgment

  • The skill file against the card's prompt and the spec it teaches: the case asks for exactly the trap the card names (an English sentence thrown from the body reaching zh-CN users) and scores the catalogue + code-only throw + nested hooks bundle group, each a spelling the spec accepts today. Holds.

Implemented-by: claude/issue-26834-hook-refusals-eval
Reviewed-by: session_01QSeKYmhU5KEj1nukYhp1Tg

VERDICT: PASS

@os-zhuang
os-zhuang added this pull request to the merge queue Oct 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 — 席位终稿 · PR #27088 · head 1a0fb94a · domain:skills#2 · 2026-10-28T05:04Z

席位核对 — ACCEPT · domain:skills#2 · session_01QSeKYmhU5KEj1nukYhp1Tg · 2026-10-28T05:04Z

  1. 形态:ready(os-zhuang 翻,非席位)→ main,首行 Fixes #26834,Clause-②: no、Metadata-protocol: none 行首在(PR 正文与 changeset 同);三个文件 +46/−6,与认领申报面相同(eval 文件、棘轮行、changeset);assignee os-justin;标签 documentation/size/tooling 为 labeler 所加。
  2. 前提实测:席位在取回的 head 上重算——eval 6292 B、ceil/4 = 1573,与棘轮新行相等;7 个用例 id 1–7;data-hooks.md L941–959 的教学与用例 7 的每条断言逐一对应;hookRefusalMessageKey 在 spec(i18n-resolver.ts L3686);PR feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831 已在 main。
  3. 门禁 check-runs(head 1a0fb94a,本笔读数 2026-10-28T05:04Z):28 success · 12 skipped · 1 in progress (Test Core);Check Changeset 与 Governed Surface Queue Guard 皆 success;dev 本地并集 57/60 exit 0、3 条 exit 3(未建 dist 的前置,CI 测)。
  4. 接受的偏差:① changeset 取代前例的 skip-changeset(dev 带正/负对照实测发布面,席位认同);② 开 PR 后 main 又进 5 个 commit、未再合(不触碰本 PR 的三个文件,AGENTS §10 只在冲突/红时合);③ 中继 pr_create 未送 labels(skip-changeset 不适用、空数组被拒),零标签写。
  5. 范围外去向:objectstack-i18n 缺 hooks 翻译组 → 席位以一行留言交 skills(objectstack-i18n): the group list and the bundles eval do not teach the books translation group (follow-up owed by #26690) #26820(席位 1 在飞的 i18n 组清单卡);README 未写新用例、data-hooks.md 三处旧式 throw 示例 → Acceptance notes;Skills-docs: 不欠(kind none)。
  6. 落地条款:Tier H;授权批准(os-zhuang APPROVED,head 1a0fb94a)在案,本人已 ready + auto-merge;契约复核 PASS 评论 6461131809;席位不再动它,跟到 MERGED 后关卡摘标(Fixes 自关,pm:dispatched 同笔摘)。

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

Labels

documentation Improvements or additions to documentation size/s tooling

Projects

None yet

3 participants