Repository navigation
fix(plugin-detail): record:history refuses a row cap the contract rejects instead of repairing it - #10093
Conversation
…d of repairing it `@objectstack/spec` declares `RecordHistoryProps.limit` a positive integer (`z.number().int().positive().optional()`). The renderer derived its cap with `Number(...) || 50` and consumed it as `Math.max(1, limit)`: `||` caught `0`, `NaN` and `''`, but a negative is truthy and survived it, so `Math.max` REPAIRED an authored `-5` into a one-row window, and a fractional `2.5` went to the adapter untouched as `$top`. Neither named a cause anywhere. One resolver now answers the question — `normalizeHistoryLimit` admits only a positive integer and drops anything else for this block's own default of 50 — and the wire consumes it directly. That is the answer the sibling `record:activity` already gives through `normalizeLimit`, silence included; the two blocks' differing defaults (50 and 20) are untouched. Pinned over the sibling's whole value set plus the two it does not cover, a non-integer and the pass-through control, at the `$top` that reaches `DataSource.find()` rather than at a helper — a correct normalizer is defeated by a `Math.max(1, …)` reappearing at the call site, and only the wire sees both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
The blanket `expect(warn).not.toHaveBeenCalled()` pinned test ORDER rather than this renderer's silence. MEASURED: rendering the timeline emits an unrelated `react-i18next` `NO_I18NEXT_INSTANCE` warning, and whether it lands inside the spy's window depends on which other tests in the file ran first — the assertion was green for the whole file and red for anyone running that one test alone. The control now filters the spy's calls down to ones ABOUT this renderer's row cap. Both legs re-measured: the pin is green run alone as well as in file order, and an injected `console.warn` on the refusal branch still turns it red with the injected call — not the i18next noise — as the reported value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #10005
Clause-②: no — this PULLS BACK to the declared contract (
@objectstack/specdeclares the member a positive integer). It relaxes no accept set and widens no public surface; it narrows what a renderer forwards to values the contract already permits.record:historywas the one renderer in the row-cap family that neither honoured nor refused a cap the contract rejects: it turned one into a one-row window, or forwarded a fractional one. It now refuses and falls back to its own default — the answer its siblingrecord:activityalready gives.The measurement, re-taken here — not read back from the card
Both columns are REAL CODE, not a simulation of the expressions: the
record:historycolumn is the$topthat reachedDataSource.find()from the rendered renderer, and therecord:activitycolumn is that module's exportednormalizeLimitcalled directly. One harness, run twice; the only thing that differed between the runs is the renderer's own bytes (blob verified in the same command as each run).limitrecord:historyto$top— BEFORErecord:historyto$top— AFTERrecord:activitywindow (untouched)0-52.5'lots'10⭐ The last row is the harness's own control: a usable cap passes through unchanged on both renderers, before and after. It does not move — so the two rows that DO move are readings, not a dead harness. The card's BEFORE column reproduced exactly, control included.
Two pre-declared STOP legs, both checked before any edit:
schema.limit. There is none. Everylimitoccurrence in the unmodified renderer was the optional member on its props interface, the one derivation, the one$top, and the effect dependency list — nothing between them.origin/main(81e12e186f), not from the card.RecordHistoryProps.limitisz.number().int().positive().optional(), described there as "Maximum history entries displayed, and the$topof the self-fetch query (renderer default: 50)".And the zero this card rests on, with a control lit by the same search: nothing in the tree pinned
record:history's answer. The two test files that import the renderer assert nothing aboutlimitor$top; the same two-step search run against the sibling lights up six files. A zero that is a reading.What changed
One resolver —
normalizeHistoryLimit— admits a positive integer and drops anything else for this block's own default of 50. The wire consumes it directly.Math.max(1, limit)is GONE rather than kept behind the resolver. It is the defect, not a belt: it REPAIRED a refused-5into a one-row window and forwarded a fractional2.5untouched.record:historystays 50,record:activitystays 20. The pin spells 50 locally so a later drift fails instead of passing.'5'answers5here, asnormalizeLimit('5')is pinned to5there). What narrows is the admitted value SET, not how a node's value is read.The refusal is SILENT — decided by measurement, and the conflict is reported, not buried
The instruction was to match the sibling, and the sibling is silent: its
normalizeLimitrefuses with no diagnostic at all. So this renderer refuses silently too, and the silence is pinned.object-kanban,object-timeline, andrecord:reference_rail— that last one in this very package): they refuse LOUDLY, naming the authored value on the developer channel. The family therefore holds two answers on loudness. This PR follows the instruction it was given and settles nothing; the pin that carries the silence says so in as many words, so whoever rules that question changes it deliberately rather than discovering it.Evidence
RED before, GREEN after — file state verified in the SAME command as each run.
RED, pins written FIRST and run against the unmodified renderer (its blob verified equal to the base blob
ed735a66…, andgit diff --statempty, in that same command):The 7 that passed RED are the values the old derivation already answered correctly (absent,
0,NaN, a non-numeric string, the pass-through control, a numeric string, and the honouredpropertiespath) — stated rather than hidden, because a pin set whose every member fails is usually measuring the harness.GREEN, after the fix — renderer blob
0f97ccb4…(differs from base), sibling blob verified UNMOVED at5ab2ef32…in the same command:record-historyenvelope pin:Test Files 2 passed (2)·Tests 13 passed (13)@object-ui/plugin-detailsuite:Test Files 191 passed (191)·Tests 1889 passed (1889)pnpm --filter @object-ui/plugin-detail run type-check(it echoestsc --noEmit && tsc -p tsconfig.test.json, so the pin file is type-checked too): shared-lock VERDICTcommand-exit 0pnpm --filter @object-ui/plugin-detail run lint: exit 0, 0 errorsAblation, on the committed state — mutation AND restore proven by object hash, never by an exit code. A
console.warnnaming the authored value was injected into the refusal branch through an anchored replace that asserts its anchor matched (a zero-hit edit would otherwise exit 0 and read as a no-op), behind anEXIT INT TERMtrap restoring withgit checkout HEAD --at an absolute path:0f97ccb4…todb578e0d…, and the run stops if it did not0f97ccb4…= the HEAD blob, marker count 0,git diff HEADemptyThat ablation found a defect in my own pin. The silence control began as a blanket
expect(warn).not.toHaveBeenCalled(). The ablation's output showed the spy ALSO catching an unrelatedreact-i18nextNO_I18NEXT_INSTANCEwarning — so whether it passed depended on which other tests in the file had rendered first: green for the whole file, red for anyone running that one test alone, and pinning test ORDER rather than this renderer's silence. The control now filters the spy down to calls about this renderer's row cap, and all three legs were re-measured: green in file order, green alone, and still red under the injected diagnostic with the injected call as the reported value.Gates
Green here:
check-changeset-presence(its own verdict line; a realpatchbump, decided by RUNNING the gate — it was red before the changeset and names the file after),check-control-bytes,check-new-cross-file-line-citations(0 added by this branch, with that gate's six internal controls passing, so the zero is a reading),check-component-surface-parity(report-only, exit 0, names nothing in this diff), plus the packagetest/type-check/lintabove.type-checkattempt exited 2 withCannot find module '@object-ui/components'across the package — a PRECONDITION NOT MET (the dependency closure was unbuilt), not a failure. Re-run properly afterpnpm --filter '@object-ui/plugin-detail^...' build; the green above is that second run.check-sdui-registration-pinsexits 2 withNo console build to weigh at apps/console/dist/assets— EXCLUDED, precondition not met: it needs anapps/consolebuild, which CI's performance-budget workflow does. This diff registers nothing and adds no import.Declared narrowing. Repo-wide
pnpm lint,pnpm testandpnpm checkare CI's runs, not this branch's. What makes the lint narrowing a measurement rather than a skipped step: the population comes from eslint's own config (249 files in the affected package, counted from its--format jsonoutput, 0 errors), and no type-aware linting is configured — the root flat config sets noparserOptions.projectand noprojectService, and the package carries no local config — so this diff cannot move the verdict on a file it did not touch. On the test side the package's public surface is byte-identical (the resolver is module-private, no export moved), so import-side packages owe nothing beyond CI's full sharded run.Acceptance notes
Found while measuring, NOT fixed here, no label applied, no card opened by me — for the landing seat to dispose of:
record:activity'snormalizeLimitFLOORS a non-integer instead of refusing it:normalizeLimit(2.5)answers2, and the same member is declaredz.number().int().positive().optional(). That is the same "repair a value the contract refused" shape this PR removed fromrecord:history, one renderer over, and its pinned set (0,-3,undefined,'lots') does not cover a non-integer — which is exactly how this one survived. Named probe: its own exported function. Untouched here becauserecordActivityFeed.tsis read-only under this order. Dedupe words:normalizeLimit non-integer floor·record:activity fractional limit·Math.floor refused row cap·plugin-detail limit int parity.record:history(this PR) andrecord:activity, loud at objectui#9925's three read points. One ruling settles both; until it exists the pin here records the choice instead of leaving it to be discovered. Dedupe words:row cap refusal loudness family·describeRefusedRowLimit parity·record:history silent refusal·refuse-and-warn family answer.Session, in prose so it survives a body rewrite: this branch was implemented in
session_018HrVaotisyhgmot9o2MLRq.Generated by Claude Code