Repository navigation
finding(plugin-detail): record:history turns a refused row cap into $top: 1 or a fractional window, while record:activity already refuses it #10005
Description
Activity
Claim: PM loop — 2026-09-20 shift, batch slot 4/5
Session:session_018HrVaotisyhgmot9o2MLRq
Branch:claude/issue-10005-record-history-limit-refusal
Worktree:objectui-issue-10005
Domain:domain:ui(triage's; ⛔ not re-routed here)
Seat:domain:ui#2
File surface:packages/plugin-detail/src/renderers/record-history.tsxand its tests underpackages/plugin-detail/src/renderers/__tests__/. ⭐recordActivityFeed.tsand its test are the MODEL to read, ⛔ not a file to edit — the sibling already refuses-and-defaults and its behaviour is what this card asksrecord:historyto match.
Container & model: S,mode:subagent,model: opus. ⛔ No tier output is quoted:scripts/pm/dispatch-gates.mjsREFUSES for this repo, so the tier is stated, not read back.
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.
Hot-file serial queue: no intersection with the three cards in flight this moment — #10043 (apps/console+ onepackages/app-shellfile), #10002 (packages/plugin-map), #10020 (packages/plugin-report). Checked locally against the in-flight claims rather than by a new query.Claimed at 2026-09-20T11:44Z.
Generated by Claude Code
Dispatched to an
os-devsubagent (mode:subagent,model: opus) at 2026-09-20T11:41Z, batch slot 4/5. This comment is the order's record.The order's FIRST instruction is a re-measurement, ⛔ not an edit
The card's six-row comparison table is to be re-derived from the real expressions on today's
origin/main— ⛔ not read back from the card. ⭐ Its last row is its own control (limit: 10passes through unchanged on both renderers), so a table where THAT row moves means the harness is wrong rather than the code.Two legs are pre-declared STOPs: an earlier guard on
schema.limitbefore the derivation (the card explicitly says there is none, and if one exists its whole reading falls), and the spec's declaration —z.number().int().positive().optional()— which is to be read at source in objectstack, ⛔ not from the card.Two traps named in the order
- ⛔
Math.max(1, …)may not survive as the guard. It IS the defect: it REPAIRS a refused value into a one-row window instead of refusing it, and it forwards a non-integer untouched. - ⛔ The default may not move.
record:historydefaults to 50 andrecord:activityto 20; they differ on purpose, and unifying them is ⛔ not this card. If the dev thinks they should be unified it reports that — it does not do it.
Whether the refusal is also LOUD is decided by matching the sibling, ⛔ not by inventing a diagnostic channel this family does not have.
⭐ The pin is the point of the card
The card's sharpest observation is that
record:activity's column is pinned andrecord:history's is not — which is why only one of the two rotted. The order therefore requires pins over the sibling's value set (0, a negative,undefined, a non-numeric string) PLUS the two it does not obviously cover — a non-integer and the pass-through control — written FIRST and shown failing against the unmodified renderer.recordActivityFeed.tsand its test are declared READ-ONLY: the model to copy, ⛔ not files to edit.Landing is this seat's
⛔ No label, ⛔ no ready flip, ⛔ no auto-merge, ⛔ no MCP GitHub write tool. The PR stays DRAFT and carries
Fixes #10005, with the re-measured table before and after in its body.
Generated by Claude Code
- ⛔
os-dev-report
{ "issue": 10005, "status": "done", "branch": "claude/issue-10005-record-history-limit-refusal", "pr": "https://github.com/objectstack-ai/objectui/pull/10093", "session": "session_018HrVaotisyhgmot9o2MLRq", "premise_still_valid": true, "summary": "`record:history` now refuses a row cap the contract rejects and falls back to its own default of 50, through one module-private resolver (`normalizeHistoryLimit`: positive integer admitted, everything else dropped); `Math.max(1, limit)` is gone from the wire rather than kept behind it. The card's six-row table was re-derived from real code before any edit and reproduced exactly, control row included, and both pre-declared STOP legs held: there is no earlier guard on `schema.limit` (the unmodified renderer's only `limit` occurrences are the props member, the one derivation, the one `$top` and the effect dependency), and `RecordHistoryProps.limit` reads `z.number().int().positive().optional()` at source in objectstack on origin/main 81e12e186f, described there with 'renderer default: 50'. Defaults untouched (50 here, 20 on the sibling) and `recordActivityFeed.ts` untouched (its blob verified unmoved at 5ab2ef32 in the same command as each run). The refusal is SILENT because the sibling this order named is silent; the three read points objectui#9925 landed refuse loudly, so the family holds two answers on loudness — reported, not settled, and the pin says so. PR 10093 is DRAFT with auto_merge null and carries NO `needs:contract-review`; the labels on it (`plugin`, `tests`) were applied by the repo's own labeler workflow, not by this seat — reported, not touched.", "tests": "MEASUREMENT (first action, before any edit): rendered renderer -> `$top` at `DataSource.find()` for record:history, exported `normalizeLimit` called directly for record:activity. BEFORE absent/0/-5/2.5/'lots'/10 = 50/50/1/2.5/50/10 vs sibling 20/20/20/2/20/10; AFTER = 50/50/50/50/50/10, sibling unchanged; the control row 10->10 does not move in either run. RED: pins written first, run against the unmodified renderer with its blob verified equal to the base blob ed735a66 and `git diff --stat` empty in the SAME command -> `Tests 4 failed | 7 passed (11)`, failures `expected 1 to be 50` (negative), `expected 2.5 to be 50` (non-integer), `expected 1 to be 50` (properties path). GREEN, renderer blob 0f97ccb4 and sibling blob 5ab2ef32 printed in the same command: pin file + pre-existing envelope pin `Test Files 2 passed (2)` / `Tests 13 passed (13)`; whole @object-ui/plugin-detail suite `Test Files 191 passed (191)` / `Tests 1889 passed (1889)` (under the shared verify lock, VERDICT batch-last-exit 0); `pnpm --filter @object-ui/plugin-detail run type-check` (echoed `tsc --noEmit && tsc -p tsconfig.test.json`) lock VERDICT command-exit 0; `pnpm --filter @object-ui/plugin-detail run lint` exit 0, 0 errors over 249 files counted from `--format json`. ABLATION on the committed state, anchored replace that asserts its anchor matched, `trap EXIT INT TERM` restoring `git checkout HEAD --` at an absolute path: marker count 0 -> 1, blob 0f97ccb4 -> db578e0d (run aborts if unchanged), silence pin RED with the INJECTED call as the reported value; restore proven by blob back to 0f97ccb4 = HEAD blob, marker count 0, `git diff HEAD` empty — never by an exit code. That ablation exposed a defect in my own pin: the blanket `expect(warn).not.toHaveBeenCalled()` also caught an unrelated react-i18next NO_I18NEXT_INSTANCE warning, so it pinned test ORDER (green in file, red run alone); the control now filters to calls about this renderer's row cap and was re-measured on three legs (green in file, green alone, red under the injected diagnostic). GATES green: check-changeset-presence (red before the changeset, green after, quoting its own verdict line), check-control-bytes, check-new-cross-file-line-citations (0 added, its six internal controls PASS), check-component-surface-parity (report-only exit 0, names nothing in this diff). NOT A VERDICT, recorded as such: the first type-check attempt exited 2 on `Cannot find module '@object-ui/components'` = PRECONDITION NOT MET (dependency closure unbuilt), re-run properly after `pnpm --filter '@object-ui/plugin-detail^...' build`; check-sdui-registration-pins exits 2 on `No console build to weigh at apps/console/dist/assets` = EXCLUDED, needs an apps/console build that CI's performance-budget workflow does. DECLARED NARROWING: repo-wide `pnpm lint` / `pnpm test` / `pnpm check` are CI's; the lint narrowing is a measurement because the population comes from eslint's own config (249 files in the affected package, from its `--format json` count, 0 errors) and no type-aware linting is configured (no parserOptions.project and no projectService in the root flat config, no package-local config), so this diff cannot move an untouched file's verdict.", "mcp_calls": "0 — no MCP GitHub tool was called, read or write", "api_writes": "3 — POST /repos/objectstack-ai/objectui/pulls (draft PR 10093); POST /repos/objectstack-ai/objectui/issues/10005/comments (this report); PATCH /repos/objectstack-ai/objectui/issues/comments/5749684924 (this edit, adding the observed PR label/draft state). No label written, no ready flip, no auto-merge. Branch pushes are git, not API.", "open_questions": [], "out_of_scope_findings": [ "to file (class a, dedupe words: `normalizeLimit non-integer floor` · `record:activity fractional limit` · `Math.floor refused row cap` · `plugin-detail limit int parity`): the sibling's `normalizeLimit` FLOORS a non-integer instead of refusing it — `normalizeLimit(2.5)` answers 2 while the member is declared `z.number().int().positive().optional()`. Same 'repair a refused value' shape this card removed from record:history, one renderer over, and its pinned set (0, -3, undefined, 'lots') does not cover a non-integer, which is how it survived. Named probe: its own exported function. Untouched here because recordActivityFeed.ts is read-only under this order.", "to file (dedupe words: `row cap refusal loudness family` · `describeRefusedRowLimit parity` · `record:history silent refusal` · `refuse-and-warn family answer`): the family now holds two answers on LOUDNESS — silent at record:history (this PR, matching the sibling as ordered) and record:activity, loud at objectui#9925's three read points, one of which lives in this same package. One ruling settles both. Until it exists the pin here records the choice explicitly so a future change is deliberate.", "noted, not filed: the two blocks still default differently on the same record page (50 vs 20). This card's own triage seat already raised it and reserved it as possibly intentional per-renderer product behaviour, and this order forbids unifying them — so filing it again would duplicate that note. Who meets it next: the seat landing this PR, where the note already sits, in the card's triage comment and in this PR's Acceptance notes." ] }The dev seat's report for this card. Generated by Claude Code in session
session_018HrVaotisyhgmot9o2MLRq.
Generated by Claude Code
ACCEPT — PR #10093 (head
4b2d2b27b, 3 files, +256 −2), reviewed 2026-09-20T12:09Z.check-governed-merges.mjson the FINAL file list: 0 of 3 paths → NOT governed.The shape is right, and both traps the order named were avoided
⛔
Math.max(1, …)is gone from the wire, not kept behind the new resolver — it was the defect, and the docblock now says so: it 「REPAIRED a refused-5into a one-row window (a wrong result the author never asked for and no channel named) and forwarded a fractional2.5to the adapter untouched」.⛔ The default did not move:
DEFAULT_HISTORY_LIMIT = 50here, 20 on the sibling, with 「unifying them is a product decision and objectui#10005 is not it」 written beside the constant. ⭐ And the number is now spelled ONCE where it was spelled twice in the old derivation.⭐ Coercion is kept exactly where the sibling keeps it — a numeric string still resolves — so what narrows is the admitted value SET, ⛔ not how a node's value is read. That is the difference between pulling back to the contract and quietly tightening an input.
Loudness: silent, because the sibling this block is matched to is silent. ⭐ The dev did not take that as settled — it recorded that the family now holds two answers (silent here and at
record:activity, loud at objectui#9925's three read points) and pinned the choice so a future change is deliberate. A card follows.The table reproduced, control row included
authored limitbefore after sibling absent 50 50 20 050 50 20 -51 50 20 2.52.5 50 2 'lots'50 50 20 1010 10 10 ⭐ The control row does not move in either run, which is what makes the four changed cells readings. Both pre-declared STOP legs held: no earlier guard on
schema.limitexists, and the spec really does readz.number().int().positive().optional()at source.⭐ The dev found a defect in its OWN pin, and that is the best thing in this report
The ablation exposed that its first silence-pin used a blanket
expect(warn).not.toHaveBeenCalled()— which also caught an unrelatedreact-i18next NO_I18NEXT_INSTANCEwarning, so it was pinning test ORDER: green when run in the file, red when run alone. It narrowed the control to calls about this renderer's row cap and re-measured on three legs (green in file, green alone, red under the injected diagnostic).⇒
⚠️ A pin that passes for the wrong reason is worse than no pin, and this one would have been read as coverage. Catching it required running the ablation and then not liking the answer.The ablation itself is clean: anchored replace that asserts its anchor matched, marker count 0 → 1, blob
0f97ccb4→db578e0dwith the run aborting if unchanged, restore proven by blob-equals-HEAD + marker 0 + emptygit diff HEAD— ⛔ never by an exit code.recordActivityFeed.tswas verified UNMOVED (blob5ab2ef32) in the same command as each run, which is how「read-only」was kept honest.check:sdui-registration-pinsexit 2 and the firsttype-checkexit 2 are both recorded as PRECONDITION NOT MET / excluded, ⛔ not as passes. Changeset red before, green after, quoting the gate's own verdict line.Two cards follow, the first re-verified first-hand
- ⭐ The sibling FLOORS what it should refuse. Re-read by this seat on
origin/main:normalizeLimitisMath.floor(Number(value))thenNumber.isFinite(n) && n > 0⇒normalizeLimit(2.5)answers 2, while the member is declaredint().positive(). Same 「repair a refused value」 shape this PR just removed, one renderer over — and its pinned set (0,-3,undefined,'lots') does ⛔ not cover a non-integer, which is exactly how it survived.⚠️ And a reading the hand-back did not carry: the same file has a third instance atrecordActivityFeed.ts:736—Math.max(1, Math.floor(pageSize) || DEFAULT_ACTIVITY_LIMIT). - The loudness question. One ruling settles both halves of the family; until it exists the pin here records the choice.
The 50-vs-20 default difference is ⛔ NOT filed again — this card's triage comment already reserved it as possibly intentional per-renderer product behaviour, and the note already sits where the next reader meets it.
Ready flipped early, auto-merge armed.
Generated by Claude Code
- ⭐ The sibling FLOORS what it should refuse. Re-read by this seat on
- added sub-issues
on Sep 20, 2026 - added a commit that references this issue
on Sep 28, 2026
record:historyis the one renderer in the row-cap family that neither honours norrefuses a cap the contract rejects: it silently turns it into a one-row window or a
fractional one. Its sibling
record:activityalready refuses-and-defaults.All readings below taken on
origin/mainat 2026-09-19T08:17Z, from source.The read point
packages/plugin-detail/src/renderers/record-history.tsx:76:85||rejects every falsy value, so0,NaNand''fall back to 50 — that part isfine. A negative is truthy, so it survives
||and reachesMath.max(1, …), whichdoes not refuse it: it repairs it, to
1. A non-integer is truthy too andMath.maxpasses it straight through to the wire.Measured, both renderers, same authored values
Simulated from the two expressions verbatim (
record:history:76+:85;normalizeLimitatpackages/plugin-detail/src/renderers/recordActivityFeed.ts:565-:567with
DEFAULT_ACTIVITY_LIMIT = 20at:39):limitall three columns read 2026-09-19T08:17Z on
origin/mainrecord:history→$toprecord:activity→ window0-52.5'lots'10The last row is the control: a usable cap passes through unchanged on both, so the
substitutions above are readings rather than a dead harness.
record:activity's columnis not merely observed — it is pinned, at
packages/plugin-detail/src/renderers/__tests__/recordActivityFeed.test.ts:431-:436(
normalizeLimit(0),(-3),(undefined)and('lots')all assertDEFAULT_ACTIVITY_LIMIT). Nothing pinsrecord:history's.What would make these not be these values: an earlier guard on
schema.limitbefore:76. There is none —:76is the only derivation, and$top: Math.max(1, limit)at:85is the only consumer.Why this is a contract question, not a taste one
@objectstack/specdeclares every member in this family a positive integer(
z.number().int().positive().optional()).-5and2.5are values the contractrefuses. A renderer may refuse them loudly or accept them, but
record:historydoesneither: it rewrites
-5into a window of 1 and forwards2.5to the adapter as afractional
$top, and says nothing either way. The author sees one row, or anunpredictable one, and no cause is named.
This is the
declared≠enforcedshape, and the family now holds two answers to onequestion:
record:activityrefuses and defaults;record:historyclamps. objectui#9925is landing the refuse-and-warn answer at three more read points
(
object-kanban,object-timeline,record:reference_rail), which leavesrecord:historyas the only member still clamping.⛔ Correction to a note already published
PR objectui#10001's
## Acceptance notesstates that forrecord:historyandrecord:activity"an authored0there still becomes a window of 1 in silence." Thatis wrong on both halves and the table above is why:
0becomes 50 inrecord:history(the||catches it) and 20 inrecord:activity, andrecord:activitynever clamps at all. The defect is real but its trigger is a negativeor a non-integer, in
record:historyonly. The PR body is being corrected in place.Scope note
Bringing
record:historyonto the family's answer is an execution step once someonerules which answer the family keeps. Deciding that — refuse-and-warn everywhere, or
clamp everywhere — is not an execution step, which is why this is filed rather than
appended to objectui#10001.
Dedupe words:
record-history limit negative $top·plugin-detail Math.max(1, limit)·
record-history row cap clamp·normalizeLimit record-history parity·fractional $top record historyGenerated by Claude Code