Skip to content

feat(types): declare the four action keys the two renderers read off UIActionSchemadisabled · recordIdField · resultDialog · undoable - #9543

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-8648-ui-action-four-undeclared-keys
Sep 15, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-8648-ui-action-four-undeclared-keys

Conversation

@os-sam

@os-sam os-sam commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8648

Four keys, six (key, file) pairs, eight read expressions, one declaring type — and four separate readings that happened to agree. ⛔ Not one answer forced onto four: the instrument below asks the question once per key, and its inline sibling is where the same question comes back no.

The decision rule, and the instrument that answered it

Triage settles the exit per key with one question — does @objectstack/spec declare this key, and on WHICH schema? — because @object-ui/types is a MIRROR of the platform contract, not an authority over it. Every verdict here came from checker.getPropertyOfType on the static type of schema before the cast, ⛔ never from a grep (objectui#8410 is the standing card that grep-shaped absence claims are unsound).

The pre-cast type at all eight sites, as the checker prints it (generic spelled as a word — see the note at the bottom):

UIActionSchema AND-TYPE OBJ-OPEN type: string; className?: string OR undefined; actionType?: string OR undefined; OBJ-CLOSE

key pre-cast getPropertyOfType on spec's ActionSchema on spec's InlineActionSchema verdict
disabled absent declared absent align the mirror
recordIdField absent declared absent align the mirror
resultDialog absent declared absent align the mirror
undoable absent declared absent align the mirror

Controls in the same pass, so none of the above is vacuous: label declared on every one of the four types; endpoint and enabled declared on the mirror and absent on spec's ActionSchema (so "the mirror equals the contract" is not what the instrument is reporting); refreshAfter declared on the inline sibling, which is where two of the four are absent; and a negative control declared nowhere.

disabled is the one triage singled out, and it is the one this discriminates. A word-frequency screen over the UI contract answers "present" for it loudly (67 hits at triage; 71 when re-run). That reading decides nothing — the contract also refuses disabled on its own inline action schema, which spec's own inline test asserts from the other side. Per-schema is the only reading that separates them, and it is re-derived on every run rather than written down.

What landed

  • @object-ui/typesUIActionSchema declares all four, each by derivation from the contract (SpecAction['…'], the form this file already uses for onSuccess / bodyExtra / bodyShape), so none can drift. disabled inherits all three arms the contract accepts: literal boolean, raw CEL string, and a { dialect, source } envelope.
  • @object-ui/components — all eight read sites are un-cast. ⚠️ Declaring is not enough on its own: a cast at the read site defeats the declaration while a membership instrument still reports the member as present — the lesson objectui#8649 paid for. The checker re-run after the change reports all eight reading a DECLARED member, with the contract's type, and no cast.
  • A pin that re-derives the contract side every run, with direction proofs (Expect must refuse false; Equal must refuse never and must refuse anyany being exactly what the casts produced).

Alignment, not a widening past the contract. The accept set of this face moves to the platform's and never beyond it: a document already valid everywhere else stops being refused here with TS2353. ⛔ Nothing that worked stops working, and no runtime behaviour changes — all eight reads were already honoured; what changes is that the compiler checks them.

The card's three fences, all held. ⛔ No rows added to the undeclared-but-consumed-keys-6150 ledger. ⛔ Nothing folded into objectui#8347. ⛔ No single answer forced onto four keys — each verdict has its own reading behind it, in the table above and in the pin.

⚠️ One as any was hiding TWO defects, and the second is filed, not fixed

Removing the read-side cast on resultDialog made the compiler report that @object-ui/core's ResultDialogSpec — whose own docblock claims it mirrors the contract's block — hand-writes title / description / acknowledge as string where the contract declares I18nLabel. So this repo refuses what the platform accepts on those three members, and ActionResultDialog renders the member straight into JSX.

That repair lands in @object-ui/core plus the dialog's own resolver, and its second half is a real i18n-resolution decision rather than a mechanical one — outside this card's declared file surface. ⛔ Not guessed at here. It is filed as objectui#9542 (that card stays open; this PR does not address it).

Meanwhile the WRITE at both forward sites carries a narrowing assertion to ActionDef['resultDialog'] — strictly narrower than the as any it replaces, since every other member of the forward literal is compiler-checked again. It is documented at both sites as a ledger entry and pinned two ways: it cannot regress to as any, and it goes red when objectui#9542 lands and the assertions go.

Verification

Every number below is a reading with its command; ⛔ nothing is asserted from absence of output.

Reverse verification — the pin can fail, both halves, from the committed state. Each leg: mutate → prove it reached disk → rebuild → prove it reached dist → measure → restore → prove the restore is byte-identical.

  • Leg 1 (delete the disabled declaration from the mirror): source marker hits 1 → 0, injected marker 1; @object-ui/types build exit 0; dist marker 1 → 0, so the mutation reached the artifact the pin reads. tsc -p tsconfig.test.json then exits 2 with 9 error TS, naming TS2339 on the type pins and TS2353 on the repro literal, plus TS2339 at the renderer's own read site. Restore: git diff HEAD empty, blob on disk equals blob at HEAD, rebuild exit 0, dist marker back to 1.
  • Leg 2 (re-introduce the read-side cast in action-button.tsx): un-cast hits 1 → 0, re-cast 1; the pin's source-text leg goes red1 failed | 15 passed. Restore byte-identical.
  • Post-ablation green re-run: 16 passed (16), type-check 9 successful, 9 total.

Tests and type-check (all through the shared verify lock, VERDICT command-exit 0):

  • pnpm exec vitest run packages/components/src/renderers/action/Test Files 19 passed (19) · Tests 299 passed (299)
  • turbo run type-check --filter=@object-ui/types --filter=@object-ui/componentsTasks: 10 successful, 10 total
  • turbo run build --filter="@object-ui/components^..." --filter="@object-ui/types^..."Tasks: 7 successful, 7 total

Gates, each exit code captured before any pipe:

gate exit verdict line
check:changeset-presence 0 3 source files of 2 released packages changed, 1 changeset declared
check:new-line-citations 0 0 new citation(s), enforcement report-only
check:action-forward-parity 0 every declared surface payload excess-property CHECKED
check:control-bytes 0
check:spec-symbols 0 1384 files scanned … 0 untriaged collisions
check:comment-mask-corpus 0
check:test-path-roots 0
check:handler-key-reads 0
check:changeset-claims 0 report-only

Plus a control-character self-scan over all five changed files: no hit (grep exit 1).

Acceptance notes

  • ⚠️ This PR does NOT carry needs:contract-review. The card carries it, and this seat was instructed in as many words that it hangs no label, adds none and removes none — labels are the claiming seat's alone. Flagged rather than resolved either way. Something does widen here: all four keys land as declare, so Clause ② is live and the declaration is not a false alarm.
  • Three casts on keys this card does not cover — description, openIn, locations — remain in both renderers although all three are already declared members of UIActionSchema. Noted, ⛔ not filed and ⛔ not fixed: they are outside this card's four keys, and they are a different class from it (a redundant cast over a present declaration, not a read with no declaration behind it). The successor that will touch these forward blocks is the rest of the objectui#8327 family.
  • Left in draft. ⛔ Not flipped ready, ⛔ not enqueued, ⛔ no auto-merge — landing is the claiming seat's.

⚠️ Why some type spellings above are written as WORDS. GitHub's body sanitizer silently deletes tag-shaped fragments, and backticks and fenced blocks do not protect them — a before/after table of types is exactly the thing it destroys most quietly. AND-TYPE, OBJ-OPEN, OBJ-CLOSE and OR stand where the compiler prints the punctuation. Written this way deliberately, ⛔ not a transcription error.


Generated by Claude Code

…`UIActionSchema`

`disabled`, `recordIdField`, `resultDialog` and `undoable` were read at eight
sites across `action-button.tsx` and `action-icon.tsx` through `as any`, and
none was a declared member of the type. Each key was decided on its own
reading with `checker.getPropertyOfType` on the static type of `schema` BEFORE
the cast, never with a grep: all four are DECLARED on `@objectstack/spec`'s
`ActionSchema`, the schema this interface mirrors, so all four align the mirror.

The casts are removed in the same change, because a cast at the read site
defeats the declaration while a membership instrument still reports the member
as present. A new pin re-derives the contract side every run and goes red if a
cast returns.

Removing the `resultDialog` cast made the compiler name a second defect it had
been hiding: `@object-ui/core`'s `ResultDialogSpec` hand-copies the contract's
block and has drifted on three `I18nLabel` members. That repair is in another
package and turns on an i18n-resolution decision, so it is filed separately;
the write side carries a documented, pinned narrowing meanwhile.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3062.9 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-DouZa7A1.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 544.93KB 130.50KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.20KB 62.88KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.99KB 14.24KB
plugin-charts (index.js) 71.33KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 252.48KB 65.61KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.83KB 57.97KB
plugin-kanban (index.js) 46.63KB 14.53KB
plugin-list (index.js) 112.67KB 27.68KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 84.89KB 20.96KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 99.04KB 32.62KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Contract review

objectui#9543 at head a1da4f875eae858a31935507f3bfa5972850b337 — card objectui#8648, one of the objectui#8327 family. Independent seat. ⛔ Nothing below is adopted from the card, from triage 5619607236, from the claim comment 5673023367, or from the os-dev-report on the card; every number is a reading taken here, with its instrument, its corpus and its counting rule beside it.

Where the readings were taken

Fresh clone of objectstack-ai/objectui into this seat's own scratchpad — git rev-parse --is-shallow-repositoryfalse — with the head commit fetched directly. Merge-base derived here, ⛔ not adopted from base.sha: git merge-base origin/main a1da4f87…75fca9669a3df84b065c1e9ded0946d67112000d; origin/main is at that same commit, so the head is a descendant of current main. Diff against that derived merge-base: 5 files, +500 / −14, one commit.

AGENTS.md was read from 75fca966… in this clone, ⛔ never from the shared checkout; its rule #11 (cite by content, ⛔ never a cross-file line address) is why nothing below carries a path:line. All type readings are against the installed @objectstack/spec@17.4.0 in this clone's own node_modules, TypeScript 6.0.3. ⛔ No shared checkout was fetched, checked out, worktree'd or mutated.

⚠️ Generic punctuation is spelled as words where it appears in a type — RECORD-OF-STRING-TO-STRING stands where the compiler prints the angle-bracket form — because the body sanitizer deletes tag-shaped fragments even inside a fence.

Claim population — 24, with a proved bound

Enumeration Ajq 'length' over this seat's claim ledger (claims.json, one object per falsifiable claim): 24.
Enumeration Bgrep -cE '^\| C[0-9]{2} ' over the rendered claim table in this very comment, counting lines (each claim is exactly one line, and the rule is published because grep -c counts lines and not occurrences): 24.
Two enumerations over two separately authored artifacts, agreeing at 24. Tally: 22 CONFIRMED · 1 CONFIRMED-with-refinement · 1 REFUTED-as-current.

id claim verdict
C01 disabled DECLARED on the contract's Action CONFIRMED
C02 recordIdField DECLARED on the contract's Action CONFIRMED
C03 resultDialog DECLARED on the contract's Action CONFIRMED
C04 undoable DECLARED on the contract's Action CONFIRMED
C05 disabled ABSENT on InlineActionParsed CONFIRMED
C06 recordIdField ABSENT on InlineActionParsed CONFIRMED
C07 resultDialog ABSENT on InlineActionParsed CONFIRMED
C08 undoable ABSENT on InlineActionParsed CONFIRMED
C09 eight read expressions, hadCast=false at all eight CONFIRMED
C10 all eight resolve a DECLARED member with the contract's type, none any CONFIRMED
C11 the pre-cast type is verbatim what the card printed CONFIRMED
C12 pin leg 1 — deleting a declaration reddens tsc CONFIRMED
C13 pin leg 2 — re-introducing a cast reddens the source-text leg CONFIRMED
C14 every restore byte-identical by blob hash CONFIRMED
C15 published .d.ts gains exactly the four members and nothing else CONFIRMED
C16 the accept set moves TO the contract's and not past it CONFIRMED
C17 the ResultDialogSpec drift is real and objectui#9542 is filed and open CONFIRMED with refinement
C18 the interim write-side narrowing is strictly narrower than as any, and pinned twice CONFIRMED
C19 no runtime behaviour changes CONFIRMED
C20 CI settled — total_count equals the returned array length, nothing non-green CONFIRMED
C21 Test (shard 1/4) not cancelled, so objectui#9499 was not hit CONFIRMED
C22 deleting a read site entirely reddens the pin's liveness leg (this seat's own leg) CONFIRMED
C23 widening the mirror PAST the contract reddens tsc (this seat's own leg) CONFIRMED
C24 the PR body's acceptance note "this PR does NOT carry needs:contract-review" REFUTED as current

1 · The per-key verdict, re-derived per key

Instrument. A one-off program built over packages/components/tsconfig.json — the project CI actually type-checks — walking every PropertyAccessExpression whose base, after stripping parentheses, as and !, is the identifier schema; taking checker.getTypeAtLocation on that identifier, i.e. the static type BEFORE any cast, and checker.getPropertyOfType on it. A probe module in the same program declares one const of each contract type so the contract half is read by the same instrument. ⛔ Not a grep.

The pre-cast type at all eight sites, one distinct string across all of them:

UIActionSchema AND-TYPE OBJ-OPEN type: string; className?: string OR undefined; actionType?: string OR undefined; OBJ-CLOSE
key on spec's Action on spec's ActionParsed on spec's InlineActionParsed exit
disabled DECLAREDstring OR boolean OR OBJ-OPEN dialect; source?; ast?; meta? OBJ-CLOSE OR undefined DECLARED absent align the mirror
recordIdField DECLAREDstring OR undefined DECLARED absent align the mirror
resultDialog DECLARED — strict block of title / description / acknowledge / format / fields DECLARED absent align the mirror
undoable DECLAREDboolean OR undefined DECLARED absent align the mirror

Controls in the same pass, so none of the four absences is vacuous. label DECLARED on Action, ActionParsed, InlineActionParsed and on the mirror — a positive that fires on the very type where the four come back absent. refreshAfter DECLARED on InlineActionParsed — a second positive on that same type. endpoint and enabled DECLARED on the mirror and ABSENT on the contract's Action — so the instrument is provably not reporting "the mirror equals the contract". Negative control vvbb_absent_probe_token_9543, chosen fresh and verified absent: false on all five probed types and at all eight read sites.

⚠️ One reading that decides nothing, disclosed rather than quoted. InlineAction (the z.input side) resolves to bare unknown in this program — every key reads "absent" on it because unknown has no members at all. That is a degenerate type, ⛔ not a measurement; the non-vacuous inline reading is InlineActionParsed, which is what the table uses and where label and refreshAfter fire.

disabled, the one triage singled out. Triage's warning is confirmed the hard way: a token screen answers a different number for every corpus and every counting rule, and none of them decides the card. Re-run here — CORPUS = the 18 files under src/ui/ of the installed @objectstack/spec@17.4.0 in this clone's node_modules; COUNTING RULE = grep -roF TOKEN src/ui | wc -l, OCCURRENCES not linesdisabled reads 38 occurrences, 34 matching lines, 7 files. Triage read 67 and the dev re-read 71 over a different corpus (the objectstack working tree). ⇒ four numbers, one token, no verdict between them. Positive controls in the same corpus: groupField 3, NavigationConfigSchema 4, KanbanConfigSchema 3 — all fire. Nonsense controls in the same corpus: zzqx_no_such_key 0, qqzz_absent_token_9999 0, xxkk_review_control_9543 0. ⚠️ zzqx_no_such_key reads 0 here and 6 occurrences across the objectui tree — the same token, two corpora, two answers, which is exactly why a control without its corpus is not a control.

What decides disabled is the per-schema reading and nothing else: DECLARED on Action, absent on InlineActionParsed while label and refreshAfter are present there. ⇒ declare is on the table for it, and the mirror's job is to align.

All four take the declare branch, each on its own reading. The dev's per-key verdict is confirmed. The naming differs harmlessly — the report says Action / InlineActionParsed, the body and changeset say ActionSchema / InlineActionSchema; those are the zod schemas whose z.input / z.output the types are, and the readings agree either way.

2 · Are the reads actually enforced now?

Yes, measured at the binding, not at the text. 8 read expressions of the four keys across the two renderers — hadCast=false at all eight, each resolving a member DECLARED on the pre-cast type, with the declaration site in the mirror's own emitted ui-action.d.ts, and the access type is not any at any of the eight. The card's "6 sites" is 6 (key, file) pairs; the count of read expressions is 8, because disabled is read twice in each renderer (the predicate and the declared-gate). A refinement, ⛔ not a refutation.

Six cast reads remain in the same two files — description, openIn, locations, one each per renderer — and the instrument reports all six as hadCast=true, accessTypeIsAny=true, over members that are already declared. That is the class the dev declared and did not fix, and it is outside this card's four keys.

Screen for leftovers of the four, published with its rule: CORPUS = every path listed by git ls-files at this head in my clone (7802 tracked paths); COUNTING RULE = git grep -o -E PATTERN HEAD -- . | wc -l, OCCURRENCES. Pattern (schema as WORD).KEY over the four keys → 4 occurrences, and opening all four shows every one is a comment or a regex control string inside the new pin; zero in the two renderers themselves. Positive control UIActionSchema → 117 in the same corpus (fires); negative control xxkk_review_control_9543 → 0 in the same corpus.

3 · The pin — four ablation legs, all fired, all restored by blob hash

Baseline from the committed state: pin 16 passed (16); tsc -p tsconfig.test.json for @object-ui/components exits 0 with 0 error TS. That project is chained from the package's type-check script (tsc --noEmit && tsc -p tsconfig.test.json), which is what the green Type Check job runs — so the Equal legs are observed in CI and not only locally.

Counting rule for every marker below: grep -oF FIXED-STRING FILE | wc -loccurrences, corpus = the single file named on each line.

leg mutation reached disk reached the artifact the pin reads result restore
1 delete the disabled declaration from the mirror source needle 1 → 0, injected marker 1, same-shape control undoable stays 1 dist needle 1 → 0, dist control stays 1 tsc exits 2 with 9 error TS — 8 × TS2339, 1 × TS2353 — naming the pin (5) and both renderers (2 + 2). ⚠️ vitest stays 16 passed, which is the design working: types are erased there blob on disk equals blob at HEAD, rebuild exit 0, markers back to 1
2 re-introduce the read-side cast in action-button.tsx un-cast 1 → 0, re-cast 0 → 1 n/a — this leg is source-text pin 1 failed · 15 passed, the failing test named action-button.tsx reads disabled UN-CAST, so the declaration is enforced there byte-identical
3 delete a read site outright (undoable) — this seat's own leg read 1 → 0, marker 1 n/a pin 1 failed · 15 passed, failing on the undoable liveness leg ⇒ a declaration over a dead read is caught too byte-identical
4 widen the mirror past the contract (disabled?: any) — this seat's own leg source derived-form 1 → 0, marker 1 dist derived-form 1 → 0, dist any form 1 tsc exits 2 with 1 error TS2344 ⇒ the "not past the contract" half of Clause ② is mechanized, not merely asserted byte-identical

The pin can fail, in both halves the dev claimed and in two more this seat added. The two claimed legs reproduce exactly (exit 2 with 9 error TS; 1 failed | 15 passed). Every restore was verified by git hash-object on disk equalling git rev-parse HEAD:PATH, ⛔ never by an exit code, and the tree is clean at the end (git status --porcelain empty).

The pin's own runtime calibration also fires inside the green run: the shape walker opens both contract schemas with more than two keys each, label is declared by both, and its own fresh negative control returns the empty set.

4 · Clause ② — measured, both halves

Half one: does the published .d.ts gain exactly these four members? Built @object-ui/types at this head, snapshotted every published .d.ts / .d.mts (65 files), then checked the source file out at the derived merge-base, rebuilt, snapshotted again, and restored — restored blob equal to HEAD's blob, rebuild exit 0.

  • Across the whole 65-file published surface, exactly one file differs: the action mirror's own .d.ts.
  • UIActionSchema members, parsed from each snapshot: 29 → 33. Added: disabled?, recordIdField?, resultDialog?, undoable?. Removed: none. Other top-level declarations in that file: unchanged.

exactly these four members, and nothing else.

Half two: does the accept set move TO the contract's, or past it? For each key, taking Action[KEY] and UIActionSchema[KEY] in one program:

key type strings identical mirror assignable to contract contract assignable to mirror optional on both
disabled yes yes yes yes
recordIdField yes yes yes yes
resultDialog yes yes yes yes
undoable yes yes yes yes

Direction control in the same program: number assignable to stringfalse (the relation instrument discriminates); anystring → true both ways, which is why any is what leg 4 injects. ⇒ mutual assignability, identical spellings, identical optionality: the accept set lands ON the contract's, not past it. Reinforced structurally — the emitted .d.ts keeps the derivation (SpecAction['KEY']) rather than inlining it, so a consumer re-derives against their own installed contract, and leg 4 proves a hand-widening goes red.

And nothing else moves. endpoint and enabled remain declared on the mirror and absent from the contract — pre-existing and untouched by this diff.

No runtime behaviour changes — proved, not asserted. Type-stripped, comment-stripped emit of both renderers at the merge-base and at this head: byte-identical, 4881 bytes for action-button and 4676 for action-icon. Control: the two sources do differ at both files, so the comparison instrument can see a difference when there is one. ⇒ the whole diff in those two files is annotations and prose.

5 · The declared out-of-scope finding — judged, ⛔ not waved through

The drift is real, and I re-derived it. @object-ui/core's ResultDialogSpec carries the docblock claim "Mirrors Action.resultDialog in @objectstack/spec" and does not: title, description, acknowledge are string there and string OR RECORD-OF-STRING-TO-STRING-shaped I18nLabel in the contract. Measured with getPropertyOfType over the three types at once; format matches exactly, which is the control that keeps the three mismatches from being an artefact of a broken comparison.

Was stopping there right? Yes. The repair lands in @object-ui/core (the type) and in the dialog's own resolver in @object-ui/app-shell — both outside the card's declared file surface — and its second half is a genuine i18n-resolution choice, not a mechanical one. objectui#9542 exists, is open, quotes the contract's own wording, and names the runtime consequence. Guessing that decision inside this card is precisely what the objectui#8327 family exists to stop.

Is the interim narrowing sound? Sound enough, and I will say exactly in what sense. Measured: ActionDef['resultDialog'] is assignable to the mirror's block but not the reverse, so the assertion is a genuine narrowing — strictly narrower than the as any it replaces, which checked nothing at all. It erases at runtime, and the byte-identical emit above proves it changes no behaviour. It is documented at both forward sites and pinned two ways — it cannot regress to as any, and it goes red as a dead entry when objectui#9542 lands and the assertions go; legs 2 and 3 show those legs are readings.

⚠️ But it is a downcast that asserts a wider runtime value into a narrower static type — the same shape that hid this defect in the first place. What makes it acceptable rather than a repeat is that the lie is now localised to one member, written down, and wired to expire, instead of being a global as any; and that the hazard's reachability is unchanged — server-driven JSON could always deliver an inline locale map to that dialog, and the read side's cast never stopped it. ⇒ acceptable as a bounded workaround, ⛔ not as a resting place.

③ Landing readings — GET …/commits/HEAD/check-runs?per_page=100, polled to settlement

⛔ No check_suite aggregate anywhere in this comment.

  • total_count = 36 · returned check_runs array length = 36 · equal: yes. No Link response header, so this is a single page and the equality is not masking pagination.
  • Conclusion tally over the 36: success 33 · skipped 3 (Test (coverage), Test (coverage shard …/4), dependabot).
  • Not green: 0. Still running: 0. All 36 are status: completed. Two consecutive polls returned the identical tally, so this is settlement and not a snapshot mid-flight.
  • Legacy combined status, reported separately as instructed: state success, total_count 1, one context — Vercel.
  • ⚠️ Test (shard 1/4) conclusion is success, not cancelled — 890 s against the 20-minute ceiling, inside the 661–1204 s band this shift has been reading. ⇒ objectui#9499 was not hit. ⛔ Nothing re-run, ⛔ nothing touched.

(The dev read this mid-flight at 23 success / 3 skipped / 10 in progress and declined to wait; the ten have since completed green.)

Findings — non-blocking, numbered so they can be picked up

  1. objectui#9542 under-counts the drift by one member. The same I18nLabel-vs-string mechanism also hits the nested field label: the contract's resultDialog.fields[].label is string OR I18nLabel, while @object-ui/core's ResultDialogFieldSpec.label is string — and ActionResultDialog renders that label straight into JSX exactly as it does title / description / acknowledge. objectui#9542's body says "Three of its members" and its remedy step reads "Derive ResultDialogSpec's three label members", so the nested one would be left behind. Measured over the fetched body of objectui#9542 as its own corpus, counting rule grep -oF TOKEN FILE | wc -l: fields 0, ResultDialogFieldSpec 0, with positive controls I18nLabel 8 and acknowledge 1 firing in that same corpus and nonsense control qqzz_absent_token_9999 0. ⇒ this diff is unaffected (the write-side assertion narrows the whole block, nested member included); the correction belongs on objectui#9542.
  2. The PR body's acceptance note is stale (C24). It states "This PR does NOT carry needs:contract-review." The PR does carry it: the label timeline shows package: types, package: components, tests applied by the labeler, then needs:contract-review applied 28 seconds later by the claiming seat — i.e. the carrier is hung on card and PR, exactly as the card requires. The dev's own open question resolves to option A, already taken by the seat. ⛔ No label was hung, added or removed by this review.
  3. Scope note, ⛔ not a defect. Two sibling renderers in the same directory, and two more outside it, still read disabled / resultDialog off action, def or f through as any. They are other members of the objectui#8327 population and outside this card's named file surface; recorded only so the enforcement's true edge is not overstated — the declaration binds in the two renderers this card named, not directory-wide.

This seat's own errors, disclosed

One run was invalidated and re-run. My first ablation-leg-1 script mis-quoted the marker greps inside a nested $( ), so three of its four marker readings printed a grep: … No such file or directory error and a bogus 0. A control that did not fire invalidates the run it was in, so ⛔ I reported nothing from it and re-ran the whole leg with fixed-string counting and an added same-shape control (undoable, which must stay 1 while disabled goes to 0). Every leg-1 number above comes from the re-run.

One thing I did not measure, stated rather than implied. I did not reproduce triage's token screen over the objectstack working tree's packages/spec/src/ui/; that would have meant reading a shared checkout that is genuinely shallow and whose HEAD is not an ancestor of its origin/main. I screened the installed contract artifact instead, disclosed the corpus, and treat the screen as deciding nothing either way — which is triage's own position.

One cosmetic nondeterminism, so nobody mistakes it for a finding. Two builds of the identical source emit one unrelated declaration's string-literal union in a different order. It is TypeScript declaration-emit ordering, it never touched the four members, and the member-set comparison above is order-insensitive by construction.


PASS

The exit is re-derived per key and it is the right exit for each; the declaration is by derivation and is enforced at all eight read sites with no cast standing in front of it; the pin fails in four different directions and restores byte-identically; the published widening is exactly four members and lands on the contract rather than past it; the runtime is provably unchanged; and the second defect the cast was hiding is filed rather than guessed, with a narrowing that is measurably narrower than what it replaces and wired to expire. The three findings above are refinements to carry forward — one onto objectui#9542, one a stale sentence in the PR body, one a scope note — and ⛔ none of them blocks this diff.

⛔ No label hung, added or removed. ⛔ Posted as a plain issue comment, never a review. ⛔ Nothing approved, merged, enqueued, undrafted, pushed or re-run.

Implemented-by: claude/issue-8648-ui-action-four-undeclared-keys
Reviewed-by: session_01L5xpA5q533BgTTNADibEFt


Generated by Claude Code

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Contract-review discharge — PR #9543 / card #8648

Posted before the needs:contract-review label is removed, deliberately. The Governed Surface Queue Guard leg reads the label, not the verdict: to a label reader, "the carrier was stripped with no PASS on record for this head" and "the carrier was never hung" are the same bytes. objectui#8164 landed a real published-face defect through exactly that gap. This comment is the record that closes it; the removal below is a consequence of it, ⛔ not a substitute.

Verdict being discharged against

field value
verdict comment 5673556546 — fetched here, ⛔ not cited from a report
author / created claude[bot] · 2026-09-15T02:04:18Z
verdict PASS, round 1, independent contract review, 24 claims over two agreeing enumerations
head the verdict declares a1da4f875eae858a31935507f3bfa5972850b337
head the PR carries (GET /pulls/9543head.sha) a1da4f875eae858a31935507f3bfa5972850b337
comparison string equality, both length 40, all 40 characters — ⛔ not a prefix, ⛔ not a short sha. Equal.

Read at 2026-09-15T02:07Z.

Why the carrier was hung, and what the review did with it

Card #8648 declares Clause-②: yes on the card's own ground — declaring a key is a published-type widening on @object-ui/types. The conservative direction was declared before the outcome was known; the review measured both halves rather than adopting it:

  • Exactly four members, and nothing else. It built the published surface at this head and at the derived merge-base, snapshotted all 65 .d.ts / .d.mts files both times, and found exactly one file differing. UIActionSchema goes 29 → 33 members: disabled, recordIdField, resultDialog, undoable added, none removed, other declarations unchanged.
  • The accept set lands ON the contract's, not past it. Per key, Action[KEY] and UIActionSchema[KEY] have identical type strings, are mutually assignable, and are optional on both sides — with a direction control in the same program (numberstring false) proving the relation instrument discriminates. ⭐ And it mechanized that half rather than asserting it: its own ablation leg widened the mirror past the contract (disabled?: any) and tsc went red with TS2344.

⇒ something genuinely widens, the carrier was not a false alarm, and the widening is bounded by the contract. Clause-②: yes stands, now on measurement rather than on the conservative default.

Other gates at this same head

  • PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9543 → exit 0, run from /home/user/objectstack after the verdict landed, captured as cmd > file 2>&1; EXIT=$?. Output: "both carriers agree". Re-run after the removal below.
  • total_count 36 == 36 returned; 36/36 completed; {success: 33, skipped: 3}; 0 not-green, 0 still running; legacy combined success (1 context, Vercel); mergeable_state clean. ⚠️ Draft check set — the undraft starts a 37th check, so ③ is re-taken afterwards.
  • ⚠️ Test (shard 1/4) was success at 890 s against the 1200 s ceiling. objectui#9499 was not hit. Worth noting for that card: the tightest shard this run was 2/4 at 1032 s (168 s margin), not 1/4 — the second run in a row where the shard nearest the ceiling is not the one this lane has been naming.

What is being removed, and from where

needs:contract-review comes off PR #9543 and off card #8648, by targeted DELETE /issues/{n}/labels/needs%3Acontract-review on each — ⛔ not by writing a replacement set, which would silently drop anything a concurrent writer added. Each removal is read back and diffed against the set observed immediately before it.

Label sets before removal — PR #9543: package: types, package: components, tests, needs:contract-review. Card #8648: domain:spec, priority:p3, pm:dispatched, needs:contract-review.

Three findings the review recorded are NOT discharged by this comment and do not block the merge: objectui#9542 under-counts the drift by one member (resultDialog.fields[].label, same I18nLabel-vs-string mechanism) and that correction belongs on #9542, not here; this PR's own acceptance note says it does not carry needs:contract-review, which is stale — the timeline shows the labeler's three labels and then the carrier applied by the claiming seat 28 seconds later; and two sibling renderers outside this card's named file surface still read these keys through as any, recorded so the enforcement's edge is not overstated.


Generated by Claude Code

@claude
claude Bot marked this pull request as ready for review September 15, 2026 02:10
@claude
claude Bot added this pull request to the merge queue Sep 15, 2026
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Enqueue record — PR #9543

Taken at 2026-09-15T02:09Z2026-09-15T02:11Z (UTC), continuing the carrier discharge record
posted immediately above.

Carrier removal (the promise made in the discharge record, discharged)

needs:contract-review came off by targeted
DELETE /issues/{n}/labels/needs%3Acontract-review on each carrier — not by writing a
replacement set. Each removal was read back and compared as a JSON set against the set
observed immediately before it (a whitespace join is not a label set: package: types splits
into two tokens under one).

carrier before after removed added
PR #9543 ["needs:contract-review","package: components","package: types","tests"] ["package: components","package: types","tests"] ["needs:contract-review"] []
card #8648 ["domain:spec","needs:contract-review","pm:dispatched","priority:p3"] ["domain:spec","pm:dispatched","priority:p3"] ["needs:contract-review"] []

Both diffs are exactly {needs:contract-review} removed, nothing added. Both DELETEs returned
200; the readback, not the status code, is what makes that a reading.

The three pre-landing gates, re-taken at the landing head

① same-form PASS at the landing head. Verdict comment 5673556546 declares
a1da4f875eae858a31935507f3bfa5972850b337; pulls/9543.head.sha read at 02:09:26Z is
a1da4f875eae858a31935507f3bfa5972850b337. Compared as strings, both length 40 —
STRING EQUAL: True. Not a prefix match, not a short sha.

② carrier agreement, re-run after the verdict landed and after the label removal.
PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9543
→ exit 0: "the clause-② declaration is readable in the fixed spelling and both carriers agree,
and a review of record names this head"
. Its C6-RECORD note names comment 5673556546 as the
review of record on this head — existence, not verdict; the verdict half is precondition ①
above and stayed human.

③ check census. Before the ready flip: total_count 36 == 36 returned,
{success: 33, skipped: 3}, 0 not-green, 0 running, legacy combined status success.
After the ready flip the 37th check (Governed Surface Queue Guard, started 02:10:06Z,
completed 02:10:18Z, 12 s) appeared and the census was re-taken: total_count 37 == 37
returned, {success: 34, skipped: 3}, 0 not-green, 0 running, legacy combined status success,
mergeable_state clean, draft false. Read from the job-level conclusions, not from an
aggregate field.

Landing

  • POST /pulls/9543/ccr/ready_for_review{"draft":false}; read back on GET /pulls/9543
    as draft: false and on the timeline as ready_for_review at 02:10:00Z. (PATCH /pulls/{n}
    with draft:false returns 200 and is a no-op — it was not used.)
  • PUT /pulls/9543/ccr/auto_merge {"merge_method":"SQUASH"}{"enabled":true,"merge_method":"squash"}.
  • Verified on the timeline, which is the only place that says it actually happened:
    added_to_merge_queue at 02:10:47Z. (auto_merge reads back null once queued — it is
    consumed by the enqueue, so its absence is not evidence of failure.)

Measured queue transit on this lane has been 18.5–20.2 min.

Not discharged by this landing — three findings that ride forward

The review returned PASS on this PR's contract question. Three findings it raised are not
answered by merging it, and are recorded here so they are not lost with the branch:

  1. finding(core/app-shell): ResultDialogSpec hand-copies the contract's resultDialog block and has drifted — title / description / acknowledge are string here and I18nLabel in the contract #9542 under-counts the drift by one member. Its census misses
    resultDialog.fields[].label. The card's number should be corrected at its source rather
    than re-derived by each reader.
  2. This PR's own acceptance note is stale: it states the PR does not carry
    needs:contract-review. It did carry it, from before the review through to the removal
    recorded above. The note describes a PR that did not exist.
  3. Sibling renderers outside this card's file surface still read these four keys through
    as any.
    Declaring them on UIActionSchema does not by itself retire those casts; the
    read sites are a separate, dispatchable piece of work.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant