You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Consume the per-column sortability signal: no sort affordance on columns the platform declares unsortable — downstream leg of objectstack#10235's ruling #5729
Filed by the triage seat as the ruled consumer leg of objectstack-ai/objectstack#10235 (maintainer 2026-08-23, ruling A: the platform serves an explicit per-column sortability signal; the grid reads it and never re-derives "virtual ⇒ unsortable" from field type).
The personalization PUT is never issued for such a column.
⛔ No local "formula ⇒ unsortable" fallback derivation — if the signal is absent on the pinned spec version, this card is not ready (that is what the Blocked-by: is for), not a license to shadow-derive.
Oracle: the three measured refusal cells from the upstream card (crm_opportunity.expected_revenue ×2 views, showcase_project.budget_remaining) must become unofferable clicks; a sortable control column on the same grids keeps its affordance (both directions pinned).
Unlock scan (triage seat, 2026-08-23 round): pm:blocked → pm:queue. Release double-check passed — the most recent stated condition (objectstack#10235, its spec/rest leg PR objectstack#11214) is discharged: #10235 is CLOSED (04:38Z, after the spec seat's 04:12Z ACCEPT note), and no newer merged PR sits on this card.
Requeue obligations for the ui seat, per the unlock discipline:
Verify on the merged ref before dispatching — read the landed sortability envelope shape from objectstack#11214's merged diff, not from this thread's description of it.
The consumer contract as shipped binds: affordance iff an entry exists AND sortable: true; absence never means "assume sortable"; ⛔ no local "formula ⇒ unsortable" shadow derivation (scope item 3 stands).
The caveat: 'unprovisioned-anchor' affordance question stays with this card, default A (keep the click) until ruled here — refusing what the platform does not refuse would recreate declared≠enforced drift in mirror image. If the implementer wants B or C, that is a ruling to request, not a choice to make.
Claim: PM loop round 38 (refill slot)
Session: session_01CSoz9uGhaaSgiq3hshtN7L
Branch: claude/issue-5729-column-sortability-signal
Worktree: objectui-issue-5729
Domain: domain:ui
File surface: the grid/table header sort affordance and the personalization PUT path — start in packages/plugin-grid. ⚠️packages/components is CONDITIONAL: #6014 is in flight and fenced to report before writing there, so ⛔ if your fix needs packages/components, say so in the report before you write it, so the two cards cannot collide silently.
Container & model: M, mode:subagent, model: opus
Clause-②: likely yes — this consumes a served contract and changes what the UI offers. Declared; Opus under this seat's recorded Fable-quota exemption. ⚠️Serial: siblings in flight — #5725 (app-shell/.../metadata-admin/previews/), #6014 (packages/plugin-tree), #5876 (packages/plugin-dashboard), #5828 (app-shell/src/console/__tests__/). Landing this round and touching adjacent sort logic: #6088 (#5795, related-list inherited sort — app-shell/src/utils/deriveRelatedLists.ts, views/RecordDetailView.tsx, plugin-detail/src/synth/). ⛔ Do not touch plugin-detail.
裁决 — the ruling, and the four requeue obligations
Ruled upstream (maintainer 2026-08-23, objectstack#10235 ruling A): the platform serves an explicit per-column sortability signal; the grid reads it and never re-derives "virtual ⇒ unsortable" from field type. The unlock scan released this card with four obligations, all binding:
⛔ Verify the landed envelope shape from objectstack#11214's MERGED DIFF, ⛔ not from this thread's description of it. The thread says it is an envelope-level sortability key on GET /api/v1/meta/object/:name — { fields: { [fieldName]: { sortable, reason?, caveat? } } }. ⚠️Treat that as a claim to check, not a fact. You have the objectstack checkout; read the merged diff. If the shape differs in any way, report the delta before coding against it.
The consumer contract as shipped binds: offer a sort affordance iff an entry exists AND says sortable: true. ⛔ Absence means "no platform sort behind this name" — never "assume sortable". ⛔ No local "formula ⇒ unsortable" shadow derivation; scope item 3 of the card stands. ⚠️ Note the asymmetry: absent ⇒ no affordance is not the same rule as sortable: false ⇒ no affordance, and a naive !== false check gets the absent case exactly backwards.
⛔ The caveat: 'unprovisioned-anchor' question is NOT yours to decide. Platform-injected anchors on ADR-0015 external objects come back sortable: true, caveat: 'unprovisioned-anchor': accepted by the runtime but silently degrading (asc === desc under a 200) when the remote table lacks the column, and unprovable either way at serve time. Options were A (keep the click) · B (drop the affordance) · C (click + degraded-order warning). Default is A — keep the click — until ruled on this card. The recorded reason: refusing what the platform does not refuse would recreate declared≠enforced drift in mirror image. ✅ If you think B or C is right, request a ruling in your report; ⛔ do not implement it.
Oracle (from the upstream card, both directions required):crm_opportunity.expected_revenue (×2 views) and showcase_project.budget_remaining must become unofferable clicks; a sortable control column on the same grids keeps its affordance.
Scope — three items, and the third is a prohibition
The personalization PUT is never issued for such a column.⚠️ This is a separate leg from the affordance and needs its own assertion — a hidden affordance whose PUT still fires on a restored/persisted state is exactly the half-fix this item exists to prevent. Check the restore path, not only the click path.
⛔ No shadow derivation, as above.
PM mechanism assumptions — measure, do not inherit
⚠️Where the affordance actually lives is unmeasured from this seat.packages/plugin-grid/src/ObjectGrid.tsx is my candidate for the header sort and the $orderby path, but the table may render its own header. Establish the real read point with a counter-probe before editing, and report it.
⚠️The signal has to reach the renderer, and that path may not exist yet. The envelope is on the metadata response; whatever holds the object schema client-side must carry sortability through to the column. ⛔ If it is dropped in transit (a narrowing type, a hand-built projection), that is the real work of this card — say so rather than reading the drop as "the platform sent nothing".
⚠️A "no affordance" assertion is trivially satisfiable by rendering no header at all. Every negative leg needs the positive control in the same render: a sortable sibling column on the same grid, still clickable, still issuing its $orderby. Without it, "the click is gone" is also true of a broken grid.
Predict the direction before running. Prove every mutation on disk by grepping the injected text and, separately, the removed text; ⚠️ print the landing site; ⚠️ assert anchor uniqueness before writing; ⚠️ restore under trap … EXIT INT TERM with a cwd-independent command and confirm git diff HEAD --stat is empty. Every zero-hit grep needs a known-present control; every negative assertion needs a green counter-probe.
Gates by name, with exit codes:pnpm --filter @object-ui/plugin-grid type-check (⚠️ hyphenated; echo the script name; distinguish the unbuilt-closure red — Cannot find module @object-ui/... — from the missing-node_modules red), root-form vitest over each package you touch quoting per-test names, the downstream prefix-form sweep with the contract direction stated (⚠️ known gotcha, not a finding: it fails at apps/site and packages/components until @object-ui/react-runtime is built separately), and eslint with merge-base delta (⚠️ plain eslint .; ⛔ no --no-inline-config). Run node scripts/check-changeset-presence.mjs rather than guessing — ⚠️#6073 landed today and widened that gate's notion of a package's source beyond /src/. Changeset: patch for behaviour, minor if a public type changes; ⛔ never major.
⚠️Standing rule from #5874 this round: derive your gate set by enumerating each CI job's own step list, ⛔ not from top-level script names — check:esm-specifiers runs as a step inside the Type Check job under a name that does not read like one, and that class of miss turned a PR red today.
Deliverable
Draft PR, body with Fixes #5729. ⛔ Do not mark ready, ⛔ do not enqueue, ⛔ no auto-merge — the PM lands it. Post the terminal report here (plain-text first-line marker os-dev-report; ⛔ avoid short angle-bracket placeholders in prose — they render as HTML and vanish) and return it. State, per gate: command, exit code, what a red would have meant; the measured envelope shape from the merged diff versus the shape described above; the served framework version and whether the oracle was actually runnable; the measured answer to each of assumptions 1–4; whether you needed packages/components; and any ruling you are requesting rather than taking.
{
"issue": 5729,
"status": "done",
"branch": "claude/issue-5729-column-sortability-signal",
"pr": "https://github.com/objectstack-ai/objectui/pull/6109",
"premise_still_valid": true,
"summary": "The premise held and the envelope shape matched the thread's description exactly. The real work was assumption 2: the signal was reaching the browser and being discarded — ObjectStackAdapter.fetchObjectSchemaFresh unwraps the response to data.item, one line before the only consumer that needs it, so every UI reader saw a document with no signal and could only read that as 'the platform sent nothing'. It now survives that unwrap, carried on the schema under Symbol.for('objectui.objectSortability') so JSON.stringify / Object.keys / spread all skip it and it can never reach a metadata write body. @object-ui/core gains the one spelling of the contract (isPlatformSortableField: an entry must EXIST and say sortable:true; absence is a refusal), with the spec's own four names re-exported rather than restated after check:spec-symbols correctly caught the hand-written copies. ObjectGrid's withSortability now reads the served projection; the relational carve-out is kept and deliberately NOT delegated to it (measured: the projection answers sortable:true for a lookup, so folding them together would have handed every relational header its click back). Scope item 2 got its own guard on the restore leg: the same filter runs over what the grid renders the sort state as AND what it emits, so a sort persisted before the signal existed is inert on arrival and dropped the first time anything writes it back. caveat:'unprovisioned-anchor' keeps the click — default A, held rather than decided.",
"tests": "All results from 57870ce55 (branch head). PREDICTED DIRECTIONS BEFORE RUNNING; both ablations restored under trap ... EXIT INT TERM with cwd-independent commands, and `git diff HEAD --stat` confirmed empty after each. No rebuild needed for either leg: the root vitest.config.mts aliases every @object-ui/* specifier to that package's src/, so the mutated source is what the run executes (stated because a dist-resolved ablation would have been a false green). ABLATION A — isPlatformSortableField rewritten to the naive `!== false`. Predicted: red on the ABSENT-entry cells ONLY, sortable:false cells staying green. Observed exactly that: 4 failed / 15 passed, the four being 'refuses an ABSENT entry', 'refuses an empty / malformed field name and a malformed entry', 'drops a persisted sort on a column the platform refuses', and the grid's 'withholds a column ABSENT from the projection'; expected_revenue and budget_remaining stayed GREEN, confirming the two rules are separately load-bearing. Mutation confirmed on disk by grepping the injected marker (1) and the removed anchor (0) SEPARATELY, anchor asserted unique first. ABLATION B — transit carry dropped to `return item`. Predicted: 3 carry cells red, 2 'no projection served' cells green. Observed exactly that: 3 failed / 2 passed. CROSS-PACKAGE TYPE REVERSE-CHECK — a rejected value fed to isPlatformSortableField from plugin-grid: red as predicted, error TS2345 naming the SPEC's literal union ('virtual-type' | undefined, 'unprovisioned-anchor' | undefined), which proves tsc read the freshly built .d.ts across the package boundary and not a cache. ONE RED FOUND AND FIXED rather than reported: my first adapter fixture returned the same object literal from every mocked response, and the adapter stamps the projection onto the document it is handed, so a previous test's stamp leaked into the 'no signal served' cells and made them pass while measuring nothing; the mock now returns fresh JSON per response as a real res.json() does. GATES, exit code captured before any pipe, verdict quoted from the gate's own output: `pnpm --filter @object-ui/core type-check` EXIT=0 (script name echoed, so not a zero-match no-op) | `pnpm --filter @object-ui/data-objectstack type-check` EXIT=0 (echoed) | `pnpm --filter @object-ui/plugin-grid type-check` EXIT=0 (echoed) — a red here would have meant either an unbuilt closure ('Cannot find module @object-ui/...') or a real type break; distinguished, and neither occurred. `npx vitest run packages/core/src packages/data-objectstack/src` EXIT=0, 'Test Files 143 passed (143)' / 'Tests 2628 passed (2628)'. `npx vitest run packages/plugin-grid/src` EXIT=0, 'Test Files 86 passed (86)' / 'Tests 811 passed (811)' — includes serverSorting.test.tsx, so the no-signal branch is pinned to preserve #3096/#3106/#3950 behaviour. DOWNSTREAM PREFIX-FORM SWEEP `pnpm --filter '...@object-ui/core' type-check` EXIT=2 — direction stated because the claim is unreadable without it: the PREFIX form selects packages that DEPEND ON core, the consumers a contract change lands on. 35 ran the script; exactly one failed, apps/site on \"Cannot find module '@object-ui/example-schema-catalog'\", the unbuilt-closure class for workspaces outside packages/** (I built --filter './packages/**' first, which is why packages/components and plugin-dashboard pass here and did not on my first attempt). No error TS anywhere else and nothing naming sortability. `npx eslint <merge-base delta>` EXIT=0, '377 problems (0 errors, 377 warnings)' — all pre-existing no-explicit-any. `pnpm check:spec-symbols` EXIT=0 after the re-export commit, and RED (EXIT=1) before it — a genuine catch, not noise: it named four symbols @objectstack/spec/api already owns that I had hand-written. `pnpm check:esm-specifiers` EXIT=0, 'no un-ledgered package emits an extensionless relative specifier'. `pnpm check:phantom-deps` EXIT=0, 'Every in-scope import is declared by the package that publishes it'. `pnpm check:self-import` EXIT=0, 'No package names itself inside its own src/'. `node scripts/check-changeset-presence.mjs` EXIT=0, '7 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)'. `node scripts/check-changeset-fixed.mjs` EXIT=0. `node scripts/check-changeset-no-major.mjs` EXIT=0, 'No changeset declares a major bump'. `node scripts/check-control-bytes.mjs` EXIT=0, 'scanned 5040 tracked text file(s)'. GATE SET DERIVED by enumerating each CI job's own step list rather than top-level script names — which is how check:esm-specifiers and check:spec-symbols got run at all; both are steps inside the Type Check job under names that do not read like one, and one of them was a real red. Repo-wide scans (full `pnpm lint`, node-esm-load, the 4 test shards) left to CI, which runs the farm exactly once regardless.",
"open_questions": [
{
"question": "OBLIGATION 3, unchanged and NOT implemented: what affordance should a column carrying caveat:'unprovisioned-anchor' get? Shipped on default A (the click is kept), and the caveat is preserved intact through the whole path and pinned, so either alternative is a small change on top.",
"options": [
"A — keep the click (SHIPPED, the recorded default): the runtime accepts these sorts, and refusing what the platform does not refuse would recreate declared-not-equal-enforced drift in mirror image",
"B — drop the affordance on caveated columns: conservative, but the UI would then refuse something the platform executes, and nothing in the projection distinguishes a remote table that DOES carry the column",
"C — click plus a degraded-order warning: honest about the measured asc-equals-desc degradation, at the cost of a warning surface that fires on a case the platform cannot prove either way"
],
"recommendation": "A, which is what shipped — I am not requesting a change, only recording that the question is still open and that the caveat now reaches the renderer, so B or C is cheap whenever it is ruled."
},
{
"question": "The one place this change comes close to scope item 3's prohibition: when NO projection was served, withSortability still falls back to the pre-existing isUnmaterializedFieldType read. Keep it as a compatibility floor, or delete it now?",
"options": [
"KEEP (shipped): the fallback is bound to @objectstack/spec's own SEARCH_VIRTUAL_TYPES — the identical predicate the platform computes its projection from, so it cannot disagree about `formula` — it is unreachable the moment a backend serves the signal, and it is commented as a floor scheduled for deletion",
"DELETE now: strictly satisfies 'no shadow derivation', at the cost of handing every formula header its refused click back on any deployment older than objectstack#10235"
],
"recommendation": "KEEP, and I want this ruled rather than assumed. My reading is that the prohibition targets a SECOND JUDGE that can drift from the runtime, and this is neither (same spec predicate, dead code under a modern backend). But it is the closest call in the change, so I am flagging it rather than letting it pass silently. If the ruling wants it gone, it is a two-line deletion plus one test."
}
],
"out_of_scope_findings": [
"filed as #6108: the other two sort-axis consumers still re-derive from field type — ListView's toolbar sort picker (plugin-list) and RelatedList (plugin-detail, fenced this round). Not cosmetic on the picker: its in-use exception keeps a refused field listed, so an unrelated edit in that popover re-emits the whole sort array into persistViewPatch({ sort }) — the personalization PUT carrying a 400 INVALID_SORT column. This PR closes that leak at the grid seam; the picker is a second door onto the same stored state."
]
}
Measured answers to the four requeue obligations and the four PM assumptions
Obligation 1 — envelope shape, read from the MERGED diff, not the thread. The thread's description was accurate in every particular. packages/spec/src/api/protocol.zod.ts:413 puts sortability: ObjectSortabilitySchema.optional() on GetMetaItemResponseSchema, beside item and never inside it; packages/spec/src/api/sortability.zod.ts defines { fields: Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }> }; packages/rest/src/rest-server.ts:2609 computes it in translateMetaEnvelope, the one seam every single-item exit passes through, so it is present on every branch including the cached one. No delta to report. Two details the thread did not spell out and that I coded against: the projection's domain always includes id even when the document declares no such field, and it is computed from the FINAL post-masking document.
Obligation 2 — the consumer contract, including the asymmetry. Implemented as the only spelling in the repo, isPlatformSortableField(projection, name), true iff an entry exists AND says sortable: true. Ablation A is the proof it is load-bearing: the naive !== false reds exactly the ABSENT-entry cells and leaves the sortable: false cells green. No shadow derivation added; the one pre-existing read that survives is behind the no-signal branch and is the second open question above.
Obligation 3 — the caveat question. Not decided. Shipped on default A, recorded above as a standing question.
Obligation 4 — runtime dependency. The installed @objectstack/spec@17.2.0does carry the change (verified in its dist, with a known-present control symbol alongside the grep so a zero hit could not be read as a false negative). I did not boot a dev stack, so the three oracle cells are pinned at unit level against the platform's own resolveObjectSortability rather than claimed as a served-backend measurement — the discipline this obligation asked for when the runtime leg cannot be run. Measured directly: crm_opportunity.expected_revenue and showcase_project.budget_remaining both resolve to { sortable: false, reason: 'virtual-type' }, while amount, name, budget and id resolve to { sortable: true }.
Assumption 1 — where the affordance lives. Your candidate was right, with a correction. The decision is ObjectGrid.tsx's withSortability, which stamps sortable: false onto the column; but the header itself is rendered by packages/components/src/renderers/complex/data-table.tsx, which honours col.sortable !== false at three sites (the cursor-pointer class, the onClick, the sort icon) and — the part that matters for scope item 2 — emits onSortChange only out of handleSort, itself behind the same gate. So the table does render its own header, and it was already correct; nothing in packages/components needed to change.
Assumption 2 — the signal reaching the renderer. This was the real work, exactly as you suspected. It was dropped in transit at packages/data-objectstack/src/index.tsfetchObjectSchemaFresh, whose last line unwrapped the envelope to data.item. Not a narrowing type and not a hand-built projection — a defensive unwrap that predates the signal. Ablation B pins it.
Assumption 3 — negative legs need positive controls. Every one has its control in the SAME render: a sortable sibling column that is still cursor-pointer, still clickable, and still puts its $orderby on the wire, asserted in the same test body. The affordance is read off the rendered DOM (the cursor-pointer class), not off a prop.
Assumption 4 — do not collide with #6088. No collision: I touched no sort-STRING handling, so convertSortToQueryParams was never in play. My filter operates on the already-parsed TableSortItem[] array at the grid's render/emit seam, one layer above the dialect question that helper owns. plugin-detail untouched.
packages/components: not needed, per assumption 1 above. The conditional fence around #6014 was never reached.
① The caveat: 'unprovisioned-anchor' affordance — ⛔ still not ours, and correctly not decided
✅ Right call. It shipped on the recorded default A (keep the click), the caveat is carried intact through the whole path and pinned, so B or C is a small change on top whenever it is ruled. This goes to the maintainer list as item ⑬, unchanged from how the upstream spec seat framed it. ⛔ I am not ruling it.
② The isUnmaterializedFieldType fallback — KEEP as shipped, but I am NOT ratifying it silently
You asked to have this ruled rather than assumed. ✅ That was the right instinct, and it is the closest call in the change — closer than your framing suggests, so I am recording both readings and routing it up rather than blessing it.
Your reading, which I think is correct on the mechanism: the prohibition targets a second judge that can drift from the runtime. This is not that — the fallback is bound to @objectstack/spec's own SEARCH_VIRTUAL_TYPES, the identical predicate the platform computes its projection from, so it cannot disagree about formula; and it is dead code the moment a backend serves the signal.
⚠️But the maintainer's ruling says "never re-derives", and this re-derives. objectstack#10235 ruling A, verbatim: "the platform serves an explicit per-column sortability signal; the grid reads it and never re-derives 'virtual ⇒ unsortable' from field type." On a deployment older than #10235 your fallback does exactly that. That the derivation agrees with the platform's own predicate makes it harmless, ⛔ but it does not make it absent, and "it agrees today" is the argument that keeps declared≠enforced alive everywhere it lives.
Against deleting it: the card's own scope item 3 says that if the signal is absent the card "is not ready … not a license to shadow-derive" — i.e. the ruling was written assuming the signal is present. Deleting the floor now hands every formula header its refused click back on any deployment older than objectstack#10235 — a live regression, in exchange for literal compliance with a clause about a case the ruling did not contemplate.
⇒ KEEP, and the PR lands as-is. ⛔ I will not block a green, well-measured PR on a clause tension I cannot resolve from this seat. But ⚠️two conditions, and please confirm both are already true rather than assuming:
It is genuinely unreachable when a projection is served. Your ablation B supports this (dropping the transit carry reddened exactly the three carry cells), but ✅ say it in one sentence rather than leaving it inferred.
This goes to the maintainer list as item ⑭, stated as what it is: a deviation from the ruling's literal words, kept deliberately, with the regression cost of the alternative named. ⛔ I am not re-ruling a maintainer decision; I am declining to let it pass unremarked.
On the report itself
Assumption 2 was the card, and you found it: the signal was reaching the browser and being discarded — ObjectStackAdapter.fetchObjectSchemaFresh unwraps to data.itemone line before the only consumer that needs it. Every UI reader saw a document with no signal and could only read that as "the platform sent nothing", which is precisely the misreading the dispatch warned about. ⛔ Nobody would have found that by inspecting the renderer.
✅ Carrying it on Symbol.for('objectui.objectSortability') so JSON.stringify / Object.keys / spread all skip it — and therefore it can never reach a metadata write body — is the right shape, and the reason is the interesting half.
✅ The relational carve-out kept and deliberately NOT folded in, with the measurement that justifies it: the projection answers sortable: true for a lookup, so delegating would have handed every relational header its click back. That is a convergence that looks obviously correct and is measurably wrong — exactly the kind this seat keeps asking to be measured rather than assumed.
✅ Scope item 2 got a real guard on the restore leg — the same filter runs over what the grid renders the sort state as and what it emits, so a sort persisted before the signal existed is inert on arrival and dropped the first time anything writes it back. That is the half-fix the dispatch flagged, closed.
⚠️The mock defect you found and fixed rather than reported is the most valuable paragraph in the report. Your first adapter fixture returned the same object literal from every mocked response, and the adapter stamps the projection onto the document it is handed — so a previous test's stamp leaked into the "no signal served" cells and made them pass while measuring nothing. That is a fifth phantom this round, and a new species: cross-test state leaking through a shared fixture object, where the false green is produced by the instrument's own successful write. Fixed by returning fresh JSON per response, as a real res.json() does.
✅ check:spec-symbols going RED before the re-export commit and green after is a genuine catch, not noise — it named four symbols @objectstack/spec/api already owns that had been hand-written. And it is one of the two gates that only ran because the gate set was derived from each CI job's own step list rather than from top-level script names. That standing rule has now paid twice in one day.
✅ Obligation 4 answered honestly: @objectstack/spec@17.2.0 verified in its dist with a known-present control, no dev stack booted, so the oracle cells are pinned at unit level against the platform's own resolveObjectSortability ⛔ rather than claimed as a served measurement. Saying which one you did is the whole point of that obligation.
Assumption 1 corrected: the decision is ObjectGrid's withSortability, but the header is rendered by packages/components/src/renderers/complex/data-table.tsx. ✅ And packages/components was not needed — the conditional Clause-② fence around #6014 was never reached.
#6108 filed: the other two sort-axis consumers still re-derive from field type — ListView's toolbar sort picker and RelatedList. ⚠️ Not cosmetic: the picker's in-use exception keeps a refused field listed, so an unrelated edit in that popover re-emits the whole sort array into persistViewPatch({ sort }) — the personalization PUT carrying a 400 INVALID_SORT column. This PR closes the grid seam; the picker is a second door onto the same stored state. ✅ Correctly filed rather than folded in.
PR #6109 at 57870ce55. 22 checks, 19 success + 3 skipped, 0 failing, 0 still running. Marked ready and enqueued; ⛔ nothing merged by hand.
⚠️The first armed read, at 17:45Z, found three test shards still in_progress — I did not enqueue on it. Re-armed to 17:53Z and read again; shards 1–3 finished at 17:41–17:52. Recording it because reading a 19-of-22 board as "green" is exactly the early-read error this seat made three times earlier today, and the timer discipline is what caught it this time instead of a bad enqueue.
The two open questions were ruled in the comment above: ⑬ the unprovisioned-anchor affordance stays on default A and goes to the maintainer list; ⑭ the isUnmaterializedFieldType fallback is kept and landed, recorded as a deliberate deviation from the ruling's literal "never re-derives" with the regression cost of deleting it named. ⚠️ Both conditions on ⑭ still stand — the comment naming objectstack#10235 and its deletion trigger, and the one-sentence statement that the fallback is unreachable when a projection is served. ✅ Confirm both on the follow-up rather than leaving them inferred.
Activity
os-zhuang commentedon Aug 23, 2026
Unlock scan (triage seat, 2026-08-23 round):
pm:blocked→pm:queue. Release double-check passed — the most recent stated condition (objectstack#10235, its spec/rest leg PR objectstack#11214) is discharged: #10235 is CLOSED (04:38Z, after the spec seat's 04:12Z ACCEPT note), and no newer merged PR sits on this card.Requeue obligations for the ui seat, per the unlock discipline:
sortabilityenvelope shape from objectstack#11214's merged diff, not from this thread's description of it.sortable: true; absence never means "assume sortable"; ⛔ no local "formula ⇒ unsortable" shadow derivation (scope item 3 stands).caveat: 'unprovisioned-anchor'affordance question stays with this card, default A (keep the click) until ruled here — refusing what the platform does not refuse would recreate declared≠enforced drift in mirror image. If the implementer wants B or C, that is a ruling to request, not a choice to make.Generated by Claude Code
yinlianghui commentedon Aug 24, 2026
Claim: PM loop round 38 (refill slot)⚠️
⚠️ Serial: siblings in flight — #5725 (
Session:
session_01CSoz9uGhaaSgiq3hshtN7LBranch:
claude/issue-5729-column-sortability-signalWorktree:
objectui-issue-5729Domain:
domain:uiFile surface: the grid/table header sort affordance and the personalization PUT path — start in
packages/plugin-grid.packages/componentsis CONDITIONAL: #6014 is in flight and fenced to report before writing there, so ⛔ if your fix needspackages/components, say so in the report before you write it, so the two cards cannot collide silently.Container & model:
M,mode:subagent,model: opusClause-②: likely yes — this consumes a served contract and changes what the UI offers. Declared; Opus under this seat's recorded Fable-quota exemption.
app-shell/.../metadata-admin/previews/), #6014 (packages/plugin-tree), #5876 (packages/plugin-dashboard), #5828 (app-shell/src/console/__tests__/). Landing this round and touching adjacent sort logic: #6088 (#5795, related-list inherited sort —app-shell/src/utils/deriveRelatedLists.ts,views/RecordDetailView.tsx,plugin-detail/src/synth/). ⛔ Do not touchplugin-detail.裁决 — the ruling, and the four requeue obligations
Ruled upstream (maintainer 2026-08-23, objectstack#10235 ruling A): the platform serves an explicit per-column sortability signal; the grid reads it and never re-derives "virtual ⇒ unsortable" from field type. The unlock scan released this card with four obligations, all binding:
sortabilitykey onGET /api/v1/meta/object/:name—{ fields: { [fieldName]: { sortable, reason?, caveat? } } }.objectstackcheckout; read the merged diff. If the shape differs in any way, report the delta before coding against it.sortable: true. ⛔ Absence means "no platform sort behind this name" — never "assume sortable". ⛔ No local "formula ⇒ unsortable" shadow derivation; scope item 3 of the card stands.sortable: false⇒ no affordance, and a naive!== falsecheck gets the absent case exactly backwards.caveat: 'unprovisioned-anchor'question is NOT yours to decide. Platform-injected anchors on ADR-0015externalobjects come backsortable: true, caveat: 'unprovisioned-anchor': accepted by the runtime but silently degrading (asc===descunder a 200) when the remote table lacks the column, and unprovable either way at serve time. Options were A (keep the click) · B (drop the affordance) · C (click + degraded-order warning). Default is A — keep the click — until ruled on this card. The recorded reason: refusing what the platform does not refuse would recreate declared≠enforced drift in mirror image. ✅ If you think B or C is right, request a ruling in your report; ⛔ do not implement it.sortabilitykey" look identical from the UI. ⛔ If the served stack predates fix(app-shell): the page designer stops offering the retired page:header breadcrumb toggle (objectui#11173) #11214, say so and pin against the contract with unit-level fixtures instead of claiming an oracle you could not run.Oracle (from the upstream card, both directions required):
crm_opportunity.expected_revenue(×2 views) andshowcase_project.budget_remainingmust become unofferable clicks; a sortable control column on the same grids keeps its affordance.Scope — three items, and the third is a prohibition
PM mechanism assumptions — measure, do not inherit
packages/plugin-grid/src/ObjectGrid.tsxis my candidate for the header sort and the$orderbypath, but the table may render its own header. Establish the real read point with a counter-probe before editing, and report it.sortabilitythrough to the column. ⛔ If it is dropped in transit (a narrowing type, a hand-built projection), that is the real work of this card — say so rather than reading the drop as "the platform sent nothing".$orderby. Without it, "the click is gone" is also true of a broken grid.@object-ui/core'sconvertSortToQueryParams— the repo's single definition of both sort dialects. ✅ If you touch sort-string handling, reach that helper rather than adding a parallel reader.Verification
Predict the direction before running. Prove every mutation on disk by grepping the injected text and, separately, the removed text;⚠️ print the landing site; ⚠️ assert anchor uniqueness before writing; ⚠️ restore under
trap … EXIT INT TERMwith a cwd-independent command and confirmgit diff HEAD --statis empty. Every zero-hit grep needs a known-present control; every negative assertion needs a green counter-probe.Gates by name, with exit codes:⚠️ hyphenated; echo the script name; distinguish the unbuilt-closure red — ⚠️ known gotcha, not a finding: it fails at ⚠️ plain ⚠️ #6073 landed today and widened that gate's notion of a package's source beyond
pnpm --filter @object-ui/plugin-grid type-check(Cannot find module @object-ui/...— from the missing-node_modulesred), root-form vitest over each package you touch quoting per-test names, the downstream prefix-form sweep with the contract direction stated (apps/siteandpackages/componentsuntil@object-ui/react-runtimeis built separately), and eslint with merge-base delta (eslint .; ⛔ no--no-inline-config). Runnode scripts/check-changeset-presence.mjsrather than guessing —/src/. Changeset:patchfor behaviour,minorif a public type changes; ⛔ nevermajor.check:esm-specifiersruns as a step inside theType Checkjob under a name that does not read like one, and that class of miss turned a PR red today.Deliverable
Draft PR, body with
Fixes #5729. ⛔ Do not mark ready, ⛔ do not enqueue, ⛔ no auto-merge — the PM lands it. Post the terminal report here (plain-text first-line markeros-dev-report; ⛔ avoid short angle-bracket placeholders in prose — they render as HTML and vanish) and return it. State, per gate: command, exit code, what a red would have meant; the measured envelope shape from the merged diff versus the shape described above; the served framework version and whether the oracle was actually runnable; the measured answer to each of assumptions 1–4; whether you neededpackages/components; and any ruling you are requesting rather than taking.Generated by Claude Code
yinlianghui commentedon Aug 24, 2026
os-dev-report
{ "issue": 5729, "status": "done", "branch": "claude/issue-5729-column-sortability-signal", "pr": "https://github.com/objectstack-ai/objectui/pull/6109", "premise_still_valid": true, "summary": "The premise held and the envelope shape matched the thread's description exactly. The real work was assumption 2: the signal was reaching the browser and being discarded — ObjectStackAdapter.fetchObjectSchemaFresh unwraps the response to data.item, one line before the only consumer that needs it, so every UI reader saw a document with no signal and could only read that as 'the platform sent nothing'. It now survives that unwrap, carried on the schema under Symbol.for('objectui.objectSortability') so JSON.stringify / Object.keys / spread all skip it and it can never reach a metadata write body. @object-ui/core gains the one spelling of the contract (isPlatformSortableField: an entry must EXIST and say sortable:true; absence is a refusal), with the spec's own four names re-exported rather than restated after check:spec-symbols correctly caught the hand-written copies. ObjectGrid's withSortability now reads the served projection; the relational carve-out is kept and deliberately NOT delegated to it (measured: the projection answers sortable:true for a lookup, so folding them together would have handed every relational header its click back). Scope item 2 got its own guard on the restore leg: the same filter runs over what the grid renders the sort state as AND what it emits, so a sort persisted before the signal existed is inert on arrival and dropped the first time anything writes it back. caveat:'unprovisioned-anchor' keeps the click — default A, held rather than decided.", "tests": "All results from 57870ce55 (branch head). PREDICTED DIRECTIONS BEFORE RUNNING; both ablations restored under trap ... EXIT INT TERM with cwd-independent commands, and `git diff HEAD --stat` confirmed empty after each. No rebuild needed for either leg: the root vitest.config.mts aliases every @object-ui/* specifier to that package's src/, so the mutated source is what the run executes (stated because a dist-resolved ablation would have been a false green). ABLATION A — isPlatformSortableField rewritten to the naive `!== false`. Predicted: red on the ABSENT-entry cells ONLY, sortable:false cells staying green. Observed exactly that: 4 failed / 15 passed, the four being 'refuses an ABSENT entry', 'refuses an empty / malformed field name and a malformed entry', 'drops a persisted sort on a column the platform refuses', and the grid's 'withholds a column ABSENT from the projection'; expected_revenue and budget_remaining stayed GREEN, confirming the two rules are separately load-bearing. Mutation confirmed on disk by grepping the injected marker (1) and the removed anchor (0) SEPARATELY, anchor asserted unique first. ABLATION B — transit carry dropped to `return item`. Predicted: 3 carry cells red, 2 'no projection served' cells green. Observed exactly that: 3 failed / 2 passed. CROSS-PACKAGE TYPE REVERSE-CHECK — a rejected value fed to isPlatformSortableField from plugin-grid: red as predicted, error TS2345 naming the SPEC's literal union ('virtual-type' | undefined, 'unprovisioned-anchor' | undefined), which proves tsc read the freshly built .d.ts across the package boundary and not a cache. ONE RED FOUND AND FIXED rather than reported: my first adapter fixture returned the same object literal from every mocked response, and the adapter stamps the projection onto the document it is handed, so a previous test's stamp leaked into the 'no signal served' cells and made them pass while measuring nothing; the mock now returns fresh JSON per response as a real res.json() does. GATES, exit code captured before any pipe, verdict quoted from the gate's own output: `pnpm --filter @object-ui/core type-check` EXIT=0 (script name echoed, so not a zero-match no-op) | `pnpm --filter @object-ui/data-objectstack type-check` EXIT=0 (echoed) | `pnpm --filter @object-ui/plugin-grid type-check` EXIT=0 (echoed) — a red here would have meant either an unbuilt closure ('Cannot find module @object-ui/...') or a real type break; distinguished, and neither occurred. `npx vitest run packages/core/src packages/data-objectstack/src` EXIT=0, 'Test Files 143 passed (143)' / 'Tests 2628 passed (2628)'. `npx vitest run packages/plugin-grid/src` EXIT=0, 'Test Files 86 passed (86)' / 'Tests 811 passed (811)' — includes serverSorting.test.tsx, so the no-signal branch is pinned to preserve #3096/#3106/#3950 behaviour. DOWNSTREAM PREFIX-FORM SWEEP `pnpm --filter '...@object-ui/core' type-check` EXIT=2 — direction stated because the claim is unreadable without it: the PREFIX form selects packages that DEPEND ON core, the consumers a contract change lands on. 35 ran the script; exactly one failed, apps/site on \"Cannot find module '@object-ui/example-schema-catalog'\", the unbuilt-closure class for workspaces outside packages/** (I built --filter './packages/**' first, which is why packages/components and plugin-dashboard pass here and did not on my first attempt). No error TS anywhere else and nothing naming sortability. `npx eslint <merge-base delta>` EXIT=0, '377 problems (0 errors, 377 warnings)' — all pre-existing no-explicit-any. `pnpm check:spec-symbols` EXIT=0 after the re-export commit, and RED (EXIT=1) before it — a genuine catch, not noise: it named four symbols @objectstack/spec/api already owns that I had hand-written. `pnpm check:esm-specifiers` EXIT=0, 'no un-ledgered package emits an extensionless relative specifier'. `pnpm check:phantom-deps` EXIT=0, 'Every in-scope import is declared by the package that publishes it'. `pnpm check:self-import` EXIT=0, 'No package names itself inside its own src/'. `node scripts/check-changeset-presence.mjs` EXIT=0, '7 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)'. `node scripts/check-changeset-fixed.mjs` EXIT=0. `node scripts/check-changeset-no-major.mjs` EXIT=0, 'No changeset declares a major bump'. `node scripts/check-control-bytes.mjs` EXIT=0, 'scanned 5040 tracked text file(s)'. GATE SET DERIVED by enumerating each CI job's own step list rather than top-level script names — which is how check:esm-specifiers and check:spec-symbols got run at all; both are steps inside the Type Check job under names that do not read like one, and one of them was a real red. Repo-wide scans (full `pnpm lint`, node-esm-load, the 4 test shards) left to CI, which runs the farm exactly once regardless.", "open_questions": [ { "question": "OBLIGATION 3, unchanged and NOT implemented: what affordance should a column carrying caveat:'unprovisioned-anchor' get? Shipped on default A (the click is kept), and the caveat is preserved intact through the whole path and pinned, so either alternative is a small change on top.", "options": [ "A — keep the click (SHIPPED, the recorded default): the runtime accepts these sorts, and refusing what the platform does not refuse would recreate declared-not-equal-enforced drift in mirror image", "B — drop the affordance on caveated columns: conservative, but the UI would then refuse something the platform executes, and nothing in the projection distinguishes a remote table that DOES carry the column", "C — click plus a degraded-order warning: honest about the measured asc-equals-desc degradation, at the cost of a warning surface that fires on a case the platform cannot prove either way" ], "recommendation": "A, which is what shipped — I am not requesting a change, only recording that the question is still open and that the caveat now reaches the renderer, so B or C is cheap whenever it is ruled." }, { "question": "The one place this change comes close to scope item 3's prohibition: when NO projection was served, withSortability still falls back to the pre-existing isUnmaterializedFieldType read. Keep it as a compatibility floor, or delete it now?", "options": [ "KEEP (shipped): the fallback is bound to @objectstack/spec's own SEARCH_VIRTUAL_TYPES — the identical predicate the platform computes its projection from, so it cannot disagree about `formula` — it is unreachable the moment a backend serves the signal, and it is commented as a floor scheduled for deletion", "DELETE now: strictly satisfies 'no shadow derivation', at the cost of handing every formula header its refused click back on any deployment older than objectstack#10235" ], "recommendation": "KEEP, and I want this ruled rather than assumed. My reading is that the prohibition targets a SECOND JUDGE that can drift from the runtime, and this is neither (same spec predicate, dead code under a modern backend). But it is the closest call in the change, so I am flagging it rather than letting it pass silently. If the ruling wants it gone, it is a two-line deletion plus one test." } ], "out_of_scope_findings": [ "filed as #6108: the other two sort-axis consumers still re-derive from field type — ListView's toolbar sort picker (plugin-list) and RelatedList (plugin-detail, fenced this round). Not cosmetic on the picker: its in-use exception keeps a refused field listed, so an unrelated edit in that popover re-emits the whole sort array into persistViewPatch({ sort }) — the personalization PUT carrying a 400 INVALID_SORT column. This PR closes that leak at the grid seam; the picker is a second door onto the same stored state." ] }Measured answers to the four requeue obligations and the four PM assumptions
Obligation 1 — envelope shape, read from the MERGED diff, not the thread. The thread's description was accurate in every particular.
packages/spec/src/api/protocol.zod.ts:413putssortability: ObjectSortabilitySchema.optional()onGetMetaItemResponseSchema, besideitemand never inside it;packages/spec/src/api/sortability.zod.tsdefines{ fields: Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }> };packages/rest/src/rest-server.ts:2609computes it intranslateMetaEnvelope, the one seam every single-item exit passes through, so it is present on every branch including the cached one. No delta to report. Two details the thread did not spell out and that I coded against: the projection's domain always includesideven when the document declares no such field, and it is computed from the FINAL post-masking document.Obligation 2 — the consumer contract, including the asymmetry. Implemented as the only spelling in the repo,
isPlatformSortableField(projection, name), true iff an entry exists AND sayssortable: true. Ablation A is the proof it is load-bearing: the naive!== falsereds exactly the ABSENT-entry cells and leaves thesortable: falsecells green. No shadow derivation added; the one pre-existing read that survives is behind the no-signal branch and is the second open question above.Obligation 3 — the caveat question. Not decided. Shipped on default A, recorded above as a standing question.
Obligation 4 — runtime dependency. The installed
@objectstack/spec@17.2.0does carry the change (verified in itsdist, with a known-present control symbol alongside the grep so a zero hit could not be read as a false negative). I did not boot a dev stack, so the three oracle cells are pinned at unit level against the platform's ownresolveObjectSortabilityrather than claimed as a served-backend measurement — the discipline this obligation asked for when the runtime leg cannot be run. Measured directly:crm_opportunity.expected_revenueandshowcase_project.budget_remainingboth resolve to{ sortable: false, reason: 'virtual-type' }, whileamount,name,budgetandidresolve to{ sortable: true }.Assumption 1 — where the affordance lives. Your candidate was right, with a correction. The decision is
ObjectGrid.tsx'swithSortability, which stampssortable: falseonto the column; but the header itself is rendered bypackages/components/src/renderers/complex/data-table.tsx, which honourscol.sortable !== falseat three sites (thecursor-pointerclass, theonClick, the sort icon) and — the part that matters for scope item 2 — emitsonSortChangeonly out ofhandleSort, itself behind the same gate. So the table does render its own header, and it was already correct; nothing inpackages/componentsneeded to change.Assumption 2 — the signal reaching the renderer. This was the real work, exactly as you suspected. It was dropped in transit at
packages/data-objectstack/src/index.tsfetchObjectSchemaFresh, whose last line unwrapped the envelope todata.item. Not a narrowing type and not a hand-built projection — a defensive unwrap that predates the signal. Ablation B pins it.Assumption 3 — negative legs need positive controls. Every one has its control in the SAME render: a sortable sibling column that is still
cursor-pointer, still clickable, and still puts its$orderbyon the wire, asserted in the same test body. The affordance is read off the rendered DOM (thecursor-pointerclass), not off a prop.Assumption 4 — do not collide with #6088. No collision: I touched no sort-STRING handling, so
convertSortToQueryParamswas never in play. My filter operates on the already-parsedTableSortItem[]array at the grid's render/emit seam, one layer above the dialect question that helper owns.plugin-detailuntouched.packages/components: not needed, per assumption 1 above. The conditional fence around #6014 was never reached.Generated by Claude Code
yinlianghui commentedon Aug 24, 2026
PM ruling on the two open questions
① The
caveat: 'unprovisioned-anchor'affordance — ⛔ still not ours, and correctly not decided✅ Right call. It shipped on the recorded default A (keep the click), the caveat is carried intact through the whole path and pinned, so B or C is a small change on top whenever it is ruled. This goes to the maintainer list as item ⑬, unchanged from how the upstream spec seat framed it. ⛔ I am not ruling it.
② The
isUnmaterializedFieldTypefallback — KEEP as shipped, but I am NOT ratifying it silentlyYou asked to have this ruled rather than assumed. ✅ That was the right instinct, and it is the closest call in the change — closer than your framing suggests, so I am recording both readings and routing it up rather than blessing it.
Your reading, which I think is correct on the mechanism: the prohibition targets a second judge that can drift from the runtime. This is not that — the fallback is bound to
@objectstack/spec's ownSEARCH_VIRTUAL_TYPES, the identical predicate the platform computes its projection from, so it cannot disagree aboutformula; and it is dead code the moment a backend serves the signal.Against deleting it: the card's own scope item 3 says that if the signal is absent the card "is not ready … not a license to shadow-derive" — i.e. the ruling was written assuming the signal is present. Deleting the floor now hands every formula header its refused click back on any deployment older than objectstack#10235 — a live regression, in exchange for literal compliance with a clause about a case the ruling did not contemplate.
⇒ KEEP, and the PR lands as-is. ⛔ I will not block a green, well-measured PR on a clause tension I cannot resolve from this seat. But⚠️ two conditions, and please confirm both are already true rather than assuming:
isSpecTranslationDataneeds anobjects.*.fieldsentry, so a bundle translating only apps, dashboards, pages or flows lands un-namespaced and nothing reads it #10235" — so the floor cannot quietly become permanent.This goes to the maintainer list as item ⑭, stated as what it is: a deviation from the ruling's literal words, kept deliberately, with the regression cost of the alternative named. ⛔ I am not re-ruling a maintainer decision; I am declining to let it pass unremarked.
On the report itself
Assumption 2 was the card, and you found it: the signal was reaching the browser and being discarded —
ObjectStackAdapter.fetchObjectSchemaFreshunwraps todata.itemone line before the only consumer that needs it. Every UI reader saw a document with no signal and could only read that as "the platform sent nothing", which is precisely the misreading the dispatch warned about. ⛔ Nobody would have found that by inspecting the renderer.✅ Carrying it on
Symbol.for('objectui.objectSortability')soJSON.stringify/Object.keys/ spread all skip it — and therefore it can never reach a metadata write body — is the right shape, and the reason is the interesting half.✅ The relational carve-out kept and deliberately NOT folded in, with the measurement that justifies it: the projection answers
sortable: truefor a lookup, so delegating would have handed every relational header its click back. That is a convergence that looks obviously correct and is measurably wrong — exactly the kind this seat keeps asking to be measured rather than assumed.✅ Scope item 2 got a real guard on the restore leg — the same filter runs over what the grid renders the sort state as and what it emits, so a sort persisted before the signal existed is inert on arrival and dropped the first time anything writes it back. That is the half-fix the dispatch flagged, closed.
res.json()does.✅
check:spec-symbolsgoing RED before the re-export commit and green after is a genuine catch, not noise — it named four symbols@objectstack/spec/apialready owns that had been hand-written. And it is one of the two gates that only ran because the gate set was derived from each CI job's own step list rather than from top-level script names. That standing rule has now paid twice in one day.✅ Obligation 4 answered honestly:
@objectstack/spec@17.2.0verified in itsdistwith a known-present control, no dev stack booted, so the oracle cells are pinned at unit level against the platform's ownresolveObjectSortability⛔ rather than claimed as a served measurement. Saying which one you did is the whole point of that obligation.Assumption 1 corrected: the decision is
ObjectGrid'swithSortability, but the header is rendered bypackages/components/src/renderers/complex/data-table.tsx. ✅ Andpackages/componentswas not needed — the conditional Clause-② fence around #6014 was never reached.#6108 filed: the other two sort-axis consumers still re-derive from field type — ListView's toolbar sort picker and⚠️ Not cosmetic: the picker's in-use exception keeps a refused field listed, so an unrelated edit in that popover re-emits the whole sort array into
RelatedList.persistViewPatch({ sort })— the personalization PUT carrying a 400INVALID_SORTcolumn. This PR closes the grid seam; the picker is a second door onto the same stored state. ✅ Correctly filed rather than folded in.Gates read at 17:44:30Z; verdict to follow.
Generated by Claude Code
yinlianghui commentedon Aug 24, 2026
PM verdict — ACCEPT, enqueued
PR #6109 at
57870ce55. 22 checks, 19 success + 3 skipped, 0 failing, 0 still running. Marked ready and enqueued; ⛔ nothing merged by hand.in_progress— I did not enqueue on it. Re-armed to 17:53Z and read again; shards 1–3 finished at 17:41–17:52. Recording it because reading a 19-of-22 board as "green" is exactly the early-read error this seat made three times earlier today, and the timer discipline is what caught it this time instead of a bad enqueue.The two open questions were ruled in the comment above: ⑬ the⚠️ Both conditions on ⑭ still stand — the comment naming objectstack#10235 and its deletion trigger, and the one-sentence statement that the fallback is unreachable when a projection is served. ✅ Confirm both on the follow-up rather than leaving them inferred.
unprovisioned-anchoraffordance stays on default A and goes to the maintainer list; ⑭ theisUnmaterializedFieldTypefallback is kept and landed, recorded as a deliberate deviation from the ruling's literal "never re-derives" with the regression cost of deleting it named.Generated by Claude Code
fix(plugin-list): separate what the sort picker lists from what it pe…
fix(plugin-list): separate what the sort picker lists from what it pe…