Skip to content

Commit a66c9ff

Browse files
skills(objectstack-api): teach import applyOn, read FLOW_FAILED's innerRefusal, and the anonymous-form file refusal (#26392)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ83Exa2w6Et9nBGUexBDabLx)_ Fixes #26299 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:对外发布的 `skills/objectstack-api` 补三段教学,各落在卡点名的章节:`SKILL.md` 公开表单段加「匿名表单不声明文件字段」(lint 规则 `public-form-file-field`、发布门、旧表单提交时的 `400 VALIDATION_FAILED`);派生操作表后加导入 `mapping` 条目的 `applyOn: 'always' | 'create'`;错误信封段加流程门的 `400 FLOW_FAILED` 的 `details`(`FlowFailureDetailsSchema`:`errorMessage` / `summary` / `innerRefusal`)与渲染顺序。`evals/endpoints-auth-routes.json` 加一题导入 eval;该 eval 文件在 `scripts/check-skills-token-ratchet.mjs` 的天花板行按实测抬 1042 → 1234。 - 为什么改:#26184 技能缺口审计 § objectstack-api 三行(API1–API3)均为「未教、无 eval」:写导入作业的 AI 作者每次重跑 upsert 都会覆盖管理员手改的列;客户端作者只能显示「flow failed」而不是真正的拒收原因;公开表单作者会声明一个访客永远填不了的文件字段。 - 风险与回滚:只改已发布技能文本与一条棘轮行,无运行时代码;每句教学都对照 `main` 上的 spec 与实现逐行核过(见 Acceptance notes)。回滚 = revert 本 PR。 - 先看:① `skills/objectstack-api/SKILL.md` 三个 hunk(行 104–111、269–275、372–380);② `skills/objectstack-api/evals/endpoints-auth-routes.json` 新第 6 题;③ `scripts/check-skills-token-ratchet.mjs` 的 `endpoints-auth-routes.json` 行(1042 → 1234,引你 2026-10-27 的裁决)。 - 两分钟验证:`node scripts/check-skills-token-ratchet.mjs | grep objectstack-api` — 预期 `SKILL.md 5128 / 6314`、`endpoints-auth-routes.json 1234 / 1234 (+0)`。 - 需要你做的:Tier H(`skills/**`):审阅通过后 approve,或直接 Merge when ready。 ## What changes Three teachings the skills gap audit (#26184, Part A § objectstack-api, rows API1–API3) measured missing from the published `objectstack-api` package, each placed in the section the card names, in the fewest lines an AI author needs: 1. **Public (anonymous) Form Endpoints** — a form open to anonymous intake declares no file field: a section field whose object type is in `FILE_REFERENCE_TYPES` (`image`, `file`, `avatar`, `video`, `audio`) is refused by the gating lint rule `public-form-file-field` at `os validate` / `os build` / `os lint` and at the view's publish; a form published before that rule meets the same refusal at submit (flat `400 VALIDATION_FAILED`, `fields[].code: 'invalid_value'`); the prescription is a signed-in form. 2. **API Methods (Operations)** — after the derived-verbs table: `POST /data/{object}/import` takes `mapping` as `{ Column: field }` or as `FieldMappingEntry[]`, and only the entry form carries `applyOn` (`'always'` default; `'create'` writes the column only when the row is created, so a re-run `upsert` leaves an admin's edit alone; a create-only column is still read for matching; any other value is `400 INVALID_REQUEST`). 3. **Error Envelopes & the Code Ledger** — a flow door's `400 FLOW_FAILED` carries `error.details` shaped by `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?` as `{ code, message }` in the wire spelling), and a client renders a refused run as a record form renders a refused save: top-level `userMessage`, then `details.innerRefusal`, then `message`; absent keys mean the run produced none. One eval fixture (id 6) for the import `applyOn` in `evals/endpoints-auth-routes.json`, in the file's existing shape. **Token ceiling (ruling A).** `scripts/check-skills-token-ratchet.mjs` prices the eval file shrink-only at 1042 with 3 tokens of headroom on `main`. The maintainer ruled on the seat's decision card 「skills 修复卡的教学和 eval 都撞 token 天花板,怎么付?」 with the tap, verbatim and untranslated, 「A:先削重复,余额按实测提高」. Paid down first: the file's five fixtures share no sentence, so there was no duplicate to cut (0 bytes). The fixture adds +780 bytes (4156 → 4936 bytes, 1039 → 1234 tokens); the 3 tokens of headroom are absorbed and the row moves 1042 → 1234 (+192), pinned AT the measurement with zero headroom. `SKILL.md` fits inside its existing ceiling (4691 → 5128 of 6314) and its row does not move. The file surface widened to that one ratchet row under the seat's amended claim. **Line readings** (os-dev.md 290–291, whole file before → after): | File | Before | After | Δ | |:--|--:|--:|--:| | `skills/objectstack-api/SKILL.md` | 417 | 444 | +27 (three paragraphs of 8 / 7 / 9 lines, each with its blank separator) | | `skills/objectstack-api/evals/endpoints-auth-routes.json` | 55 | 65 | +10 | | `skills/objectstack-api/references/_index.md` (generator-owned, untouched) | 46 | 46 | 0 | | **Package** (`SKILL.md` + `references/**/*.md` + `rules/**/*.md`) | **463** | **490** | **+27** | Token readings in the ratchet's convention (`ceil(utf8 bytes / 4)`): `SKILL.md` 4691 → 5128 (ceiling 6314); eval file 1039 → 1234 (ceiling 1042 → 1234). Nothing is removed: the card names no stale lines, and no line in the package duplicated what the three paragraphs teach (0 hits on `main` for `applyOn`, `innerRefusal`, `FLOW_FAILED`, `file field`). ## Why The audit's acceptance is 「one fix card per skill package」; this is the objectstack-api card. Who pays today: an AI author of import jobs, who overwrites a column an admin owns on every re-import; a client author, who shows "flow failed" instead of the real reason; a public-form author, who declares a field no visitor can fill and meets the refusal only at publish. ## Tests - `pnpm --filter @objectstack/spec check:skill-docs` — exit 0 (`✅ Skill docs in sync`; frontmatter untouched). - `pnpm --filter @objectstack/spec check:skill-refs` — exit 0 (`✅ 9 generated files in sync`; `references/_index.md` is generator-owned and unchanged — no `gen:skill-refs` needed). - `pnpm --filter @objectstack/spec check:skill-examples` — exit 0 after `pnpm --filter @objectstack/spec build` and `pnpm --filter @objectstack/client-react... build` under the verify lock (✅ self-test: flags a bare `any` parameter / variable / property / return / alias / cast in a ✅ 264 prose examples type-check across 3 surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on all of them); the first run exited 3 (PREREQUISITE NOT MET: no `packages/client-react/dist` declarations) and measured nothing (needs a built `packages/spec/dist`; the diff adds no `os:check` block). - `node scripts/check-skills-token-ratchet.mjs` — exit 0 after the raise (`81 authored bundle file(s) within their ceilings`); before the raise it refused exactly this file (`1234 tokens; the ratchet ceiling is 1042 (over by 192)`), which is the reading the row quotes. `--self-test`: 79 cases pass. - `pnpm check:skill-identifier-liveness` — exit 0 (Leg 1: 490 citations over 69 files; Leg 2: 8 registered sections, 0 gaps). `pnpm check:skill-top-level-keys`, `check:skill-compatibility`, `check:skill-frame-sync` — exit 0. - `npx eslint --no-inline-config scripts/check-skills-token-ratchet.mjs` — exit 0; `pnpm check:nul-bytes` — exit 0. - The eval fixture parses (`JSON.parse`), and its `must_contain` tokens appear in its own `expected_output`. - No package build or `pnpm test` is owed: the diff touches no `packages/**` source (local-verification ①/② empty); the one script edited is a gate whose own suite is its `--self-test` (run above). ## Acceptance notes Every sentence is read off `main` at `756220c4`, not off the card's paraphrase: - `applyOn` — `packages/spec/src/api/export.zod.ts:174` (`FieldMappingEntrySchema.applyOn: ColumnApplyOnSchema.default('always')`), `packages/spec/src/data/column-apply-on.zod.ts` (`create | always`); the import door reads it only from the entry-array form of `mapping` (`packages/rest/src/import-prepare.ts:610–627`, `createOnlyFields`), the record shorthand cannot carry it, and a value outside the pair is `400 INVALID_REQUEST` (same file, 620–626); the runner leaves create-only targets out of the update patch (`packages/core/src/utils/import-runner.ts:246`). - `FLOW_FAILED` — `packages/spec/src/api/automation-api.zod.ts:585–602` (`FlowFailureDetailsSchema`: `errorMessage?`, `summary?`, `innerRefusal?`), `FlowInnerRefusalSchema` (`code` in the wire spelling, `message`); the envelope's top-level `userMessage` is `ApiErrorSchema.userMessage` (`packages/spec/src/api/contract.zod.ts:93`); the doors (trigger, resume, `/actions`, declared `type: 'flow'` endpoints) and the render order `userMessage` → `details.innerRefusal` → `message` are the runtime CHANGELOG's own consumer note for that change (`packages/runtime/CHANGELOG.md:52–63`). - Anonymous-form file field — `packages/lint/src/validate-public-form-file-fields.ts` (rule id `public-form-file-field`, `FILE_REFERENCE_TYPES` = `image`, `file`, `avatar`, `video`, `audio` from `packages/spec/src/data/field-value.zod.ts:306`), wired `tier: 'gating'`, `commands: ALL`, `surfaces: CLI_AND_RUNTIME`, `runtimeTypes: ['view']` in `packages/lint/src/authoring-rules.ts:1574–1596`; the submit door's refusal for forms published before the rule is `packages/rest/src/form-routes.ts:650–663` (`code: 'invalid_value'`, folded into the flat `400 VALIDATION_FAILED` with `fields[]`). The seat's premise read did not locate this in `packages/spec/src/api`; it lives in `@objectstack/lint`, so the paragraph names the lint rule rather than a spec symbol. - Scope held to the card's 「Wanted」: three paragraphs, one eval, one ratchet row. Not taught (deliberately, outside the Wanted): the seed dataset's `SeedColumn.applyOn` twin (objectstack-data's surface), the resume door's `ResumeFailureDetailsSchema` keys, and the `errorMessage` / `summary` keys beyond naming them. - The commit's trailer pair is model-free as the pre-push hook requires; the harness-written attribution lines at the top of this body are kept as the platform wrote them. - PR body layout: `.github/PULL_REQUEST_TEMPLATE.md` landed on `main` after this card was dispatched (`756220c4`); this body carries the os-dev.md section order (`Fixes` / declaration lines / `## 维护者速读` / `## What changes` / `## Tests` / `## Acceptance notes` / `## Verification`) and adds the template's `## Why` and `## Human Minimum` so every template heading is present. ## Verification <details><summary>Derived gate list and local run (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `3add67c7`; exit codes captured before any pipe)</summary> | Command | Exit | Reading | |:--|--:|:--| | `node scripts/check-ci-filter-parity.mjs` | 0 | pass | | `node scripts/check-closing-keyword-parity.mjs` | 0 | pass | | `node scripts/check-closing-keyword-parity.mjs --self-test` | 0 | pass | | `node scripts/check-comment-mask-corpus.mjs` | 0 | pass | | `node scripts/check-declaration-mirrors.mjs` | 0 | pass | | `node scripts/check-declaration-mirrors.mjs --self-test` | 0 | pass | | `node scripts/check-doc-route-spelling.mjs --advisory` | 0 | pass | | `node scripts/check-doc-route-spelling.mjs --self-test` | 0 | pass | | `node scripts/check-dts-emitted.mjs --self-test` | 0 | pass | | `node scripts/check-scripts-symbol-anchors.mjs` | 0 | pass | | `node scripts/check-scripts-symbol-anchors.mjs --self-test` | 0 | pass | | `node scripts/check-self-test-wired.mjs` | 0 | pass | | `node scripts/check-self-test-wired.mjs --self-test` | 0 | pass | | `node scripts/check-self-test-workflow-commands.mjs` | 0 | pass | | `node scripts/check-self-test-workflow-commands.mjs --self-test` | 0 | pass | | `node scripts/check-skills-token-ratchet.mjs` | 0 | pass | | `node scripts/check-skills-token-ratchet.mjs --self-test` | 0 | pass | | `node scripts/check-whole-set-label-write.mjs` | 0 | pass | | `node scripts/check-whole-set-label-write.mjs --self-test` | 0 | pass | | `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` | 0 | pass | | `node scripts/ci/prune-turbo-cache.mjs --self-test` | 0 | pass | | `node scripts/pm/bare-root-worklist.mjs --self-test` | 0 | pass | | `pnpm --filter @objectstack/lint run check:doc-formula-expressions` | 0 | pass | | `pnpm --filter @objectstack/spec run check:skill-docs` | 0 | pass | | `pnpm check:agent-test-spelling` | 0 | pass | | `pnpm check:bash32-floor` | 0 | pass | | `pnpm check:cli-command-ids` | 0 | pass | | `pnpm check:corpus-claim-drift` | 0 | pass | | `pnpm check:cross-package-test-inputs` | 0 | pass | | `pnpm check:doc-authoring` | 0 | pass | | `pnpm check:driver-memory-census` | 0 | pass | | `pnpm check:dts-closure` | 0 | pass | | `pnpm check:dual-build-cjs-loads` | 3 | NOT MEASURED — PREREQUISITE NOT MET: 33 dual-build packages have no dist/ (needs a repo-wide `pnpm build`; the diff touches no package) — CI's run is the reading | | `pnpm check:entry-guard` | 0 | pass | | `pnpm check:gitlink-declared` | 0 | pass | | `pnpm check:lean-entry-closure` | 0 | pass | | `pnpm check:merge-driver` | 0 | pass | | `pnpm check:nul-bytes` | 0 | pass | | `pnpm check:parse-guard` | 0 | pass | | `pnpm check:pm-dispatch-gates` | 0 | run detached (fast tier, 1901 cases); verdict: ✓ dispatch-gates self-test (fast tier): 1901 cases pass; 7 slow section(s) deferred. | | `pnpm check:pm-governed-merges` | 0 | pass | | `pnpm check:pm-skill-id-lint` | 0 | pass | | `pnpm check:pnpm-filter-targets` | 0 | pass | | `pnpm check:pnpm-flag-spellings` | 0 | pass | | `pnpm check:ratchet-remedy-authority` | 0 | pass | | `pnpm check:refd-timer-probe` | 0 | pass | | `pnpm check:role-word` | 0 | pass | | `pnpm check:skill-compatibility` | 0 | pass | | `pnpm check:skill-frame-sync` | 0 | pass | | `pnpm check:skill-identifier-liveness` | 0 | pass | | `pnpm check:sourcemap-no-sources-content` | 0 | pass | | `pnpm check:watch-hint-literal` | 0 | pass | </details> <details><summary>`--ran` reconciliation</summary> ``` Run reconciliation — 52 derived, 51 run, 1 NOT-MEASURED, 0 UNRUN. - pnpm check:dual-build-cjs-loads [recorded exit 3 on line 33 — PREREQUISITE NOT MET] ✓ dispatch-gates --ran: 52 derived famil(ies) accounted for — 51 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3). ``` </details> <details><summary>Token ratchet before and after the raise</summary> ``` before: ✗ check-skills-token-ratchet: skills/objectstack-api/evals/endpoints-auth-routes.json is 1234 tokens; the ratchet ceiling is 1042 (over by 192). The published c… after: ✓ check-skills-token-ratchet: skills/objectstack-api/SKILL.md is 5128 tokens (ceiling 6314; headroom 1186). Its release-stamp line (`version: "18.1.0"`, 20 bytes) is written by the version script, not an author, and is not priced. after: ✓ check-skills-token-ratchet: skills/objectstack-api/evals/endpoints-auth-routes.json is 1234 tokens (ceiling 1234; headroom 0). ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (no schema touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint passes on the one script touched (`pnpm lint` is CI's run). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (`skills/**` carries no `package.json`; nothing is released → `skip-changeset`; the declaration lines are above) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block and the quoted ruling are the repo's own Chinese conventions). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbaQ2Qeznx5DrAfybLP Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c70f91 commit a66c9ff

3 files changed

Lines changed: 55 additions & 1 deletion

File tree

‎scripts/check-skills-token-ratchet.mjs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,24 @@ export const CEILINGS = new Map([
658658
// moves: no existing row is raised, the population is unchanged, and
659659
// `evals/**` stays priced.
660660
['skills/objectstack-ai/evals/skills-tools-knowledge.json', 1074],
661-
['skills/objectstack-api/evals/endpoints-auth-routes.json', 1042],
661+
// 1042 -> 1234 (card #26299, the objectstack-api row of the skills gap
662+
// audit): one eval for the import mapping's `applyOn: 'create'` — the
663+
// published teaching is the only place an author meets the column policy
664+
// before a re-run upsert reverts an admin's edit, and this fixture is the
665+
// instrument that makes the wrong answer fail at scoring. Maintainer ruling
666+
// 2026-10-27 on the seat's decision card 「skills 修复卡的教学和 eval 都撞
667+
// token 天花板,怎么付?」, his tap verbatim and untranslated:
668+
// 「A:先削重复,余额按实测提高」. Paid down first, as the ruling asks: the
669+
// file's five fixtures share no sentence, so there was no duplicate to cut
670+
// (0 bytes). Arithmetic in bytes, because that is what this convention
671+
// divides: on `origin/main` the file was 4156 bytes (1039 tokens, headroom
672+
// 3); the fixture adds +780 bytes, so the LANDED file is 4936 bytes -> 1234
673+
// tokens: the 3 tokens of headroom are absorbed and the ceiling moves
674+
// 1042 -> 1234 (+192). Pinned AT the measurement — zero headroom, so the
675+
// next token added here is paid for by deleting one from this file.
676+
// Independently reproducible without running this script:
677+
// ceil($(wc -c < skills/objectstack-api/evals/endpoints-auth-routes.json) / 4) == ceil(4936 / 4)
678+
['skills/objectstack-api/evals/endpoints-auth-routes.json', 1234],
662679
['skills/objectstack-automation/evals/flows-triggers-approvals.json', 1255],
663680
// 894 -> 1095 (#26179): one regression case for the field permission `when`
664681
// (the contract prompt: lock the legal terms for finance from approval on,

‎skills/objectstack-api/SKILL.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ organization to offer it again. For the full contract, read
101101
`node_modules/@objectstack/spec/src/ui/sharing.zod.ts` (`SharingConfigSchema`
102102
with `enabled` / `allowAnonymous` / `publicLink`).
103103

104+
A form open to anonymous intake declares no file field. A section field whose
105+
object type is in `FILE_REFERENCE_TYPES` (`image`, `file`, `avatar`, `video`,
106+
`audio`) is refused by the gating lint rule `public-form-file-field` — at
107+
`os validate` / `os build` / `os lint`, and at the view's publish — because a
108+
visitor has no upload door to mint a `sys_file` id. A form published before that
109+
rule meets the same refusal at submit: a file value answers the flat `400
110+
VALIDATION_FAILED` with `fields[].code: 'invalid_value'`. Collect the file on a
111+
signed-in form instead.
112+
104113
---
105114

106115
## Declarative Endpoints (`apis:`) — no handler code
@@ -257,6 +266,14 @@ eight extra verbs are never declared in `apiMethods`:
257266
derive — the trash surface is retired. Declaring any derived verb in
258267
`apiMethods` is stripped at parse with a FROM → TO warning.
259268

269+
`POST /data/{object}/import` takes `mapping` as `{ Column: field }` or as
270+
`FieldMappingEntry[]` (`api/export.zod.ts`); only the entry form carries
271+
`applyOn`. `'always'` (default) writes the column on every row the import
272+
creates or updates; `'create'` writes it only when the row is created, so a
273+
re-run `upsert` refreshes the `always` columns and leaves a value an admin set
274+
since (an owner, a stage) as it is. A create-only column is still read for
275+
matching. Any other value is `400 INVALID_REQUEST`.
276+
260277
---
261278

262279
## Discovery & Health
@@ -352,6 +369,16 @@ ledger and composes the two checks itself — `envelopeViolations(body)` for the
352369
SHAPE, `makeApiErrorSchema(yourCodes)` for the VOCABULARY (both in
353370
`api/contract.zod.ts`). What no repo may do is emit a code in no ledger at all.
354371

372+
A flow door — `POST /api/v1/automation/{name}/trigger`, resume, `/actions`, a
373+
declared `type: 'flow'` endpoint — answers a failed run with `400 FLOW_FAILED`;
374+
its `error.details` is `FlowFailureDetailsSchema` (`api/automation-api.zod.ts`):
375+
`errorMessage?` (the flow author's text), `summary?` (which node failed) and
376+
`innerRefusal?`, the `{ code, message }` a node's write was refused with, `code`
377+
in its wire spelling (`UNIQUE_VIOLATION`, `VALIDATION_FAILED`,
378+
`PERMISSION_DENIED`). Render it as a record form renders a refused save:
379+
top-level `userMessage`, then `details.innerRefusal`, then `message`. Absent
380+
keys mean the run produced none — never invent an empty.
381+
355382
---
356383

357384
## Best Practices

‎skills/objectstack-api/evals/endpoints-auth-routes.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@
5050
"must_contain": ["apiMethods", "/batch", "trackHistory", "lifecycle"],
5151
"must_not_contain": ["'search'", "'restore'"]
5252
}
53+
},
54+
{
55+
"id": 6,
56+
"prompt": "Our nightly CSV upsert of `supplier` rows by `tax_id` keeps resetting the `owner` column that sales reassigns by hand. Fix the import body.",
57+
"expected_output": "Sends `mapping` as a `FieldMappingEntry[]` (the `{ Column: field }` shorthand cannot carry it) with `{ sourceField: 'Owner', targetField: 'owner', applyOn: 'create' }`: the column is written when the upsert creates the row and left out of the patch when it matches one; the other columns keep the default `applyOn: 'always'`. Keeps `writeMode: 'upsert'` and `matchFields: ['tax_id']`.",
58+
"files": [],
59+
"assertions": {
60+
"must_contain": ["applyOn", "'create'", "sourceField", "targetField"],
61+
"must_not_contain": ["writeMode: 'insert'"]
62+
}
5363
}
5464
]
5565
}

0 commit comments

Comments
 (0)