Skip to content

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

@os-tesla

record:history is the one renderer in the row-cap family that neither honours nor
refuses a cap the contract rejects: it silently turns it into a one-row window or a
fractional one. Its sibling record:activity already refuses-and-defaults.

All readings below taken on origin/main at 2026-09-19T08:17Z, from source.

The read point

packages/plugin-detail/src/renderers/record-history.tsx:76

const limit: number = Number(schema.limit ?? schema.properties?.limit ?? 50) || 50;

:85

$top: Math.max(1, limit),

|| rejects every falsy value, so 0, NaN and '' fall back to 50 — that part is
fine. A negative is truthy, so it survives || and reaches Math.max(1, …), which
does not refuse it: it repairs it, to 1. A non-integer is truthy too and
Math.max passes it straight through to the wire.

Measured, both renderers, same authored values

Simulated from the two expressions verbatim (record:history :76+:85;
normalizeLimit at packages/plugin-detail/src/renderers/recordActivityFeed.ts:565-:567
with DEFAULT_ACTIVITY_LIMIT = 20 at :39):

authored limit
all three columns read 2026-09-19T08:17Z on origin/main
record:history → $top record:activity → window
absent 50 20
0 50 20
-5 1 20
2.5 2.5 2
'lots' 50 20
10 10 10

The 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 column
is 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 assert
DEFAULT_ACTIVITY_LIMIT). Nothing pins record:history's.

What would make these not be these values: an earlier guard on schema.limit before
:76. There is none — :76 is the only derivation, and $top: Math.max(1, limit) at
:85 is the only consumer.

Why this is a contract question, not a taste one

@objectstack/spec declares every member in this family a positive integer
(z.number().int().positive().optional()). -5 and 2.5 are values the contract
refuses. A renderer may refuse them loudly or accept them, but record:history does
neither: it rewrites -5 into a window of 1 and forwards 2.5 to the adapter as a
fractional $top, and says nothing either way. The author sees one row, or an
unpredictable one, and no cause is named.

This is the declared ≠ enforced shape, and the family now holds two answers to one
question: record:activity refuses and defaults; record:history clamps. objectui#9925
is landing the refuse-and-warn answer at three more read points
(object-kanban, object-timeline, record:reference_rail), which leaves
record:history as the only member still clamping.

⛔ Correction to a note already published

PR objectui#10001's ## Acceptance notes states that for record:history and
record:activity "an authored 0 there still becomes a window of 1 in silence." That
is wrong on both halves and the table above is why: 0 becomes 50 in
record:history (the || catches it) and 20 in record:activity, and
record:activity never clamps at all. The defect is real but its trigger is a negative
or a non-integer, in record:history only. The PR body is being corrected in place.

Scope note

Bringing record:history onto the family's answer is an execution step once someone
rules 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 history


Generated by Claude Code

Activity

  1. self-assigned this
    on Sep 20, 2026
  2. os-tesla commented on Sep 20, 2026

    @os-tesla
    CollaboratorAuthor

    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.tsx and its tests under packages/plugin-detail/src/renderers/__tests__/. ⭐ recordActivityFeed.ts and 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 asks record:history to match.
    Container & model: S, mode:subagent, model: opus. ⛔ No tier output is quoted: scripts/pm/dispatch-gates.mjs REFUSES for this repo, so the tier is stated, not read back.
    Clause-②: no — this PULLS BACK to the declared contract (@objectstack/spec declares 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 + one packages/app-shell file), #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

  3. os-tesla commented on Sep 20, 2026

    @os-tesla
    CollaboratorAuthor

    Dispatched to an os-dev subagent (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: 10 passes 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.limit before 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:history defaults to 50 and record:activity to 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 and record: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.ts and 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

  4. os-tesla commented on Sep 20, 2026

    @os-tesla
    CollaboratorAuthor

    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

  5. os-tesla commented on Sep 20, 2026

    @os-tesla
    CollaboratorAuthor

    ACCEPT — PR #10093 (head 4b2d2b27b, 3 files, +256 −2), reviewed 2026-09-20T12:09Z. check-governed-merges.mjs on 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 -5 into a one-row window (a wrong result the author never asked for and no channel named) and forwarded a fractional 2.5 to the adapter untouched」.

    ⛔ The default did not move: DEFAULT_HISTORY_LIMIT = 50 here, 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 limit before after sibling
    absent 50 50 20
    0 50 50 20
    -5 1 50 20
    2.5 2.5 50 2
    'lots' 50 50 20
    10 10 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.limit exists, and the spec really does read z.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 unrelated react-i18next NO_I18NEXT_INSTANCE warning, 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 → db578e0d with the run aborting if unchanged, restore proven by blob-equals-HEAD + marker 0 + empty git diff HEAD — ⛔ never by an exit code. recordActivityFeed.ts was verified UNMOVED (blob 5ab2ef32) in the same command as each run, which is how「read-only」was kept honest.

    check:sdui-registration-pins exit 2 and the first type-check exit 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

    1. ⭐ The sibling FLOORS what it should refuse. Re-read by this seat on origin/main: normalizeLimit is Math.floor(Number(value)) then Number.isFinite(n) && n > 0 ⇒ normalizeLimit(2.5) answers 2, while the member is declared int().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 at recordActivityFeed.ts:736 — Math.max(1, Math.floor(pageSize) || DEFAULT_ACTIVITY_LIMIT).
    2. 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

  6. removed their assignment
    on Sep 20, 2026
  7. added a commit that references this issue on Sep 28, 2026
    cd2cb41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions