Repository navigation
feat(rest,console): one request opens a record page - #27713
objectstack-fleet[bot] wants to merge 22 commits into
Conversation
…/:id/page Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr Co-Authored-By: Claude <noreply@anthropic.com>
…S/FLS equivalence Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr Co-Authored-By: Claude <noreply@anthropic.com>
The record page asks DataSource.getRecordPage once, in a layout effect, and seeds each reader with its part: the related-count store (badge, card, rail and compact rows), the update/delete verdicts, the transfer decision, the field projection and the approval rows and lock. The per-related top=1 probes and the separate field-access, lock and explain calls at open retire. The drawer stepper prefetches the previous and next record's payload. Related lists may name an order. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
…nerated artifacts The two request refusals of POST /data/:object/:id/page answer through the shared sendError (details.fields); the catch still relays mapDataError as GET /data/:object/:id does, declared with the data family's #9559 ratchet. Each new spec shape names its parsed state. Regenerated the api surface, export origins, declaration map, reference docs, strictness counts and the llms.txt inventory. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
…-route tripwire and the hook-refusal ledger Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
…ibe at load Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
…ed, minor The new shapes enter the authorable-surface api shard, which the level axis reads as a key added. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
…able lives in the spec module Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 330e18b · 2026-10-29T20:42Z Generated by Claude Code |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract reviewServed-tier: Inputs read: card #27627 body and its three comments (deployment half 6500767235, claim 6501011060, claim amendment 6507391490); PR #27713 body, 47-file list and net diff from the files endpoint; the check-runs on the head. The rest of the diff (console readers, pins, docs, census tests) is the lane seat's ACCEPT at ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Holds, clause by clause:
Tried to break it:
Findings:
No must-fix. The schema is a faithful declaration of the ruling's one-request payload, every part reuses or mirrors the replaced read's declared shape, the strict request and empty query set hold the ingress rules, and the first consumers (route, client, console builder) read it as written. Implemented-by: VERDICT: PASS Generated by Claude Code |
…ed 400 before any read Contract review should-fix 3: the route lowers every declared filter with parseFilterAST beside the invalid_relationship judgement, before the record read, and reuses the lowered filter for the list read. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
…s come from the operation enum Contract review should-fix 1, 2 and 4: RecordPageRelatedAnswerSchema is a union of two strict arms (RecordPageRelatedRows, RecordPageRelatedRefusal), so a refused list can never carry a count or rows and an answer never carries neither; the explain part's keys derive from RecordPageExplainOperationSchema.options and RecordPageExplainAnswerSchema is exported; approvals.requests and related.filter describe the no-service answer and the filter refusal. Artifacts regenerated. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
Reconciles with #27636 (objectstack#27056): RelatedList's compact branch keeps the payload adoption ahead of the in-view gate and reads the card context once; the record page prefetches the drawer's upcoming record (RecordDrawerUpcomingContext) instead of its own neighbours context, and skips the related-count warm-up where the adapter answers the payload. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
…dled rejection The record page's seeded field-access, approvals and verdict parts get a handler of their own, so a refused or missing part that no reader joins (a reader already reading, or none mounted) is not an unhandled rejection; a reader that joins it still sees the refusal. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
… with the payload, as count probes without it Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP Contract re-review requested on the new head · 2026-10-30T03:06Z Record 6513269907 was FAIL on The dev reports exit 0 for these on this head: a workspace |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract-review-claim: Generated by Claude Code |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract reviewServed-tier: This is a delta record on the FAIL of record 6513269907 (head ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Holds:
Tried to break should-fix 1's union against the changed consumer on THIS head:
Read, no finding: the two pre-existing No must-fix and no new should-fix on the Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP Epic preview check waived by the maintainer · 2026-10-30T11:21Z At 2026-10-30T11:19Z, the maintainer wrote in the project chat: 「27713 你正常审核落地就行」. That waives the epic #26676 seat's console-preview check that #27627's order put before the seat's ACCEPT (request 6514401576 on #26676). The PR now goes through the normal ACCEPT. First, a blocker: |
One conflict, ui/packages/data-objectstack/src/index.ts: main's #27907 (objectstack#27626, shared getRecordFieldAccess asks) and this branch's record page payload each added adapter fields beside inflightFindOnes and lines to dropInflightReads. Both sides kept: the two record page maps and main's three field-access members; the record page maps cleared and main's field-access entries dropped by prefix. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
The os-regen driver merged api-surface, authorable-surface and export-origins without a text merge; regenerated from the merged source (spec build, gen:export-origins, gen:schema, gen:api-surface), which restores main's rows the merge dropped (e.g. the three `disabledActions` authorable rows) beside this branch's RecordPage rows. Against main the three shards differ by exactly this branch's additions (+36, +30, +36). check:generated exits 0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP 维护者速读 (席位终稿)
席位意见:接受。分诊契约复核在 席位核对 · ACCEPT ·
|
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract-review-claim: Generated by Claude Code |
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
Record pane v4 (#28129) hands the pane BOTH neighbours as an array, so the payload prefetch read a single upcoming record that no longer exists and asked none. Ask each neighbour's page once this record's has answered, and pin an in-place step to either one opening from that answer. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP Contract re-review requested: scoped to The seat's ACCEPT (6524688486) was on The delta is three first-parent commits:
The PR is now 3296 lines, still Tier H. The maintainer's approval (12:49Z) stands. Auto-merge is off until the seat re-ACCEPTs. |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract reviewServed-tier: This is a delta record on the PASS of record 6514375644 (head ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Holds:
Tried to break the schema against the merged tree:
Read, no finding: No must-fix and no new should-fix on the Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP 席位核对 · ACCEPT (re-issued on the new head) ·
|
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP
Part of #27627
Claim: 6501011060
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none edited. No skill teaches which requests a record page sends when it opens, and the API skill's spec index (
skills/objectstack-api/references/_index.md) lists schema files it reads from the installed package, so the newapi/record-page.zod.tsneeds no row (check:skill-refsexit 0). Named, not edited.Metadata-protocol: added, measured. The claim'saddednamed the app bundle (PR 2); this PR measuresaddedfor itself. The new request and response shapes enterpackages/spec/authorable-surface/api.json, the authorable-surface shard the level axis reads, socheck-changeset-no-major --eventrefusesnonehere. Nothing an author already writes changes meaning: no existing key, default or answer moves. Per the rule, every package this PR moves takesminor(both changesets).维护者速读
POST /api/v1/data/{object}/{id}/page,一次请求返回记录详情页打开时要的全部数据:记录本身(含查找字段展开)、每个相关列表的总数和前几行、字段权限、审批状态与锁、页头按钮的权限判定。控制台记录页(整页、分栏、抽屉)改为只发这一个请求,再把各部分交给原来各自去取的组件;抽屉里预取下一条将要显示的记录。top=1计数探测,远距离部署下每批都要多等一个往返。packages/rest/src/record-page-routes.ts(每一部分调用哪个函数、父记录条件怎么拼)、packages/qa/dogfood/test/record-page.dogfood.test.ts(非管理员成员下与分开请求逐项对比)、ui/packages/app-shell/src/views/recordPagePayload.ts(页面要哪些列表、答案交给谁)。cd packages/qa/dogfood && pnpm exec vitest run test/record-page.dogfood.test.ts→ 3 个用例绿:非管理员成员拿到的记录、相关总数与行、字段权限、判定、审批与分开请求一致,无权限的相关列表 403 且无总数无行。What changes
Request counts, before and after
Measured in a console test harness over the real
ObjectStackAdapteron a held transport (each "wave" is one round: everything asked so far is answered together, and what the page asks next is the next wave). The record is a contact with one lookup and four related lists, opened in StrictMode on the default record page; the same harness file ran onorigin/main(theui/packagestree checked out fromorigin/main) and on this head, and both runs draw the record.Correction to the figure quoted to the seat (warm 9 → 8)
The first harness run answered the record read (
GET /data/{object}?filter=["id","=",…]) with no row, so it measured a "Record not found" page; its warm figure was 9 because the not-found page also read the approval lock. With the record answered,origin/mainreads cold 13 in 2 waves and warm 8 in 1 wave. The cold figure is unchanged.Before,
origin/mainat the merge base178b5238:After, this head:
top=1probesThe only second-wave request left is the record object's own definition (
/meta/object/<object>), which the details block reads once the record arrives; it was there before too, and it is what PR 2's app bundle serves.The route:
POST /api/v1/data/{object}/{id}/pageThe query form (a POST with a strict body) was chosen because the page must name its related lists, their declared filters and their order, and that does not fit a closed query string. Seat ruling: accepted,
Clause-②: yes (widening).Request (
RecordPageRequestSchema,@objectstack/spec/api, strict; the query-parameter set is closed and empty):Response
data(RecordPageResponseSchema):Every part is the read it replaces, asked as the same caller, through the same function:
recordGET /data/{object}/{id}getData, after the same object-exposure gate, asked with the caller's contextrelated[i]GET /data/{child}?filter=…&$countfindDatawith the canonical QueryAST (where,limit,orderBy,count: true), after the child's own exposure gatefieldAccessGET /data/{object}/{id}/field-accessgetRecordFieldAccessexplain.<op>POST /security/explainwith the record idexplainapprovalsGET /approvals/requestsfor the record andGET /approvals/records/{object}/{id}/locklistRequests(the pending row enriched bygetRequest) andgetRecordLock{ code, status, message }) in place of its answer, and the other parts are answered. ⛔ A related list the caller may not read carries neithertotalnorrecords.{object}, or a400 VALIDATION_FAILEDwhoseerror.details.fieldslocatesinvalid_relationshipatrelated.N.field, before anything is read. Both request refusals answer through the sharedsendError; the catch relaysmapDataErrorexactly asGET /data/{object}/{id}does (declared incheck-route-envelope.mjswith the data family's [tracking] Envelope-position convergence line for packages/rest's flat dialect — the live ratchet owner #9559 ratchet). The parent condition is the server's, compiled to the field's arity ($containsfor a multi-value field); a declaredfiltercan only narrow it.metaspan beside the existingdbandtotal, under the existing gate (OS_SERVER_TIMING=true, orX-OS-Debug-Timing: 1from an admin), per the seat's ruling. OpeningServer-Timingto every caller would be a maintainer card.Cache-ControlandETagalready answer on the metadata item routes (/meta/object/{name}), so nothing moves there in this PR; the bundle in PR 2 carries its ownETag.@objectstack/client:data.recordPage(object, id, request). Route ledger rowPOST /api/v1/data/:object/:id/page(disposition: 'sdk',client: 'data.recordPage',responseSchema: 'RecordPageResponseSchema').The console
RecordDetailViewbuilds the request from the page it renders (therecord:related_listnodes, therecord:reference_railentries and the lists derived for a synthesized page; the header'supdate/deleteverdicts andtransferwhen the object has a queue; field access; approvals) and sends it once, in a layout effect, before any reader's effect runs. Each part is handed to the reader that used to ask for it, as that reader's read on the wire:RelatedCountStore.seed(tab badges, collapsed, compact and rail cards),seedRecordVerdict,seedQueueClaimDecision,seedRecordFieldAccess,seedRecordApprovals. A refused part reaches its reader as that read's refusal. No dual path: an adapter that implementsgetRecordPagenever sends the replaced reads at open.upcomingrecord from perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636) throughDataSource.prefetchRecordPage(at most 4 held, taken once, dropped by any write). See "Merge resolution with perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636" below.listView, one whose filter carries a context token the reader resolves against the session first, one whose filter does not lower, the lists past the fiftieth); the attachments badge probe; a sorted list sharing a key with a rail card (the rail takes rows in the default order); and a list whose part the server refused: the store keeps no refusal, so a reader that mounts after the payload answered reads that list once more and gets the same refusal (measured in the harness: one extra request per refused list, never a count or a row). An adapter withoutgetRecordPagereads exactly as before.Files outside the claim's file surface
The claim named
RecordDetailView.tsx,recordDrawerStack.tsx,recordBinding.tsx,RelatedCardStack.tsxandui/packages/data-objectstackon the consumer side. Retiring the separate reads with no dual path meant each reader had to accept its part, so the diff also touches the readers themselves:ui/packages/types(theDataSourcemethods and shapes),ui/packages/componentsrelated-count-store.ts(seed/takeRows),ui/packages/plugin-detail(useRecordEditable.ts,queueClaim.ts,recordFieldAccess.tsx,RelatedList.tsx,index.tsx),ui/packages/app-shelluseRecordApprovals.tsand the newrecordPagePayload.ts, and the dogfood fixture and test.recordBinding.tsxandRelatedCardStack.tsxdid not need to move.packages/spec/contracts/**does not exist; the shapes live inpackages/spec/src/api/record-page.zod.tsbeside the other REST shapes.packages/objectqldid not move (each list's count rides its ownfindDatawithcount: true).Contract review should-fix (review 6508117594 on
330e18be1)RecordPageRelatedAnswerSchemais now a union of two strict arms,RecordPageRelatedRowsSchema{object, field, total, records}andRecordPageRelatedRefusalSchema{object, field, error}, so an answer carries rows or a refusal, never both and never neither. The authorable shard drops the fiveRecordPageRelatedAnswer:*rows (they never shipped) and gains the two arms' rows.35a872079record-page.test.ts: both arms parse; a refusal withtotaland/orrecords, and an answer with neither, are refused.RecordPageExplainOperationSchema.options;RecordPageExplainAnswerSchemais exported with itsz.inputandParsedaliases (rows inapi-surface,export-origins,declaration-map,json-schema.manifest).35a872079record-page.test.ts: the explain object's keys equal the enum's options, and every option's refusal parses.parseFilterASTbeside theinvalid_relationshipjudgement, before the record read, and reuses the lowered filter for the list read. A refusal is a400 VALIDATION_FAILEDatrelated.N.filter(invalid_filter).related[].filter's description says so.5a7107f65(route, test),35a872079(description)record-page-routes.test.ts: a filterparseFilterASTrefuses ({ title: { $in: 'x' } }) is a located 400; neithergetDatanorfindDatais called.approvals.requestson a deployment with no approval servicelocksaysnull.35a872079record-page-routes.test.ts(existing): with no approval service the part is{ requests: [], lock: null }.Metadata-protocol: addedstill holds for the final shape: the authorable shard gains rows and loses only rows this PR added. Every moved package staysminor.Contract re-review must-fix (reviews 6512166649 on
0efe2a4deand 6513269907 on392693230)Each must-fix was a CI red on the head it was found on, and each landed with a test, or a typecheck, that fails without it.
GET /data/:object/:idenforceApiAccess(req, res, p, environmentId, 'get', context): the route passes the context it already holds, ascrud-routes.tsdoes since #27692 (the 5-argument call was TS2554,Type Check · workspace)392693230record-page-routes.test.ts"asks the object-exposure gate as GET /data/:object/:id does, with the caller's context (#27692)": the gate is asked once with'get'and the resolved context. Ablated: with the 5-argument call it fails.@objectstack/resttypecheck(tsc andcheck:test-typecheck) exits 0query: { object, where, limit, count: true, orderBy? }, typed againstFindDataRequestwith no cast; the wire aliasesfilter/topand theas anyare gone.REGISTRAR_QUERY_SLOT_FLOORSgains'record-page-routes.ts': 1and the whole-package slot control goes 4 → 5392693230rest-server-canonical-query-ast.test.ts(resttest:repo, theTest Core (8/8)red) passes;record-page-routes.test.tspins the exactfindDataarguments in the new spelling. The engine foldstopintolimitandfilterintowhere, so the dogfood comparison against the separate list read is unchangedrecord-page.dogfood.test.tsreads each related part throughrowsArm(i)/refusalArm(i), which narrow by'total' in/'error' inand fail the test on the wrong arm; no cast. Six TS2339 in@objectstack/dogfoodtypecheck(theType Check · workspacered on392693230) are gone6f040ab5e@objectstack/dogfoodtypecheckexits 0; the dogfood trio exits 0 (the refused ledger list now has to be the refusal arm, and the two readable lists the rows arm)The review's ① also named the
Type Check UIred (check:spec-symbols):@object-ui/typeshand-declaredRecordPageRelatedRequest,RecordPageRequestandRecordPagePartRefusal, three names@objectstack/spec/apiexports.8ad440797re-exports them from the spec and bindsRecordPageOperationto the spec'sRecordPageExplainOperation.check:spec-symbolsexits 0, and so do the remaining Type Check UI legs (below).Correction to r2. r2 listed every locked suite and gate on
392693230as exit 0, and they were, but@objectstack/dogfoodtypecheckwas not among them: vitest runs the dogfood test without type-checking it, so the six TS2339 there (must-fix 3) reached CI. This revision runsturbo run typecheckover every packageturbo ls --filter='...[merge base]'lists (73 packages, 142 tasks, the packages the PR touches and every dependent of them,@objectstack/dogfoodincluded) and lists its exit code.Correction to r1. r1 said every derived gate exited 0 on
0efe2a4de, and that was true of the 137 thatdispatch-gatesderives. CI then failed three required contexts that none of those 137 covers:@objectstack/resttypecheck(after the main merge), resttest:repo(my locked run had resttestonly), and uicheck:spec-symbols. This revision runs all three, plus the ui type-check legs, and reports each one's exit code.Merge with #28129 (record pane v4), main at
d70e94ae1The merge queue dropped the PR at 14:17Z on a conflict with #28129.
49204ce58mergesorigin/mainat277275aa4throughscripts/pm/os-regen-merge.sh;f66a0104bthen merges main atd70e94ae1(9 more commits, no conflict) the same way. Main moved no generated shard in either range, so step 2 kept the branch's bytes; the full regen chain on the merged tree (pnpm --filter @objectstack/spec build,gen:export-origins,gen:schema,gen:api-surface,check:generated --fix,gen:docs,gen:strictness-ledger,check:generated) exits 0 and leaves the tree clean (git status --porcelain: 0 lines).One conflicted hunk, the
./recordDrawerStack.jsimport inui/packages/app-shell/src/views/RecordDetailView.tsx. This PR importsRecordDrawerUpcomingContext(and adds the./recordPagePayload.jsimport below it); main importsuseRecordPane. Resolved as the union: one import ofPrefetchUpcomingRelatedCounts, RecordDrawerReturnLink, RecordDrawerUpcomingContext, RecordPageDrawerHost, useRecordPane, then this PR'srecordPagePayloadline. Every other file merged without a conflict (recordDrawerStack.tsx,RelatedList.tsx,plugin-detail/src/index.tsx,data-objectstack,client).A shape change the text merge did not show, fixed in
722a37c5a. #28129 changedRecordDrawerUpcomingContextfrom one record (RecordDrawerUpcoming | null, the step in the last direction) toreadonly RecordDrawerUpcoming[], BOTH neighbours, the one in the last step's direction first. This PR's prefetch readupcoming.objectName/upcoming.recordId, which merged cleanly and isundefinedon an array, so on the merged tree the pane would have asked no neighbour ahead. The fix asks each neighbour of the same object once this record's page has answered, keyed on the ids' content (AGENTS.md #10). The adapter's prefetch map holds up to 4 entries, so both fit.v4 entry points checked.
ObjectViewRecordOverlay→RecordStackDrawer, which renders the same embeddedRecordDetailViewinsideRecordDrawerUpcomingContext. One page request per open.↑↓and a click on another row (root swap): the level'sFragmentkey is depth + object, so the record swaps in place throughrecordIdOverride; a swap to a prefetched neighbour sends nothing for that record.⤢maximize in place:NavigationOverlaykeeps one element and restyles it; no remount, no re-ask.↗: the record route, the non-embedded view the cold and warm cases pin.New test case in
RecordDetailView.recordPagePayload-27627.test.tsx("in the v4 pane, both neighbours are asked ahead as pages, and an in-place step to either opens from that answer"): the pane opensc2with neighbours[c3, c1]→ pagesc2,c3,c1and no count probe for either neighbour; an in-place step toc1with neighbours[c0, c2]→ onlyc0andc2are asked, nothing forc1, no non-page data request. The existing cases now hand the context an array. Ablation: prefetching only the first neighbour turns this case red (1 failed, 5 passed); restored, 6 passed.Cost, stated. Each step re-asks the record just left: the open took its prefetched answer, and the adapter keeps no settled payload (a kept one would serve rows a write has since changed). That is one background page request per step, sent after the current page has answered, matching v4's choice to warm both sides (its count warm-up also covers both neighbours).
Merge with main at
ba4ecf1d2(178 commits)428642e58mergesorigin/mainthroughscripts/pm/os-regen-merge.sh;780dd8c26regenerates the three surface shards the os-regen driver merged without a text merge. Nothing else moved.One conflict,
ui/packages/data-objectstack/src/index.ts, two hunks. Main's #27907 (objectstack#27626: concurrentgetRecordFieldAccessasks share one request; 404 and 501 are remembered) and this PR each added adapter members in the same two places:inflightFindOnes: this PR'sinflightRecordPages/prefetchedRecordPages, main'sinflightFieldAccess/unansweredFieldAccess/fieldAccessUnsupported. Kept both, this PR's first.dropInflightReads: this PR clears both record page maps (a payload carries other objects' rows, so any write drops it); main drops its field-access entries by the resource prefix. Kept both.Main's rewritten
getRecordFieldAccessand this PR'sgetRecordPage/prefetchRecordPage/readRecordPagemerged without a conflict. The two do not overlap: with the payload, the console seeds field access from the page and never callsgetRecordFieldAccesson open, while after a save the field-access readers still go through it (now shared).Generated shards. The merge commit dropped main's three
disabledActionsauthorable rows (the driver's silent one-sided merge); the regeneration restores them. Against main the three shards differ by exactly this PR's rows:api-surface+36,authorable-surface+30,export-origins+36.check:generatedexits 0.Merge resolution with #27636
origin/maingained #27636 (objectstack#27056: the related column loads counts first, then rows only for the cards in view, and the drawer warms the next record's related counts). The merge (7d6f3ed34) conflicted in three files:ui/packages/app-shell/src/views/RecordDetailView.tsx— the imports. The page now reads perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636'sRecordDrawerUpcomingContextfor its prefetch.ui/packages/app-shell/src/views/recordDrawerStack.tsx— taken as main has it. This PR's own neighbours context (RecordNeighborsContext, previous and next sibling) is gone, so the drawer names one next record, perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636'supcoming.ui/packages/plugin-detail/src/RelatedList.tsx— the fetch effect's dependency list, and a name collision. The compact branch reads the card context once (perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636'scompact). The payload adoption still runs before perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636's in-view / non-zero gate (rowsWanted), so a card whose rows the payload carries draws them without reading, in view or not.What runs where:
getRecordPage/prefetchRecordPage(the console'sObjectStackAdapter): the open asks one page payload, and perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636's count set (useRelatedCounts→RelatedCountStore.fetch) joins the seeded counts, so it sends nothing. In a drawer, the page prefetches theupcomingrecord's payload once this one's has answered, and perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details #27636'sPrefetchUpcomingRelatedCountsis not mounted. The next record's counts are asked once, inside its page, not twice.Tests, both in the acceptance file, on the real adapter in a drawer (
embedded,upcoming= c2):#27636's own suites (
RecordDetailView.relatedCountsFirst-27056,__tests__/recordDrawerStack.prefetch-27056,useRelatedCounts-27056,RelatedList.compactLazy-27056) pass unchanged on the merged head.Tests
packages/rest/src/record-page-routes.test.ts, 13 cases: the composed answer parses againstRecordPageResponseSchema(expand, declared filter AND parent condition, multi-value$contains,sort→orderBy); only the named parts are asked; a record refusal is the whole answer; a related refusal carrieserrorand nototal/records; an API-disabled child isOBJECT_API_DISABLED404 and never read; part refusals for field access, explain and approvals; missing services answer 501 parts and empty approvals; a system context without a user id gets a 401 explain part;invalid_relationship400 before any read; a strict body refuses unknown keys and a bad sort order; a query parameter is a 400.packages/qa/dogfood/test/record-page.dogfood.test.ts, 3 cases, as a non-admin member on a booted stack (fixture: an account with an FLS-unreadablerating; notes under owner RLS with an FLS-unreadablesecret; tasks with a declared filter; a ledger object with no grant; a private object). Each part equals its separate read for the same member: the record equalsGET; notestotal2 and the same rows with nosecret; tasks with{status:'open'}total4 and 3 rows; the ledger list is a 403 part with neithertotalnorrecords; field access equalsGET …/field-access; explain equalsPOST /security/explain; approvals match; an unreadable or missing record's page equals the record read's 404;invalid_relationshipand query-parameter 400s.ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx, the card's acceptance test on the real adapter: a cold open sends exactly one page request and zerotop=1probes, every data request in wave 1, and every badge total comes from the payload; a warm open sends one page request; the drawer's next record is asked ahead with the same body, and stepping to it sends nothing but the following record's prefetch; the two merge-resolution cases below; and the v4 pane case (both neighbours, in-place step), 6 cases in all.packages/spec/src/api/record-page.test.ts(new, 3 cases) and one new case inrecord-page-routes.test.ts: the contract review's should-fixes, mapped below.rest-write-response-internal-fields.tripwire.test.tsdrives the route as a read over POST (the read-path strip ofinternal: truefields, likePOST /data/:object/query);rest-write-route-hook-refusal-sentence.ledger.test.tsdrives abeforeFindrefusal of the record read as the whole answer;record-page-routes.tsjoinsREST_SERVER_REGISTRAR_FILES, so theresolveExecCtxcensus and the docblock-position floors read it.recordOpenRequests-11699,recordOpenRequests-12701,recordOpenWaves-13010,afterActionExplain-13341,recordReadFailure-11902): their assertions on what is asked, how often and in which wave are kept.Verification
Exit codes are the verdict; counts are context.
Locked suites (os-verify-lock), each part with its own exit code
On
f66a0104b(maind70e94ae1merged, the v4 prefetch fix):turbo run build(whole repo)packages/data-objectstackpackages/plugin-detail+packages/app-shell(whole package, #28129's tests included)pnpm --dir ui run check:spec-symbolsturbo run type-checkover CI's affected filter sinced70e94ae1@objectstack/resttypecheck/test/test:repoturbo run typecheckover the 74 packages that depend on the merge base's changes (packages/*,apps/*)record-page+authz-probe-blind-spot+authz-conformanceOn
780dd8c26(main merged, artifacts regenerated):turbo run build(whole repo, afterpnpm installfor main's newdriver-odata)packages/data-objectstackpackages/plugin-detail+app-shellRecordDetailView*+recordDrawerStackpnpm --dir ui run check:spec-symbolsturbo run type-checkover CI's affected filter since the merge base@objectstack/resttypecheck/test/test:repoturbo run typecheckover the 74 packages that depend on the merge base's changes (packages/*,apps/*)record-page+authz-probe-blind-spot+authz-conformancespeccheck:generatedOn
6f040ab5e(must-fix 3):turbo run typecheckover the 73 workspace packages the PR touches or that depend on one it touches (turbo ls --filter='...[merge base]',packages/*andapps/*)@objectstack/dogfoodtypecheckrecord-page+authz-probe-blind-spot+authz-conformanceOn
392693230(must-fixes 1 and 2):@objectstack/resttypecheck(tsc --noEmitandcheck:test-typecheck)@objectstack/resttest@objectstack/resttest:repo@objectstack/restbuild, then dogfoodrecord-page+authz-probe-blind-spot+authz-conformancepnpm --dir ui exec vitest run scripts/)On
8ad440797(the ui types fix; no ui file moved after it), the Type Check UI legs:pnpm --dir ui run check:spec-symbolscheck:action-forward-parity,check:designer-field-key-parity,check:icon-record-names,check:i18n-keys,check:i18n-drift,check:phantom-deps,check:self-import,check:unreferenced-sources,check:side-effects-arrayturbo run type-checkover the ui packages changed since the merge base and their dependents (CI's filter; 106 tasks)type-check:scripts,type-check:e2e,type-check:vitest-setup,type-check:coverageCarried from r1, on
0efe2a4de(no file these cover moved since):@object-ui/app-shelltsc --noEmitpackages/app-shellpackages/plugin-detail+components/src/hooks+data-objectstack+types@objectstack/client@objectstack/spec/test:repoRecordDetailView*+recordDrawerStacktestsCorrection to r0. r0 reported the app-shell suite green. That run exited 1: every test passed, but vitest counted three unhandled rejections from
afterActionExplain-13341, raised by a seeded approvals part that no reader joined. Fixed in367cfd2ad; the app-shell run above exits 0.dispatch-gates --commandsonf66a0104b: 137 derived;--ran: 137 run, 0 NOT-MEASURED, 0 UNRUN, every gate exit 0;--ranitself exit 0 (resumed from an append-only record after a container restart at gate 45). None of the 137 ischeck:spec-symbols, a packagetypecheckor resttest:repo; those are run and listed above.check-changeset-no-major --base origin/main --eventwith this body: exit 0 (declarationClause-②: yes (widening),Metadata-protocol: added)git diff --shortstat origin/main...HEAD, 48 files), up from 3257 by the v4 fix. Over the 3000 threshold, so this PR is Tier H and needs a maintainer approval.f66a0104b6d18598f4132bcc629f66d551d0a244.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr