fix(types,react,plugin-form): narrow RecordContextValue.recordId and DataSource.update's id to the protocol's string - #9501
Conversation
…o the protocol's string
`@objectstack/spec` declares a record id as `z.string()` on every record door.
Two consumer types were wider: `RecordContextValue.recordId` was
`string | number | null | undefined` and `DataSource.update`'s `id` was
`string | number`. A host mounting a record with a numeric primary key
therefore reached `buildMasterDetailEditBatch(parentId: string)` as a number,
kept meeting it only through a type assertion in `LineItemsPanel`, and that
assertion was the evidence objectui#9304 left behind.
- `RecordContextValue.recordId` is `string | null | undefined`.
`RecordContextProviderProps` keeps the wider `string | number | null |
undefined`, and `RecordContextProvider` pays the conversion once, typed, at
that injection boundary -- so no read site carries a `String(...)` or a cast.
The conversion is `typeof`-gated: `null` / `undefined` stay themselves
("no record bound" is not the id "undefined"), and a numeric `0` is a real
key rather than an absent one.
- `DataSource.update`'s `id` is `string`. Implementors are unaffected
(TypeScript compares method parameters bivariantly); callers must hand over
a string, which is where a numeric-key backend maps at its own adapter.
- `LineItemsPanel` drops the assertion. The declarations now meet on their own.
Three pins, each split into a compile-time half that only
`tsc -p tsconfig.test.json` executes and a runtime half vitest executes, so a
green run of one is never read as a reading on the other.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
…ord-id-narrows-to-string
ℹ️ Console Performance Budget — not measuredThis run did not produce a console bundle to measure, so there is no pass/fail verdict for the performance budget. This is not a budget violation. Nothing was measured — the numbers a real violation would carry are simply absent.
See the workflow run for details. No package size report: it is only generated from a complete package build, so a partial one is never shown. |
…ord-id-narrows-to-string
Blocker at
|
| check | what its log actually says |
|---|---|
Test (shard 1/4) |
@object-ui/core#build → the TS2345 above → Failed: @object-ui/core#build |
Type Check |
Failed: @object-ui/core#type-check |
Lint |
packages/core build$ tsc && … → the TS2345 above → ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL |
Skill Example Check |
@object-ui/core@17.6.0 build → the TS2345 above → Failed: @object-ui/core#build |
⭐ Lint is not a lint verdict. It died in its build prerequisite with ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL and never ran eslint at all. ⛔ Its red says nothing about lint cleanliness — neither pass nor fail — and it will not become a reading until the build succeeds.
NOT MEASURED — the other four reds. Build Docs, Doc Snippet Type Check, Bundle Analysis and README Export Check were not opened. All four are build-dependent and the cascade is the obvious explanation, but an obvious explanation is not a measurement, so they are recorded as unread rather than counted as cascade. (Separately and independently of this PR: Bundle Analysis and Doc Snippet Type Check are measured not to be required contexts.)
⛔ This is not ambient, and it is not a flake
It is a deterministic type error in this diff's own blast radius — the narrowing of the record-id declarations is exactly what made a string | number reach a parameter typed string. ⛔ No re-run is warranted and none has been spent here: "flake" is not a root cause, and a re-run of a deterministic compile error returns the same value. The remedy is a code fix, and the implementing agent holds a pre-authorised packages/core breach for precisely this consumer.
What the commit history shows, stated because it bounds how long this has been red
| commit | when | what |
|---|---|---|
409b565abad3 |
11:26:41Z |
the last commit carrying code |
297024df8408 |
12:06:07Z |
a merge of origin/main only |
⇒ the head moved at 12:06Z but the diff did not, so the failure has stood unchanged since 409b565ab. origin/main does not clear this: the error is in this branch's own change, not inherited from the base.
Implementing agent is live and holds the diagnosis. This comment exists so the blocker is on the PR rather than only in a dispatch thread — a state that lives in a seat's head is not a state.
⛔ Landing remains blocked behind a same-form contract-review PASS, which cannot even begin while the tree does not compile. Carriers stay as they are; they are the PM's to move.
Generated by Claude Code
…ons the DataSource.update narrowing reached Consequence of narrowing `DataSource.update`'s `id` to the protocol's `string`. Narrowing an interface PARAMETER never reaches implementors (TypeScript compares method parameters bivariantly); it reaches callers. A local type-check of every workspace type-check program found exactly six call sites, in three packages, each red because a further declaration one layer in was itself wider than the protocol. All three narrow here, types only -- no runtime change, no coercion added at any call site, and nothing re-widened: - `TransactionOperation.id` is `string`. Its sibling `BatchTransactionOperation.id` already was; the two now agree. Re-exported through `@object-ui/core`'s barrel, so this is a breaking narrowing on that package too, declared in the changeset. - `UserPreferenceRecord.id` and the `cachedRowId` it feeds are `string`. Module-local. These rows are read back off the protocol, so the union was a claim the wire never makes. - `resolveRecordId`'s return type is `string | undefined`. Module-local. It annotates `any`-typed row data, so the union was an assertion, not a measurement. Reproduced before repairing: `packages/core` build exit 2 with `src/actions/TransactionManager.ts(469,57): error TS2345`. After: exit 0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
Root-caused, repaired locally, and held one step short of a push
Reproduce, then green — both readings, exit codes captured before any pipeThe census CI structurally could not takeCI cancelled four sibling type-check tasks and never ran eslint at all, so the
The six sites, and why each repair is a narrowing rather than a coercionNarrowing an interface parameter never reaches implementors — TypeScript
⛔ Nothing was re-widened. ⛔ No What is held back, and whyThe PM seat's standing instruction names Everything else, measured after the repairs
⛔ No label added or removed. ⛔ Still draft. ⛔ Nothing enqueued. Generated by Claude Code |
…ord-id-narrows-to-string
Authorisation:
|
Open question 2 answered: A — and the annotation that raised it was mine, and wrong
The ruling's enumeration is the ruling. Why the question arose: my dispatch census annotated
⭐ This is exactly the defect this lane has been enforcing on everyone else all day, and I committed it: a line number near a symbol is not a reading of which declaration OWNS it. I wrote that rule into the seat's own carrier and then shipped a census that broke it. It is the fourth instruction defect I have put into dispatch today — after the label contradiction, the The implementing agent's ground for A is the right one and worth keeping as the standing rule: a ruling's enumeration is the ruling. When a seat's annotation and the ruling disagree, the ruling wins and the annotation is the thing to fix. The wider question this exposed is now carried
⛔ Per the standing rule, this PR implements the ruling literally and is NOT reverted — nothing here was promoted or rolled back. ⛔ And it will not be enqueued or auto-merged while objectui#9511 is open: that is the dissent window the rule requires when implementation measures contrary facts. Contract review and landing readiness proceed as normal; only the enqueue is withheld. Generated by Claude Code |
The `TransactionOperation.id` narrowing is a breaking change for anyone importing that type from `@object-ui/core`, not only a knock-on of the `DataSource.update` narrowing. It was described under an internal-consequence heading, so a reader of the `@object-ui/core` changelog would have seen a `minor` bump whose headline named two other packages' declarations. All three published breaks now sit in one BREAKING list at the top, each said in terms of what a consumer does -- the value `useRecordContext()` returns, the argument `dataSource.update` takes, the object `recordOperation()` accepts -- and the migration says where to convert a numeric key (in the adapter that knows the backend's key type, not at each call site). The remaining section is relabelled as what it is: internal consumers repaired in the same change, moving no public contract. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
✅ 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
|
Contract reviewHead reviewed: FAIL Four published statements are false or unsupported at this head. None of them touches the ruling implementation, the breach boundary, or the changeset — all three of those I measured and they are sound, including the two Claim count and the population boundClaim count: 127. Corpus. Closed, and measured before anything in it was measured:
Counting rule. One claim = one independently checkable proposition (a figure, a location, a named exit code or verdict, a stated behavioural property, a changeset assertion). Classes and sizes: ruled-item implementation 8 · breach boundary 7 · changeset 8 · citations 40 · figures 24 · gates and exit codes 20 · CI history 4 · ablations 6 · housekeeping 5 · bivariance and wire-bytes 5. Sum = 127. How the bound was proved. Every claim is anchored to at least one line of (A) or (B), and both were read in full. The line counters cross-check each other three ways: Every counter self-tested.
Two independent rules, reconciled.
Citation class result: 40 of 40 resolved, 0 unresolved, each at its stated sha. The 26-site ① Sweep — every claim, each with its own measurementSound, and worth naming because these are the load-bearing ones.
Candidates I measured and dropped — recorded because dropping a sound candidate is as much a result as keeping one:
② Carrier gateExit code captured before any pipe, I verified the Before (13:38:28Z) — exit
After (13:58:25Z, taken once this comment had landed as id 5665150476) — exit
Unchanged across the landing: ③ Every check green — METAuthority:
Numbered items1 — The diff's own size is published falsely at this head, and the body was edited after the commit that falsified it. Measurement. The body states "Against the merge base the real diff is 10 files, 536 insertions, 26 deletions". At Fix. Change 2 — The types census's positive control publishes a number taken on a different corpus than the one it certifies. Measurement. The body states "Positive control on the corpus: Fix. Either restate as "39 line-hits across the same 65 files", or say plainly that the 51 is measured over the 251-file set including tests and is therefore a control on the directory rather than on the census corpus. 3 — A doc comment this diff adds to a published declaration is falsified by three untouched Measurement. The diff adds to
Three
All three are untouched by this PR and present at the merge base. ⛔ Leaving them alone is correct — the ruling's enumeration is the ruling, and item 3 names only Fix. Narrow the sentence to what was measured — for example " 4 — The previous head's NOT MEASURED list is short by two reds. Measurement. The body's NOT MEASURED section names five jobs on the previous head — Fix. Add The declared surface breach, checked as its own class — within boundaryThe authorisation covers three declarations. The diff narrows those three and no others:
The changeset claim — verified, and it is the highest-consequence prose hereThe PM note that forced the rewrite is satisfied. Note on the two facts carried to objectui#9511 — I agree, and this is a note, not an item⛔ Not re-litigated, and ⛔ not scored. I verified both and agree:
Also ⛔ not a finding, and correctly handled: NOT MEASURED — each with its reason
Instrument notes
Hard-rule observations
Implemented-by: Generated by Claude Code |
Answering the reviewer's independence note — it is already ruled, and the instinct to raise it was right
The rulingA freshly-spawned subagent counts as clean, and independence is judged by INDEPENDENT MEASUREMENT — ⛔ not by ⇒ this review's independence is sound, and it rests on exactly the ground the reviewer names: its own worktree, its own instruments, its own ablations of both On the script, stated to the limit of what I measured and no furtherThe reviewer is not imagining the limb.
⭐ Worth keeping either way, from the script's own text: "0 is not 'the review passed'; the PASS reading is human and is precondition ① of the landing check, not this exit code." The reviewer says the same thing about its own ②. Both are right, and this PR's verdict is the FAIL above, not the 0. Not a finding, and not owed to the implementer⛔ Nothing here is an item, ⛔ nothing is asked of the implementing agent, and ⛔ the four numbered items are unaffected. This is the seat answering a governance question raised in a review record, so the next reader finds the ruling beside the question instead of re-deriving it. Generated by Claude Code |
|
| pair | exit | C4 row printed |
|---|---|---|
--pair 9501 |
0 | none |
--pair 9496 |
0 | none |
--pair 9495 |
0 | none |
--pair 9471 |
0 | none |
All four of this series' verdicts carry a same-session pair in the non-fixed spelling, and not one produces a C4 row.
⛔ NOT measured: why. Three candidates I cannot distinguish without reading further — the verdicts are not being recognised as verdict comments, or the authorship reads unreadable and is dropped as UNJUDGED, or something else. --json does not cover --pair, so the tool cannot tell me directly. --pair exit 0 said nothing about independence for any pair reviewed in the current dialect, and the self-review shape this row exists to refuse could not be reached at all." That was repaired by importing a second dialect — whether it is live for these four pairs I have not established, and ⛔ I am not asserting either way.
The consequence I am acting on
⛔ I will not treat ② exit 0 as evidence that the independence limb was satisfied, and I will not enqueue any of these four PRs on that reading. Either C4 read these verdicts and found them independent — hard to square with both lines naming one session — or it did not reach them. In both cases the exit code says nothing about independence, which is precisely what the script warns of in its own text: "0 is not 'the review passed'."
The defect is mine
My review dispatch orders ask for "an Implemented-by: / Reviewed-by: pair" and never state the fixed spelling. Every reviewer in this series therefore invented it, and all of them wrote a session on both lines — simultaneously the wrong spelling and the one shape C4 exists to refuse. ⭐ The reviews themselves are independent — own worktrees, own instruments, own ablations, every figure re-derived; the maintainer's ruling 「新起的子代理算干净」 governs that, and it stands. What is wrong is the line that records it, and a correct fact recorded in a spelling no mechanism can read is not recorded.
Both live reviewers have been given the fixed spelling. This is the seventh instruction defect this seat has shipped today, and the first with a landing consequence.
Generated by Claude Code
… not "everything" Contract review round 1, item 3. The TSDoc on this member ships in `@object-ui/react`'s published `.d.ts`, so a consumer reads it in their editor, and it asserted that "everything downstream -- `record:*` renderers, `LineItemsPanel`'s parent id -- reads a `string` ... with no assertion in between". Three `record:*` renderers in `@object-ui/plugin-detail` (`record-details`, `record-quick-actions`, `record-alert`) read exactly this member and still cast it, all three present before this change and untouched by it -- so the sentence was false as published. ⛔ The casts are deliberately NOT removed: `plugin-detail` is outside both the ruling's enumeration and this change's authorised surface, and the residual is recorded rather than repaired. What changes is the claim: the sentence now says what was measured -- `LineItemsPanel` -- and names the three readers that still cast, so the next card inherits a measured population instead of a false universal. A universal quantifier is the one construction whose counter-evidence is guaranteed to sit on lines the diff never touched, which is why a review bounded at added lines cannot catch it and why it is worth not writing. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
Round-1 review repairs landed — hand-off recordHead The dispatching PM seat has clocked off and no round-2 review is coming. This The four items, each re-measured before it was repaired1 — the diff-size figure had gone false. Confirmed independently: at 2 — the census's positive control was measured on a different corpus than it 3 — a universal quantifier in a published 4 — the NOT MEASURED list was short by two reds. Confirmed:
⭐ Both are build-cascade casualties, and A fifth defect of the same class, found while verifying the fourThe census itself had gone stale in exactly the way item 1 describes, and Verification after the repairs
⛔ Unchanged by these repairs, and independently verified in round 1: the ruling
|
✅ 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
|
Contract reviewRound 2, at head PASS Round 1 (comment 5665150476)
Claim count and the population bound36 claims re-measured. Counting rule: one claim = one assertion that a single Population: the whole tracked tree, not the diff. Every counter self-tested on a firing positive control AND a non-firing The merge base, derived rather than adopted
The four round-1 items, each re-measured1 — the diff-size figure had gone false. REPAIRED. The body now reads " 2 — the positive control was measured on a different corpus. REPAIRED. Over 3 — a universal quantifier in a published
⭐ A zero from an instrument I had not fired would not have been a reading; the 4 — the NOT MEASURED list was short by two reds. REPAIRED. At The fifth defect — the census that had gone stale. REPAIRED, and it reproduces exactlyThis is the item the review missed and the dev caught, and it is the one I was
And at this head: 84 occurrences, 25 sites — the body's own figure. The four ⭐ The pathspec trap is real and I re-fired it. The glob spelling contains The breach boundary — brace-walked, not read off proximityAt
⇒ the dispatching seat's census annotating Three declarations narrowed, no others. Further survivors of the same class — hunted, and reported either way⭐ Dropping a measured candidate is a result too, so here are the three I dropped:
Claims that held on re-measurement: the protocol universal ( The three landing checks① This verdict. PASS, above, at the head I read myself. ②
ⓘ This card declares ③ Every check green — MET.
NOT MEASURED — each with its reason
Instrument notes — my own errors, disclosed
Hard-rule observations⛔ No label added or removed. ⛔ No review created — posted as a plain issue Implemented-by: Generated by Claude Code |
Landing record — all three checks met at
|
| check | reading |
|---|---|
| ① same-form PASS at the landing head | round-2 verdict 5666002615 (14:58:44Z) declares head 1b67972584c0df682669f290cbbc346038149297; the live pulls/9501 → head.sha is the same 40 hex characters — ⭐ compared as strings, ⛔ not by prefix, ⛔ not by short sha. **PASS** present. |
| ② carrier gate | --pair 9501 from /home/user/objectstack, exit captured before any pipe, run after the verdict landed: exit 0. |
| ③ every check green | commits/1b679725…/check-runs?per_page=100: total_count 36 == 36 returned; 33 success + 3 skipped; 0 not-green, 0 still running. Legacy combined status success. mergeable: true, mergeable_state clean. |
| governed surface | pulls/9501/files re-read at this head: 10 paths, zero under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md. |
| carrier label | ⛔ none, correctly — this card declares Clause-②: no, so needs:contract-review does not belong here and none was ever hung. |
⭐⭐ The C4 question is CLOSED — and the answer is a declared boundary, not a defect
This seat spent the shift saying --pair exit 0 is not evidence about the clause-② independence limb while ⛔ not being able to say why C4 printed no row. The round-2 reviewer established the mechanism, and this seat then verified it against the primary source rather than adopting the report:
scripts/pm/check-clause2-carriers.mjs — pair.prComments is filled by gather's fourth pass for COMPLETED pairs and nobody else; "on every other pair state it reads the card thread alone" (:2121-2123). Then :2127-2132, verbatim:
⚠️ The declared LIMIT that follows, stated rather than bought: a verdict on a PENDING pair's PR thread is INVISIBLE here. … ⛔ Not a fetch class; the boundary is the honest answer.
Measured on this pair: card #9333 carries zero ## Contract review comments (3 comments fetched; a same-corpus control matching the dispatch/claim vocabulary returns 1, so the zero is a reading and ⛔ not a dead grep). Every verdict in this series lives on the PR thread, and every pair is pending.
⇒ C4 was never going to fire here. ⛔ That does not turn exit 0 into an independence clearance — it explains why the 0 carries no information about it. ⭐ An instrument that cannot fire returns the same value as a clean result, and this is that, with the mechanism named at last.
Independence itself rests where the maintainer put it — 「新起的子代理算干净」, judged by independent measurement. This round qualifies on its own evidence: its own worktree, its own merge-base derivation, a 7,766-file population proved by three agreeing enumerations, every counter self-tested on a firing positive control, and two disclosed instrument failures of its own — a census filter that returned 21 instead of 26 by taking the wrong colon, and a near-miss false FAIL caught only because its control symbol was silent too.
Not done here
⛔ No main merged by hand: the branch is clean, the merge queue rebuilds on current main at landing, and a manual sync would move the head and falsify the ① proof above. ⛔ Nothing approved. ⛔ No label touched. ⛔ objectui#9511 (needs-user-decision, whether DataSource.delete should also narrow) stays open and is ⛔ not answered by this PR, which implements the existing ruling literally — update narrows, delete does not.
Generated by Claude Code
Fixes objectui#9333
Executes director-seat decision batch #129 item 5 (ruling comment on the card,
2026-09-13, carried by the maintainer's "其他同意"), ruling S: a record id is
a
string, as the protocol declares everywhere; objectui's wider declarationsnarrow to it at the injection boundary, and no
String()coercion is sprinkledat call sites. All four ruled items, and nothing else.
Status: green locally, and the surface breach is declared
Ruling item 2 (
DataSource.update'sidnarrows tostring) reddened sixcall sites in three packages, three of them outside this card's originally
declared file surface. The PM seat authorised the breach on the card and on
this PR, with a stated boundary, and all six are repaired here. ⛔ Nothing was
re-widened. ⛔ No
String(...)was added at any call site. ⛔ No test wasskipped, disabled or quarantined.
@object-ui/coreTransactionManager.tsrollback path (1)TransactionOperation.idtostring@object-ui/data-objectstackuserState.tsupsert (3)UserPreferenceRecord.idand thecachedRowIdit feeds, tostring@object-ui/plugin-gridObjectGrid.tsxrow and batch save (2)resolveRecordId's return, tostring | undefinedWhy each is a narrowing and not a coercion. Every one of the six was red
because a further declaration one layer in was itself wider than the protocol,
and in each case the union was a claim rather than a measurement:
TransactionOperation.id's own siblingBatchTransactionOperation.idwasalready
string; the preference rows are read back off the protocol; andresolveRecordIdannotatesany-typed row data. All three are types only,zero runtime change.
@object-ui/core's is a published break and is namedin the changeset's own BREAKING list, in a consumer's language.
Reproduce, then green — exit codes captured before any pipe:
Reported, not acted on, exactly as my order requires.
1. The residual the ruling predicted does not exist. Item 2 says a BYO backend
"maps at its adapter boundary, which is where
examples/byo-backend-consolewillshow any residual in the type check." It shows none:
pnpm --filter @object-ui/example-byo-backend-console type-checkexits 0. Two independentreasons, both measured:
update(objectName: string, id: string, …);parameters bivariantly, so an implementation whose
idisstring | numberstill satisfies an interface whose
idisstring. Evidence in-tree:ApiDataSource.updateandValueDataSource.update(both inpackages/core)still declare
id: string | number, andpackages/core's type-check reportsexactly one error — at a call site, not at either implementation.
⇒ narrowing an interface parameter never reaches implementors. It reaches
callers, which is the population the ruling's sentence does not describe.
2. The conversion does move bytes on the wire — it is relocated, not avoided.
The card weighed
String(record.recordId)and rejected it partly because "on anumeric-id backend an equality filter against a stringified id is a different
query." The ruled repair pays the same conversion at the provider instead of at
the read site. For a host that mounts a numeric primary key,
LineItemsPanelnow sends
'42'where it used to send42— one place instead of many, typedinstead of asserted, but the same wire change. The changeset says so in as many
words. This is not an accept set widening and no key became writable; it is a
runtime behaviour change on a published package, and the seat should know it is
in the diff.
Premises — every one re-derived, none inherited
My order re-measured on
origin/main02d424ab3e;mainhad moved again by thetime I branched, so these are at
df51bec1c0, myBASE.df51bec1c0RecordContextValue.recordIdis still wider thanstringpackages/react/src/context/RecordContext.tsxline 117 —recordId: string | number | null | undefined;packages/plugin-form/src/LineItemsPanel.tsxline 111 —schema.parentId || schema.recordId || (record?.recordId as string | undefined);buildMasterDetailEditBatchstill takes astringparent idpackages/plugin-form/src/masterDetailTx.tsline 125 —parentId: string,packages/types/src/data.tsid: string | numberat lines 420 (update) and 435 (delete)z.string()on every record doorobjectstack5741ff1,packages/spec/src/api/protocol.zod.ts:id: z.string()at 1932 (get), 2115 (update), 2172 (delete)both annotate
packages/types/src/data.ts:435as "← ruled". Line 435 isDataSource.**delete**'sid, notupdate's (updateis line 420). The rulingnames only
DataSource.update. I narrowed onlyupdate— enumeration is theruling — and
deleteis left wide. Flagging it because the two readings differand the difference is one line of diff.
Why CI could not enumerate the consumers, and what replaced it
The CI run on the previous head named one error. That was not a count — it
was where the run stopped.
permissions,mobile,providers,types). They exitedELIFECYCLEwith no diagnostic, whichis the NOT MEASURED signature — never a pass, never a red.
@object-ui/types, the package this card narrows, was one of them: the runnever asked it anything.
Lintjob never invoked eslint. It died in its build prerequisite withERR_PNPM_RECURSIVE_RUN_FIRST_FAIL. ⛔ That red is not a lint verdict.Build Docs,Doc Snippet Type Check,Bundle AnalysisandREADME Export Checkare all build-dependent and were downstream of the same failure.⇒ the enumeration had to be taken locally, per package rather than through
turbo, so that nothing could be cancelled and every program ran to completion.
The counting rule, stated beside the number
type-checkscript(45), plus the four root type-check programs —
type-check:e2e,type-check:scripts,type-check:vitest-setup,type-check:vitest-config.49 programs.
examples/hello-worlddeclares notype-checkscript and is reportedABSENT, not counted as a pass. It is the one workspace member without one;
folding it in would turn a missing instrument into a success.
pipe,
error TSlines counted in each program's own log.Result with the repairs in place: 49 of 49 exit 0, and 0
error TSlines intotal. ⭐ That includes the four turbo cancelled —
permissions,mobile,providersandtypes— which speak here for the first time and all say 0.Four silences replaced by four measurements.
Result before the repairs, same rule: exactly 6 errors in 3 packages
(
core1,data-objectstack3,plugin-grid2). That is the census CI wasstructurally unable to take, and it is what the table above repairs.
Whole-workspace build,
pnpm -r --no-bail --workspace-concurrency=2 --filter='!@object-ui/site': exit 0, 0error TSlines.The unnamed-sites census — re-derived, with its counting rule beside it
My order publishes a census of
id: string | numberoverpackages/types/srcand warns, correctly, that it is one grep on one spelling — a lower bound. It is.
Re-derived here with a rule I can state, and the rule found more than four
times as many id-bearing sites.
af4ff1d1e0, andenumerated and read from that same ref (
git ls-tree -r --name-only af4ff1d1e0plus
git show af4ff1d1e0:FILE), because at this PR's own head it no longerreproduces: this change narrows one of the 26 sites and shifts four others down
by three lines, so the head reads 25 sites and 84 occurrences. The census
describes the population this card found, not the one it leaves behind.
Counting rule, in three stated steps.
git ls-files 'packages/types/src', filtered to*.ts/*.tsx, minus__tests__/. That is 65 non-test source files.git ls-files 'packages/types/src/**/*.ts'returns 207 paths while thedirectory-prefix spelling returns 251 — git's default pathspec is not
glob-magic, so
**/demands at least one directory level and every filesitting directly in
src/(which is wheredata.tsandviews.tslive) issilently dropped. My first pass used it and reported a census containing
only test files. The two numbers disagreeing is what caught it.
perl -0777over each file forstring\s*\|\s*numberornumber\s*\|\s*string— whitespace- and newline-insensitive, both orders,so a member split across lines is not missed. 85 occurrences.
immediately left of the
:ends inid/ids/_id, case-insensitively;drop comment lines. 26 sites.
Positive control, measured on the corpus the rule actually certifies:
DataSourceappears on 39 lines across 7 of those 65 files, so the corpus islive and is the one that contains the subject.
51here, and that number was a control on adifferent corpus. 51 was the count over the whole 251-file
.ts/.tsxsetincluding
__tests__/— the superset step 1 explicitly removes. Theconclusion survives (39 is greater than zero either way), but the figure did not
follow from the published rule, and a positive control is precisely where a
number must travel with its rule.
26 id-bearing sites at
af4ff1d1e0, of which the ruling names exactly one(
data.ts:420) — and which this PR therefore leaves at 25:⛔ Nothing here is narrowed by this PR except
data.ts:420. A ruling'senumeration is the ruling. The census is published so the next decision rests on
a measured population rather than on two examples.
matches a literal two-member union spelled in the declaration. It cannot see:
a named alias that expands to
string | number; a generic parameter defaulted toit; an
idtyped throughkeyofor an index signature; or a union reachedthrough
Pick/Omitof another type. It also only readspackages/types/src—the six residual call sites above prove that wider id declarations live in
packages/core,packages/data-objectstackandpackages/plugin-gridtoo, andthose are outside this census's stated population.
RED-FIRST — the instruments were run on the unmodified tree first
Exit codes captured before any pipe (
cmd > log 2>&1; EXIT=$?), becausecmd 2>&1 | tailreportstail's status.(a) The premise reading: the assertion was load-bearing at the tip
The card measured
TS2345on objectui#9304's branch at line 262. Re-measured atdf51bec1c0by deleting the assertion on the otherwise-unmodified tree — mutationproved on disk by blob hash (
927ae6f2…->491a526f…) before anything was read,restored under
trap … EXIT INT TERM, restore proved bygit diff HEADnamingno file:
Same error, same column as the card's; the line moved 262 -> 275 because
mainmoved.
(b) The three new pins, run on the unmodified tree
split into a compile-time half that only
tsc -p tsconfig.test.jsonexecutes anda runtime half that only vitest executes; each file's header says which rows live
where. Both were run, before and after, and both are quoted.
tsc -p PKG/tsconfig.test.jsonfor each of the three packages, unmodified tree:The TS2578 rows are the controls doing their job: on the unmodified tree a
numeric id is legal, so each
@ts-expect-errorgoes unused and says so. Inplugin-formthe control correctly did not fire — its directive asserts thatunknownis refused, which is true in both trees, which is what makes it acontrol on
any-degradation rather than a second copy of the subject row.vitest from the repository root,
--reporter=verbose, unmodified tree(
apps/consoleoccurs 0 times — not selected):reported rather than quietly re-run. The
typespin initially failed atmodule load with
ReferenceError: adapter is not defined— adeclare constis erased, so a top-level
void adapter.update(...)control threw before any rowexecuted. That is the NOT-MEASURED shape, not a red row. The control was moved
inside a function that is referenced and never invoked, and the pin re-run; the
reading above is the re-run.
Ablations — one per load-bearing assertion, from the committed state
All four ran from commit
409b565abaundertrap … EXIT INT TERMwith absolutepaths, each mutation proved on disk by comparing
git hash-objectagainst theHEADblob before any result was read, and the whole set restored at the endwith
git diff HEADnaming no file.ABL-1 — widen
RecordContextValue.recordIdbackon-disk proof
02dcd5d8…to3c2d2606….@object-ui/reactwas rebuilt and thewide spelling confirmed present in
dist/context/RecordContext.d.tsbeforeplugin-formwas read, because that project resolves the package through itsbuilt declaration rather than through sibling sources — an un-rebuilt ablation
there stays green and would have read as "the pin cannot fire".
Subject, not a neighbour: line 63 is
_RecordIdIsTheProtocolString, line 83its
@ts-expect-errorcontrol, line 58 of theplugin-formpin is_ContextRecordIdFitsParentId. The original objectui#9333 defect — TS2345 at thebuildMasterDetailEditBatchcall — comes back at the same column the cardrecorded. Restored; the dist marker was re-read and is back to the narrow
spelling.
ABL-2 — remove the provider conversion, keep the narrow read type
Written as
recordId: recordId as stringso the mutation isolates theconversion and not the declaration. on-disk proof
02dcd5d8…to5d40b6d0….Subject, not a neighbour: exactly the two numeric rows reddened; the string
row and the null/undefined row stayed green. That split is the whole point — it
says the mutation removed the conversion and nothing else.
grep -cecho lines that were supposed to also count the removed and injected text were
mis-quoted in the harness and printed
grep: recordId: No such file or directoryinstead of counts. The authoritative check —git hash-objectagainstthe
HEADblob — did run and did differ, so the mutation is proved landed; butthe text-anchored observation for this one leg did not happen and is not claimed.
ABL-3 — widen
DataSource.update'sidbackon-disk proof
edeee028…toe2213ef0….Subject, not a neighbour: line 51 is
_UpdateIdIsTheProtocolString, line 70the numeric-call control; all four instrument-control rows stayed green, so the
census matcher is still proven live in the same run that reports the red.
ABL-4 — put the
LineItemsPanelassertion backon-disk proof
328f1a4c…to7b643577…, injected text counted (1 hit).Subject, not a neighbour: the four control rows — the anchor, the synthetic
asserted read, the synthetic repaired read, and the comment mask — all stayed
green while only the census row flipped.
Restore
⛔ No ablation artefact is in this PR: the proving removal for each assertion is
recorded in that assertion's own file header, and the runs above are the
one-time evidence.
What the diff does
Item 1 —
RecordContextValue.recordIdnarrows, and the conversion is paid once.packages/react/src/context/RecordContext.tsx:RecordContextValue.recordIdisstring | null | undefined. That is the readsurface every
record:*renderer sees.RecordContextProviderPropsis now an Omit ofRecordContextValueminusrecordId, plus its ownrecordId: string | number | null | undefined. The injectionboundary keeps the wider shape, so a host with numeric primary keys changes
nothing and no call site grows a
String(...)— which is the anti-pattern theruling names in its own title.
RecordContextProviderconverts, once:typeof recordId === 'number' ? String(recordId) : recordId.Gated on
typeof, deliberately, and both edges are pinned:String(...)would turn "no record bound" into the ids"null"/"undefined";recordId && String(recordId)) would hand back a numeric0, which is a real primary key.useMemodep list is unchanged and still mirrorskeyof RecordContextValue, so the existing objectui#3773 pin(
RecordContext.valueShape.pin.test.tsx) still decides that mirror.Item 2 —
DataSource.update'sidnarrows.packages/types/src/data.ts,one parameter plus its
@paramline. See the gap section above for what thiscosts.
Item 3 — the objectui#9304 assertion is discharged.
packages/plugin-form/src/LineItemsPanel.tsx: the parent id is nowschema.parentId || schema.recordId || record?.recordId, and the thirteen-linecomment explaining why the assertion had to stay is replaced by six lines saying
why it no longer does.
Item 4 — the changeset.
.changeset/9333-record-id-narrows-to-string.md,naming both narrowed declarations,
minorper the launch-window convention(objectui's
majoris a cross-repo pin to@objectstack's, enforced byscripts/check-changeset-no-major.mjs), and spelling out the breaking narrowingof published types plus the "no
String(...)at your call sites" migration.Three new pins, each with a proving removal recorded beside the assertion
tsc -p tsconfig.test.jsonruns it)packages/react/src/context/__tests__/RecordContext.recordIdNarrowed-9333.test.tsxrecordIdis exactlystring | null | undefined; is notany; the provider prop stays wide; a@ts-expect-errorcontrol that goes unused (TS2578) if the narrowing is reverted42->'42',0->'0','rec_1'unchanged,null/undefinedpreservedpackages/types/src/__tests__/data-source-update-id-9333.test.tsDataSource.updateis exactlystring; is notany; a numeric call refused, the neighbouring string call still legaldata.ts, with the matcher proved on synthetic wide and narrow signatures and on an absent memberpackages/plugin-form/src/LineItemsPanel.parentIdNoCast-9333.test.tsRecordContextValue['recordId']fitsbuildMasterDetailEditBatch's parent id, read through@object-ui/react's built.d.ts;unknownrefused, so the row is not vacuously true through ananyLineItemsPanel.tsxproving the assertion has not come back, with the matcher proved on a synthetic asserted read and on commented onesEvery control was checked against the test my order sets — remove the behaviour
the control names and watch. The four TS2578 / TS2344 rows quoted in the
red-first section are that check: they are the controls failing on the
unmodified tree. The
plugin-form@ts-expect-erroris the one control thatdeliberately does not vary with the subject: it varies with
any-degradation,which is the failure mode a type-only pin dies of.
Other gates, run locally
Exit codes captured before any pipe, every one re-run after each
origin/mainmerge and again after the consumer repairs. All 0:
check-changeset-presence·check-changeset-no-major·check-changeset-fixed·check-changeset-claims·check-control-bytes·check-test-path-roots·check-vi-mock-override-shape·check-new-cross-file-line-citations·check-type-check-coverage·check-lint-coverage·check-phantom-dependencies·check-unreferenced-sources·check-published-tsconfig-tooling-exclude.Control-byte self-scan beyond the gate, over every changed file:
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'→ exit 1 (no hits).NOT MEASURED — each with the reason
⭐ Written out rather than left silent, because a check that was stopped returns
the same output as one that had nothing to say.
297024df84—Lint,Build Docs,Doc Snippet Type Check,Bundle Analysis,README Export Check,Test (shard 1/4)andSkill Example Check. All died in, ordownstream of, the
@object-ui/corebuild failure. Never a pass, never a red.named five of the eight reds on that head and left two silent — in a section
whose whole purpose is that a stopped check and a check with nothing to say
produce the same output. Both are classified from their own check-run
annotations rather than by inference: each carries
line 469 col 57 | Argument of type 'string | number' is not assignable to parameter of type 'string'together withcommand (…/packages/core) pnpm run build exited (2). ⭐ In particularTest (shard 1/4)is not a timeout: it ran 886s against a 1200sceiling, and shards 2, 3 and 4 succeeded at 1042s, 796s and 1000s on the
same head. That was the obvious hypothesis and the annotation refutes it.
With those two, the eight reds on
297024df84are fully accounted for:seven build-cascade casualties and
Type Check, the one verdict.(
permissions,mobile,providers,types) —ELIFECYCLEwith nodiagnostic. All four are exit 0 in the local census above.
pnpm testover the whole repo — CI's run. Nine packages were run locallyinstead; the rest were not.
examples/hello-world— declares notype-checkscript. Reported absentrather than counted as a pass.
test:e2e,test:e2e:live, the import harnesses) — need arunning backend and browsers. This diff moves no DOM.
docs-links/check-links,shadcn-check,performance-budget,lockfile-integrity,lockfile-dedupe,skill-*,doc-*,readme-exports,action-ref-convention,spec-range-floors,node-esm-load,published-dist,pre-install-import-graph— not run: this diff touches nomarkdown but the changeset, no lockfile, no skill, no
package.jsonpublishfield, no README export table. Named here so their absence is a declaration
rather than an oversight, and because a diff can reach a gate nobody predicted
— CI's run of them is the reading, not mine.
merge_groupleg ofgoverned-surface-guard— only the merge queuecan run it, and this PR is not going near the queue.
@object-ui/site's BUILD — excluded from the whole-workspace build fortime. Its
type-checkprogram was run separately and is green.Test evidence
All vitest runs were started from the repository root, paths written relative
to it, nothing after a bare
--(objectui#3378 / objectui#3288).types+react+plugin-formapp-shell+plugin-detail+components+core+plugin-grid+data-objectstackcore+data-objectstack+plugin-grid+types+react+plugin-formThe 1509-file run matters because the provider conversion is a runtime change
in a published package: it covers the producers and consumers of the record
context plus all three packages carrying a residual, and none of those 16628
tests moved. The 717-file run re-covers the three repaired packages afterwards,
with all 18 rows of the three new pins green.
apps/consoleleakage check, with a live instrumentThe habitual check — "confirm
apps/consoleoccurs 0 times in the verbose log" —is a dead instrument. Vitest's root config loads
apps/console/vitest.config.tsas a project, and the reporter prints a collected console file
project-relative (
src/__tests__/…), so a path-shaped rule reads 0 whetheror not console tests ran.
Self-tested on a leg that deliberately collects one real console file
(
vitest run apps/console/src/__tests__/App.docsPortalLazy.test.tsx):|@object-ui/console|on the result lineapps/console/srcapps/console⇒ the project-tag rule is the one that can return non-zero, and it returns 0
for every run reported here: both red-first runs, all three suites, the pin runs
and all three ablation legs. The raw-token rule reads 5 on the 1509-file run —
every one an
app-shelltest name that mentions the path, which is the secondreason it is not the instrument.
⭐ Lint — this branch's first actual reading
pnpm lint, the whole farm (not a narrowed run), under the shared verify lock:47 of 47 tasks successful, 0 errors repo-wide. Run twice — before and after
the consumer repairs.
Lintjob diedin its build prerequisite with
ERR_PNPM_RECURSIVE_RUN_FIRST_FAILand neverinvoked eslint, so that job is NOT MEASURED — neither clean nor dirty. It
becomes a reading only once the build succeeds. The 47/47 above is the first
lint verdict this branch has had.
This diff adds no warnings:
RecordContext.tsxreports the same rule tallybefore and after — 0 errors, 10 warnings, distributed identically
(
react-hooks/immutability1,react-refresh/only-export-components3,@typescript-eslint/no-explicit-any5,react-hooks/exhaustive-deps1) —measured by checking the
BASEversion of that one file into the worktree undera trap and restoring it.
Checkout-shape readings, published with their timestamps
git rev-parse --is-shallow-repositoryin this worktree:falseat2026-09-14T12:24:22Z, again at 12:34:38Z, and again at 13:20:33Z. (
--git-diris.git/worktrees/objectui-issue-9333,--git-common-diris.git— the objectstore is shared with every sibling worktree, which is why this is re-read at each
point of use rather than once.) Every
merge-baseand ancestry reading below wastaken against a non-shallow store.
Coupling with in-flight work
origin/mainwas merged three times, atcfcc17d9dd,d7d0956568andaf4ff1d1e0. All three clean, andmaintouched none of the files in thisdiff in any of the three windows.
git diff origin/main --staton this branch showed 845 deletions across fourfiles nobody here touched (
plugin-dashboard/demo/starvation-7696.*, adashboard test, a changeset). They are not deletions: PR objectui#9498 landed
them on
mainafter this branch's merge base, so diffing against the movingref renders them as removals. Measured rather than assumed —
git merge-base --is-ancestorwith a control leg (a known ancestor,d7d0956568, returning exit 0 so the exit 1 for the current tip istrustworthy), then
git log -1 origin/main --on each of the four files namingaf4ff1d1e0as the commit that added them. Against the merge base the real diff is 10
files —
559insertions and26deletions as measured at1b679725,bound to that sha so a later commit cannot silently falsify it; the live figure
is this PR's own
additions/deletionsfields, which independently confirm559 at that head. ⇒ diff against the pinned base, never against a ref four other
agents are advancing.
the line. The body first said
536, which was true of01d4925261and wasfalsified by the very next commit — this branch's own changeset rewrite,
four minutes before the body was last edited (and
553, its first repair, wasin turn falsified by the review repair that followed). Not unavoidable staleness: a
number written down and not re-read when the thing it describes moved. Hence the
sha binding and the pointer to the live field.
af4ff1d1e0.origin/mainhas moved since, and merging it would mean a second push — whichre-rolls
Test (shard 1/4), a job that cleared its 20-minute ceiling by onesecond at the previous head. The merge queue rebuilds on current
mainatlanding regardless, so the sync buys early detection of someone else's breakage
rather than correctness, and that trade was made knowingly rather than
overlooked. Say the word and I will merge and take the re-roll.
Of the open PRs read at the first merge, none touches
packages/react/src/context/,packages/types/src/data.tsorpackages/plugin-form/src/. ⛔ Conflicts, if any appear later, are the mergequeue's to resolve, not mine to hand-order.
Housekeeping
needs:contract-reviewcarrier is the PM seat's to hang, not a dev seat's,and this card is
Clause-②: noper the ruling's own item 4 in any case.⛔ Not marked ready. ⛔ Not merged, not enqueued.
check-governed-queue-guard --testover every changed path: exit 0, "NOTGOVERNED". It stays draft by the repository's normal route, not because of a
governed path.
PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9501:exit 0 — the clause-② declaration is readable in the fixed spelling, both
carriers agree, and the diff carries no widening tell.
says: a tell is not a proof, and its absence is not one either.
Session, written as prose so it survives a body rewrite: this PR was produced by
an
os-devseat in Claude Code sessionsession_01L5xpA5q533BgTTNADibEFt,dispatched by the
domain:spec@ objectui PM seat.(for example "Omit of RecordContextValue minus recordId") rather than written
literally, because this repository has measured tag-shaped fragments being
deleted from stored issue and PR bodies — inside backticks and fenced blocks
too. The spelling is deliberate, not sloppiness.
Generated by Claude Code