Skip to content

feat(objectql): record-currency guards — blank code refuses the write; ungrouped sum/avg/min/max over a record-mode amount refused; summary roll-up over one refused at registration - #27339

Merged
objectstack-fleet[bot] merged 27 commits into
mainfrom
claude/project-thread-1v9hrc
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 27 commits into
mainfrom
claude/project-thread-1v9hrc

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Requested by Bill · project thread
Fixes #27186

⛔ not released alone — R1: pairs with #27184, #27186, #27187, #27188 (one Version Packages release)
Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么: record 模式金额写入时币种码为空 ⇒ 拒写(ERR_CURRENCY_CODE_REQUIRED,validate() 预览同样拒);sum/avg/min/max 对 record 金额不按币种码分组 ⇒ 拒收;summary 汇总 record 子字段 ⇒ 注册时拒收;公式引用 record 金额时声明为 record 币种。
  • 为什么改: 测量轮探针里 USD 100 + EUR 100 = 200、USD 100 + JPY 15000 汇总成 15100,都是静默错数;这三道门把它们变成响亮拒收(ADR-0144 D6、D8 规则 4)。
  • 风险与回滚: 只对声明了 currencyMode: 'record' 的字段生效,该模式由已合并的 spec(field): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1, epic #8345) #27184 引入,其他字段零影响;回滚即撤本 PR,但须与 R1 同批。
  • 先看: packages/objectql/src/validation/record-currency-code.ts(更新路径用存量行叠加载荷判定);packages/objectql/src/record-currency-aggregate-door.ts;packages/objectql/src/registry.ts 的注册拒收位置。
  • 两分钟验证: pnpm --filter @objectstack/objectql exec vitest run --project local src/record-currency-guards.test.ts,预期 13/13 通过(混币 sum 被拒、按币种码分组得到 USD 100 / EUR 100)。
  • 需要你做的: 无,席位落地(与 R1 同批发版)。

What changes

  • Blank code refuses the write (ADR-0144 D8 rule 4, Open item 9). validation/record-currency-code.ts refuses a row image that holds a value in a record-mode amount beside a blank code field (absent, null, white space) with ERR_CURRENCY_CODE_REQUIRED / 400; field names the code field, amountField the amount. Judged on insert (each final row), by-id update (stored row with the final payload over it) and predicate update (each matched row with the payload over it, read only when the payload carries an amount or its code). An update that does not touch the pair is never judged, so a pre-existing row is not refused for an unrelated edit. The validate() preview (engine-validate.ts, validateData) runs the same check at the write's step, on the same image (the insert row; the stored row with the patch over it for a by-id update), and answers it as the row's ERR_CURRENCY_CODE_REQUIRED finding, so a dry run does not pass a row the write refuses (changeset 26684's parity). A predicate-dispatched update preview holds no stored row and is not judged. The code is registered under @objectstack/objectql in the ADR-0112 ledger (error-code-ledger.zod.ts); the generated reference pages are regenerated.
  • Ungrouped raw aggregate refused (ADR-0144 D6, Open item 10). record-currency-aggregate-door.ts, called in ObjectQL.aggregate right after the aggregate × field-type door: sum / avg / min / max over a record-mode amount answers INVALID_FIELD / 400 unless groupBy names its code field (string or { field } entry). The message leads with the prescription (group by the code field, or aggregate a stored home-currency amount). count and count_distinct are not judged. No new wire code: ADR-0144 assigns ADR-0112 codes only to the blank-code and missing-rate refusals.
  • Summary roll-up refused at registration (ADR-0144 D6). SchemaRegistry.registerObject refuses, before any materialization, a body whose summary field rolls up sum / avg / min / max of a record-mode child field — or a child body declaring a record-mode field that a registered parent already rolls up that way. INVALID_METADATA / 422 (RecordCurrencySummaryRefusedError), judged at whichever registration completes the pair; an end not yet registered is no verdict.
  • Rider, declared — from spec(field): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1, epic #8345) #27184's class-c finding. packages/formula/src/formula-return.ts: currencyOf mapped every non-fixed config to dynamic, so inferFormulaReturn would stamp a formula over a record-mode amount as a tenant-default amount. It now carries currencyConfig: { currencyMode: 'record', currencyField } (the shape spec(field): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1, epic #8345) #27184's object refinement already judges on a formula). Two record-mode amounts are one currency only when they name the same code field; record vs fixed / dynamic is not one currency.
  • File-surface addition, approved by the epic PM: packages/qa/dogfood/test/record-currency-probes.test.ts (test only, private package, no changeset).

Tests

  • packages/objectql/src/record-currency-guards.test.ts (new, 13 cases): the blank-code refusal on insert (four blank spellings), by-id update (clear code / amount onto a row without one / legacy row untouched), predicate update (one matched row without a code refuses the whole write, naming the record); the validate() preview, insert and by-id update, answers the same finding, with the stored-code and legacy-row controls valid; the aggregate door for all four functions, grouped-by-other refused, grouped by the code answers USD 100 / EUR 100 (string and object groupBy), count and a fixed-currency sum not judged; the summary refusal parent-second (all four functions) and child-second, count and a non-record child field register. Every thrown refusal asserts code + status + httpStatus; the preview's finding asserts field + code + message.
  • packages/qa/dogfood/test/record-currency-probes.test.ts (new): PROBE-A, the write side and PROBE-R on SqlDriver over better-sqlite3 and on SqliteWasmDriver, 6/6. ⛔ No driver-memory arm: its test consumers are a closed census (check:driver-memory-census) and the project's test backends are sqlite :memory:; the in-memory aggregation face is the objectql suite above.
  • packages/formula/src/formula-return.test.ts: a new describe for the record currency (stamped with its code field; ratio of two same-currency amounts is not money; three cross-currency mixes are not money).

Acceptance notes

Verification

Code readings on 2749517c, the last commit that changes this PR's code. Formula, spec and platform-objects readings are on cf5be508; nothing after it touches those packages' code (the one spec change is a ledger comment). On the head, 9d9f7eab (main f7188b56 merged in with a merge commit; no regeneration was needed), the guards, the probes, check:generated and the derived gates were re-read; see each section.

Tests
  • pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/record-currency-guards.test.ts: Test Files 1 passed, Tests 13 passed (13), on 2749517c and again on 9d9f7eab.
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/record-currency-probes.test.ts: Test Files 1 passed, Tests 6 passed (6), on both SQLite drivers, on 2749517c and again on 9d9f7eab.
  • Full @objectstack/objectql local project: Test Files 479 passed (479), Tests 8951 passed (8951).
  • Full @objectstack/formula: Test Files 60 passed (60), Tests 1563 passed (1563).
  • @objectstack/spec src/data/currency-config-record-mode.test.ts (spec(field): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1, epic #8345) #27184's refinement, which the guards read): 15 passed. Full @objectstack/platform-objects: 1369 passed (97 files).
  • pnpm --filter @objectstack/objectql typecheck (tsc + check:test-typecheck): exit 0. @objectstack/formula typecheck at cf5be508: exit 0.
  • The dogfood package typecheck was not measured, because its closure is unbuilt in this container. No error line names the new file.
Ablation (one-shot; suite imports src, no build leg)

scripts/ablation-replace.mjs --delete on the committed tree, run against record-currency-guards.test.ts. Each time the restore was verified (blob == HEAD) and git diff HEAD was empty.

  • The assertRecordCurrencyAggregatesGrouped call in engine.ts: 2 failed / 10 passed (read at cf5be508, 12 cases).
  • The insert assertRecordCurrencyCodes call in engine.ts: 1 failed / 11 passed (read at cf5be508, 12 cases).
  • The throw recordCurrencySummary in registry.ts: 2 failed / 10 passed (read at cf5be508, 12 cases).
  • The preview's guard in engine-validate.ts (the if (mode === 'insert' || storedRows[i]) arm made false): 1 failed / 12 passed, at 2749517c.

The by-id and predicate update call sites were not ablated separately.

Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 9d9f7eab, change set = this PR's 14 paths against merge base f7188b56: 111 families derived, all 111 run. The --ran reconciliation reads 109 run, 2 NOT-MEASURED, 0 UNRUN.

  • NOT MEASURED (exit 3, prerequisite not met in this container): check-plugin-teardown-shape --self-test (shallow clone) and spec check:skill-examples (the client SDK dist predates the merged main). CI runs both and is green.
  • Red on the first sweep and fixed in this PR:
    • check:docs / check:generated: the reference pages lacked the new ledger code, so they were regenerated.
    • check:objectql-double-limit and check:query-options-erasure: the new test's driver double and option casts were fixed.
  • pnpm --filter @objectstack/spec check:generated on 9d9f7eab, against a spec dist built by that run: all 14 artifacts current.
  • CI on 9d9f7eab: 50 check runs, 41 success, 9 skipped, 0 failure. Check Changeset, Test Core (all 8 shards) and every Type Check and Lint job pass. GitHub reports the PR clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt

claude added 13 commits October 28, 2026 12:54
…unt points at a sibling ISO 4217 code field

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…ent in the dropped-refinements ledger

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…field naming convention, paid by tightening the currency block

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…cord-mode currencyField

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…, ungrouped raw aggregate refused, summary roll-up refused at registration

ADR-0144 D6 and D8 rule 4. A record-mode amount beside a blank currency code
is refused ERR_CURRENCY_CODE_REQUIRED (registered in the ADR-0112 ledger);
sum/avg/min/max over a record-mode amount refuses INVALID_FIELD unless grouped
by its code field; a summary roll-up reading one refuses INVALID_METADATA at
registration. Rider: inferFormulaReturn carries record mode and its
currencyField for a formula over a record-mode amount.

Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt
Co-authored-by: Claude <noreply@anthropic.com>
…ns and hold the find double's limit

Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt
Co-authored-by: Claude <noreply@anthropic.com>
…ore copying rows

Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt
Co-authored-by: Claude <noreply@anthropic.com>
…e field-level answer never reads a code from a record-mode config

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…urrencyField row

The field form's new `currencyConfig.currencyField` row is one more
unwalked composite child (10 -> 11) and one more translated row label
in each locale's positive control (695 -> 696).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
… default

Under `currencyMode: 'record'` each record names its own code in the
sibling field, which only a per-row renderer can read. The field-level
answer is now `undefined` outright, so a record with a blank code shows
no symbol instead of a currency it never named.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
The authorable-surface baseline is taken from main (#27233 retired
`CurrencyValue`) and rebuilt, keeping `currencyConfig.currencyField`;
the field reference page is regenerated from the merged tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
…roject-thread-1v9hrc

# Conflicts:
#	packages/formula/src/formula-return.ts
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cf5be50823a3fe04663b7d8d4be6f482519cfa92
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + at the head: packages/objectql/src/validation/record-currency-code.ts, packages/objectql/src/record-currency-aggregate-door.ts, packages/objectql/src/engine.ts (insert 15389, by-id update 17110, predicate update 17442, aggregate 20377), packages/objectql/src/registry.ts (3623–3636), packages/objectql/src/engine-validate.ts, packages/formula/src/formula-return.ts, .changeset/27186-record-currency-guards.md, content/docs/references/api/{error-code-ledger,contract,automation-api}.mdx, packages/objectql/src/record-currency-guards.test.ts, packages/qa/dogfood/test/record-currency-probes.test.ts; on main bbb49c9f: docs/adr/0144 (D2, D6, D8, D9, Open items 9, 10, 12), docs/adr/0112 (D3, D4, D5), packages/objectql/src/aggregate-field-type-door.ts, packages/objectql/src/summary-aggregate.ts, packages/spec/src/data/field.zod.ts (summaryOperations, 1909), packages/spec/src/data/query.zod.ts (GroupByNodeSchema), packages/types/src/thrown-http-error.ts, scripts/check-changeset-no-major.mjs. This card's diff is b6f152bd...cf5be508 (13 files, +1010/−15); the formula test is the lane seat's ACCEPT at TIER_DEFAULT. The 20 other files in the PR's file list are #27268's, reviewed there (records 6487611079, 6489126506), not re-judged here.

Gates on cf5be508: the ten required contexts all success. Check Changeset (advisory) is red twice (05:57Z, 06:50Z); the cancelled 06:48Z run is superseded, not a finding. The stack explanation is true from the tree: the gate reads the kind off the PR body and diffs packages/spec/authorable-surface/ from merge-base(main, head) = 73725c20; the one moved key, data/CurrencyConfig:currencyField, is in b6f152bd (#27268) and absent from this card's diff, which moves no baseline. The same merge-base diff also carries .changeset/27184-currency-record-mode.md (added, three minors), a second reason the body's none is refused and also #27268's. Both clear when #27268 lands and main is merged in. The job log itself returned 403; judged on the gate's source and the tree.

① Derived judgments

  • Ledger: ERR_CURRENCY_CODE_REQUIRED under @objectstack/objectql, in order between ERR_CROSS_DATASOURCE_TRANSACTION_WRITE and ERR_DATASOURCE_UNAVAILABLE, comment naming 400, the thrower class and file, field = the code field, the D8 rule 4 sentence — right. ADR-0144 (line 14, Open item 9) names this code as one of exactly two ledger additions; the ERR_ prefix is the objectql block's convention; standardSynonymOf has no member holding all four tokens; the provenance gate's owner is the stamping package. ErrorCode / ApiErrorSchema.code widen by one member; the three reference pages say +348 more and list it — right. check:api-surface records existence, not members — nothing stale.
  • Thrower RecordCurrencyCodeRequiredError: code, status: 400, httpStatus: 400 (ADR-0112 D5), field (first blank code field), fields, amountField, object; the message names the row, the prescription (write the ISO code in the same write, or leave the amount empty) and that the server never fills one in — right. resolveThrownHttpError reads .status first and keeps a registered .code, so both doors answer 400 + the code verbatim.
  • Blank-code refusal on write, judged on the row IMAGE — right (D2, D8 rule 4). Insert: rows[i] after applyFieldDefaults (14644) and validateRecordInScope (15386), before driver.create / bulkCreate (15502–15505); insertMany delegates to the same loop (15760). By-id: priorRecord ⊕ final payload; priorRecord is always read (16440) and a missing row 404s before the guard. Predicate: every matched row ⊕ payload through the readPriorRows memo, armed on every predicate path (16580), read only when touchesRecordCurrency. Blank = absent, null, white space; 0 is a value; a non-ISO code is the valueDomain validator's refusal. Only pairs the update payload carries a key of are judged — right, the never-fabricate posture the by-id path already takes (③ for the pre-guard row).
  • Aggregate door — right (D6, Open item 10). sum/avg/min/max over a declared record amount; groupBy string or { field } naming the code field admits; else INVALID_FIELD / 400 with field, fields, object, param: 'aggregations', the type door's envelope to the letter, prescription first. count and count_distinct are not judged — right. No new code — right (ADR-0144 line 14). Placed after assertAggregationFieldTypesAccepted in ObjectQL.aggregate (20377), before any driver, so REST, GraphQL, flows, the summary recompute (summary-aggregate.ts:111) and the analytics strategy all pass it. A formula the rider stamps record is not judged here (type !== 'currency') and need not be: the type door refuses sum × formula on every backend (its table, row 45).
  • Summary refusal at registration — right (D6). Reads summaryOperations.{object, field, function} as the spec spells them after alias resolution (field.zod.ts:1909); sum/avg/min/max over a child record amount, judged at whichever registration completes the pair, self roll-up included; INVALID_METADATA / 422 with name as the discriminator, the registry's own envelope (registry.ts:151, :233); count registers. A roll-up over a formula child stamped record is not judged at registration; its recompute goes through engine.aggregate, where the type door refuses it — loud, no finding.
  • Formula rider — right (D2). currencyOf answers { mode: 'record', field }, sameCurrency compares code fields, inferFormulaReturn stamps { currencyMode: 'record', currencyField } — the exact shape feat(spec): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1) #27268's object door judges on a formula; record beside fixed, dynamic or another code field is not money. FormulaReturnDeclaration.currencyConfig (exported, index.ts:173) widens by one union member: a public TS surface widening.
  • Nothing on main is refused now: every guard keys on currencyMode === 'record', which main's field.zod.ts does not accept; the fixed-currency and count controls pin it.

② Semver level

Changeset 27186-record-currency-guards.md: objectql, formula, spec patch; Clause-②: no; Metadata-protocol: none; the same two lines on the body.

③ Boundary flags

④ Design judgment — packages/spec/src/api/error-code-ledger.zod.ts

Holds against ADR-0112 D3 and D4 and the ledger's own admission rules, and against ADR-0144 Open item 9 and line 14: one row under the stamping package, SCREAMING_SNAKE with the block's ERR_ prefix, a comment stating the status, the thrower and its file, what field carries, and the D8 rule 4 sentence that explains why the code exists. Not a standard synonym (MISSING_REQUIRED_FIELD is the nearest member and shares one token), and the ADR assigns the dedicated code deliberately so a client that pre-fills the code can branch on it. Its thrower agrees on every claim (400, field = the code field, refused before the write), and its consumers — the dispatcher and REST doors through resolveThrownHttpError, the generated catalog pages — carry it verbatim. Tried and not broken: 0 beside a blank code (refused, right), a white-space code (refused), blank amount beside blank code (admitted), two amounts sharing one code field (one fields entry), a code the valueDomain refuses (that validator's refusal, not this code), an amount written onto a row whose stored code covers it (admitted), an unrelated edit of a codeless row (not judged). Wording nit, no fix owed: "refused before the driver is asked" — both update paths ask the driver for the prior row(s) first; the write is what is never asked.

No must-fix. Should-fix before landing: ② the Clause-② arm on the body and the changeset. Should-fix follow-up: ③ preview parity. Escalated: the mode-switch backfill question.

Implemented-by: claude/project-thread-1v9hrc
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

VERDICT: PASS

… code; Clause-② declared as a widening

The insert and by-id update previews now judge the write's blank-code
refusal on the write's own image (the insert row, or the stored row with
the patch over it) and answer it as the row's ERR_CURRENCY_CODE_REQUIRED
finding, so a dry run no longer passes a row the write refuses
(changeset 26684's parity). The changeset declares Clause-②: yes
(widening): the ledger gains a registered code and the formula rider
widens the exported FormulaReturnDeclaration. The ledger comment says
the write is never asked, not the driver.

Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt
Co-authored-by: Claude <noreply@anthropic.com>
The authorable-surface baseline is taken from main and rebuilt, keeping
`currencyConfig.currencyField`; the API reference pages are regenerated
from the merged tree.

Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract-review-claim: session_01Utp6CbTZrFqRYNgjSaSbN5 · head 45e6b26 · 2026-10-29T08:52Z (delta on cf5be50, same reviewer)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 45e6b2614f93a4a5d014e2341ad5d5f0c8ed74ea
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + .changeset/27186-record-currency-guards.md, packages/objectql/src/engine-validate.ts, packages/objectql/src/record-currency-guards.test.ts (the own diff cf5be508..2749517c, 4 files, +58/−6); the merge 31342862 (main 7614f8f0) and the regen 45e6b261 (the three content/docs/references/api mdx files, packages/spec/authorable-surface/data.json); the live PR body; on the head: packages/objectql/src/engine.ts (by-id 17110, predicate 17442, readUpdatePriorRow), packages/objectql/src/validation/record-currency-code.ts, packages/metadata-protocol/src/protocol.ts (validateData, 13742), packages/spec/src/api/protocol.zod.ts (ValidateDataIssueSchema, 2544); on main: scripts/check-changeset-no-major.mjs, the landed yes (widening) + patch changesets. Delta of the PASS record on cf5be508: everything that record judged and this diff does not touch stands as judged there.

Gates on 45e6b261 at 08:58Z: Lint & Repo Gates, Governed Surface Queue Guard, Build Core, Lint UI, Build & E2E UI, Live E2E UI success; Type Check UI in_progress; TypeScript Type Check, Test Core and Test UI not yet reported (their shards are running; runs started 08:47–08:52Z); nothing failed or cancelled among the ten. Check Changeset red twice (08:47Z, 08:52Z), still the inherited reason: merge-base(7614f8f0, head) carries #27268's shard key and its added / three-minor changeset; this card's own pair yes (widening) / none / patch is the shape of three landed changesets (26506, 26668, 26474). Landing waits for the ten green as the Tier S rule says; this record does not stand in for them.

① Derived judgments

  • Preview parity — right, and honest against the write path. Insert: the guard judges rows[i] after applyFieldDefaults (193–196), the runtime-owned and static readonly strips (273–294) and validateRecordInScope (549) — the write's own image at the write's own step (engine.ts 15386–15389). By-id update: judgedViews[i] = storedRows[i] ⊕ row, where row is the patch after stripReadonlyFields (303) and storedRows[i] is the write's own readUpdatePriorRow read filtered to the columns the read door served this caller as stored — the write's priorRecord ⊕ payload (17110–17114), with touched = the stripped patch, so only pairs the patch carries a key of are judged, and the record id in the message. A refusal becomes the row's finding { field, code: ERR_CURRENCY_CODE_REQUIRED, message } inside judgeRow's try, after ValidationError, so the other rows of a batch are still judged (test: [false, true, true]); ValidateDataIssue.code is z.string() (ADR-0112 D6, a field-level vocabulary), so the ledger code there is a finding, not a wire envelope. validateData (protocol.ts:13744) returns engine.validate, so the REST door gets the same answer.
  • Withheld-column case — stricter, never laxer, as declared. A code column the read door withheld or masked is absent from storedRows[i] (the servedAsStored filter, 410–417), reads blank, and the preview refuses an amount patch the write (raw prior row) admits. That is the module's standing posture for every hidden column ("judged as empty"), and the finding names the field, which is metadata, never the value. Declared in the code comment, the body and the changeset.
  • Residuals, all declared and all the preview's standing limits, not this card's: a predicate dispatch is not judged (no matched rows are held; judging the patch alone would refuse every amount patch — the right call); an id the caller cannot read has no image (the write 404s); the readonlyWhen strip and beforeUpdate hooks do not run in the preview (the named ValidateDataResponse limit, 248–251), so a readonlyWhen-locked code field with a blank stored value and a patch supplying a code is admitted here and refused by the write — one corner, in the preview's declared gap, shared with every rule it judges.
  • Ledger comment: "the driver is never asked to write the row" — the nit closed; still cites the thrower and file, field semantics, D8 rule 4. No other change to the row.
  • Nothing on main is refused now — unchanged; the merge brought no main-side change to the guarded paths (card files byte-identical between 2749517c and the head; the five engine.ts calls and the registry call all present).

② Semver level

Changeset and live body: Clause-②: yes (widening), Metadata-protocol: none, objectql / formula / spec patch — right, and the three agree. The widening is the ledger face (the #16404 rule in the ledger header) and the exported FormulaReturnDeclaration union; neither is a key, value or type an author writes, so none; under ruling A none ⇒ patch, and the gate's cross-check (added cannot sit under no; narrowed needs the arm) is satisfied by yes/none. The changeset body now also names the preview's refusal, scoped to insert and by-id. No Skills-docs: line is owed (none, no narrowing arm).

③ Boundary flags

④ Design judgment — packages/spec/src/api/error-code-ledger.zod.ts

One comment line changed; the row, its position, its thrower and its consumers are as judged on cf5be508. The sentence now states exactly what both update paths do (the driver reads the prior row and is never asked to write). Holds. No finding.

No must-fix; no should-fix open on this head.

Implemented-by: claude/project-thread-1v9hrc
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

VERDICT: PASS

45e6b2614f93a4a5d014e2341ad5d5f0c8ed74ea

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract-review-claim: session_01Utp6CbTZrFqRYNgjSaSbN5 · head 9d9f7ea · 2026-10-29T10:27Z (delta on 45e6b26: main merge after #27268 landed, same reviewer)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9d9f7eab823e168acfc64ad2b7618bfe103d7251
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + the 13 other files of the diff against the merge-base f7188b56 (this card's 14 files, +1062/−15), the merge commit 9d9f7eab (parents 45e6b261, f7188b56), the live PR body, .changeset/27186-record-currency-guards.md at the head; on main: the commits 41452103..ded67bfb (#27305, #27426, #27470, #27476, #27468) read against this card's surfaces. Delta of the PASS records on cf5be508 and 45e6b261: the card's patch is unchanged, so everything those records judged stands as judged there.

Gates on 9d9f7eab at 10:30Z: 54 check-runs, 43 success, 11 skipped, none failed, cancelled or pending. Of the ten required contexts nine are success and Lint UI is completed/skipped — the filter job's verdict on a diff with no ui/ path (the earlier heads lint-ran ui/ only through #27268's i18n files). Check Changeset success twice (09:51Z, 10:27Z): with #27268 on main the merge-base diff moves no authorable baseline and carries no added changeset, so the body's yes (widening) / none beside three patch entries is the legal pair. GitHub reports mergeable: true, clean. No governed path remains in the file list (skills/** left with #27268), so the PR is Tier S on the ledger's schema face alone, and this record is the one it owes.

① Derived judgments

② Semver level

Live body (v4) lines 8–9: Clause-②: yes (widening), Metadata-protocol: none; changeset at the head: objectql / formula / spec patch, the same two lines — in agreement, and right as judged on 45e6b261. #27268's changeset is on main and out of this diff; the only changeset in the diff is this card's. No Merge-after line and no stacked wording remain on the body; the R1 ⛔ not released alone pairing line stays, as D9 requires.

③ Boundary flags

④ Design judgment — packages/spec/src/api/error-code-ledger.zod.ts

Unchanged since 45e6b261; the row, its thrower and its consumers are as judged there. Holds. No finding.

No must-fix; no should-fix open on this head.

Implemented-by: claude/project-thread-1v9hrc
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

VERDICT: PASS

9d9f7eab823e168acfc64ad2b7618bfe103d7251

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

席位记录 · epic PM epic:#8345 · 2026-10-29T10:33Z

Head: 9d9f7eab823e168acfc64ad2b7618bfe103d7251 · Session session_01Utp6CbTZrFqRYNgjSaSbN5

维护者速读

席位意见:可以合。契约复审在 cf5be508 上 PASS(6490309706,两条建议),建议项修好后在 45e6b261 增量 PASS(6492408981);#27268 合入后并入 main,本卡 14 个文件逐字节不变,复审人在本 head 上增量 PASS。

席位核对

ACCEPT · epic PM epic:#8345 · head 9d9f7eab

ACCEPT → this comment.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 10:35
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 10:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 57c51aa Oct 29, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-1v9hrc branch October 29, 2026 10:57
os-bill pushed a commit that referenced this pull request Oct 29, 2026
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 protocol:data size/xl tests tooling

Projects

None yet

2 participants