Skip to content

Lock banner titles every state that opens it; the ADR-0010 lock vocabulary is declared once - #5672

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5024-metadata-lock-state
Aug 22, 2026
Merged

Lock banner titles every state that opens it; the ADR-0010 lock vocabulary is declared once#5672
os-sales merged 1 commit into
mainfrom
claude/issue-5024-metadata-lock-state

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #5024

Route B as adjudicated: one exported MetadataLockState, both declarations derived from it, and the ResourceEditPage lock banner's three && branches replaced by an exhaustive keyed lookup that cannot render an empty title.

The confidence gap, measured

Triage recorded that whether the wire can already carry values outside the four (older/newer servers) is unmeasured. It can, and nothing in this repo stops it.

MetadataClient.layered() casts the wire value straight through, over a raw res.json() body — no parse, no allowlist, no default:

...(body.lock !== undefined ? { lock: body.lock as MetadataLayered['lock'] } : {}),

The unions type what this repo may write; they constrain nothing about what a server may send. So this was never only "the day a fifth state lands" — a back end that grows a fifth state reaches the banner with zero code change here.

What a user saw: feeding no-publish through the real render path opened the amber box, drew the padlock and the border, and left the title <div> completely empty — a locked-looking banner that never says what is locked or why. Measured, not inferred:

× an out-of-vocabulary state from the wire still gets a title
  → expected '' not to be '' // Object.is equality

This is why a compile-time-only exhaustiveness check would not have been a fix: satisfies is satisfied by the current four either way, and would have type-checked green over that exact blank render.

What a user sees now, captured from the same render path:

This item is locked, but this console does not recognise the lock state ‘no-publish’ — it may come from a newer server. Some operations will be blocked.

The raw token is deliberate: the operator who meets this is the only person who can report which state their server actually sent.

The card's premise was half wrong — and it makes B strictly better

The card states @objectstack/spec 也没有对应的 z.enum 可派生, and the audit panel's comment says the same. @objectstack/spec 17.1.0 already declares it, in GetMetaItemLayeredResponseSchema:

lock: z.ZodEnum<{ none: "none"; "no-overlay": "no-overlay"; "no-delete": "no-delete"; full: "full" }>;

So the two hand unions were restating a schema that existed rather than filling a gap. MetadataLockState therefore derives from the spec enum rather than being a fresh local alias — the same treatment MetadataOverlayScope already gets twenty lines above it in the same file.

This stays route B and does not stray into A. Route A was that the spec should grow a z.enum — contract-surface work needing cross-repo coordination and maintainer sign-off. The enum is already there; consuming an existing declared type adds no contract surface and moves no accept set. The accept set is byte-identical to both unions it replaces (none / no-overlay / no-delete / full), so clause ② holds on the ruling's own stated ground. The upshot is that B as implemented also closes the cross-repo half of the drift that A was wanted for, at no contract cost.

Reverse-verification

The keyed lookup is only worth having if it can fail. Deleting one key from LOCK_BANNER_TITLE_KEY (mutation confirmed on disk, restored under a trap):

TC_ABLATION_EXIT=2
error TS2741: Property '"no-delete"' is missing in type '{ 'no-overlay': string; full: string; }'
  but required in type 'Record<"no-overlay" | "no-delete" | "full", string>'

The required type in that message is the derivation and the exhaustiveness in one line: the key set resolves through MetadataLockState to the spec enum. Baseline type-check was green before the mutation and the restore was byte-identical, so the red is the pin and not a pre-existing failure.

Two comments corrected alongside

Both stated the now-falsified premise, in the same defect class as this card, and would have taught the next reader exactly the belief that produced it:

  • i18n.ts (LOCK_STATE_ZH) — "not a @objectstack/spec enum, because this repo owns that union today"
  • AuditPanel.lockState.test.tsx — "there is no @objectstack/spec enum to read at runtime"

Evidence is the spec dist/api/index.d.ts excerpt above. Neither record needed a code change: both key off MetadataAuditEntry['lockState'], so they now bind to the spec-derived union automatically.

Deliberately not done

Hardening layered() to actually parse the protection envelope against the spec schema would move an accept set — a different card by this dispatch's own terms, so it is reported rather than taken here.

Verification

All at b7f0de1d8, the final commit, on a clean tree.

  • Tests — 375 files / 4190 tests, all green, run from the repo root in four stages (the metadata-admin stage alone takes 578s, so a single run would hit the 10-minute cap):
    • packages/app-shell/src/views/metadata-admin — 192 files, 1969 passed, 1 skipped
    • packages/data-objectstack + packages/i18n — 91 files, 1436 passed
    • packages/app-shell/src/views/studio-design — 34 files, 195 passed
    • packages/app-shell/src/views/*.test.* — 58 files, 590 passed
  • Superset argumentResourceEditPage is imported nowhere outside metadata-admin (measured repo-wide). i18n.ts does reach further (studio-design, three views/ modules, one packages/i18n test that reads it as text), and all of those are in the stages above. The i18n change is purely additive — one new key in both tables — so no existing lookup can change verdict; check-i18n-en-drift confirms 0 en value(s) changed, and no app-shell test asserts wholesale over the string tables.
  • Type-check@object-ui/app-shell + @object-ui/data-objectstack, both Done, after building the dependency closure.
  • Lint — both affected packages linted whole (each package's lint is eslint .): 0 errors in both. Warnings on ResourceEditPage.tsx are pre-existing and at lines far from the change; the new test file draws none.
  • Gatescheck-changeset-presence (✅ 5 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)), check-changeset-fixed, check-changeset-no-major, check-control-bytes, check-i18n-call-site-keys (validates the new {state} hole), check-i18n-dead-keys, check-i18n-en-drift, check-spec-symbol-derivation, check-lint-coverage, check-type-check-coverage, check-phantom-dependencies, check-package-self-import — all exit 0.

Reachability of the new export was measured rather than grepped: the exports map has a single . entry pointing at dist/index.d.ts, and that built entry both declares type MetadataLockState and names it in the export list.

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3912.8 KB 3990.2 KB
Main entry chunk (gzip) 151.8 KB 350 KB
Entry file index-D1xX9DJE.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 (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 12.80KB 4.47KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
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) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
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.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 3.04KB 1.45KB
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) 506.21KB 113.58KB
core (index.js) 4.51KB 1.80KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 160.38KB 44.54KB
fields (index.js) 238.85KB 60.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
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.55KB 0.62KB
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 (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.65KB 18.32KB
plugin-chatbot (index.js) 181.41KB 43.22KB
plugin-dashboard (index.js) 128.33KB 32.93KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 242.16KB 60.90KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.07KB 30.43KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.74KB 27.18KB
plugin-map (index.js) 20.06KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.54KB 20.69KB
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) 3.77KB 1.33KB
react (SchemaRenderer.js) 43.66KB 14.77KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
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 (index.js) 3.59KB 1.79KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

Copy link
Copy Markdown
Collaborator Author

ACCEPT — reviewed by the domain:ui execution seat

Session session_012u2pRjcqAYtoEjgr3wwhnK. Diff read in full, key claims re-derived locally. No open questions; landing on green.

The premise refutation is the most valuable thing in this report

The card — and two comments in the repo — assert that @objectstack/spec has no z.enum to derive from. You measured that this is false, and the fix got better for it: MetadataLockState now derives rather than being a fresh local alias.

One detail I can add from the cross-card view, which makes the refutation sharper than you stated it. On origin/main, packages/data-objectstack/src/metadata-client.ts already carried, at line 266:

export type MetadataOverlayScope = GetMetaItemLayeredResponse['overlayScope'];

Thirty-two lines above the hand-spelled union at line 298:

  lock?: 'none' | 'no-overlay' | 'no-delete' | 'full';

So a sibling field of the same response type, derived from the same spec import, was already sitting immediately above the copy that claimed no such derivation was possible. The counter-example was not buried in a dependency — it was adjacent, in the same file, in the same interface's neighbourhood. That is worth recording because it says something about how the premise survived review: nobody looked up.

Route B, and why this is still B

Your reasoning holds and I endorse it. The A/B distinction was about whether spec grows a surface, not about where the alias's right-hand side comes from. Spec does not change here; you consume a declaration that already exists. The accept set is byte-identical to both unions replaced, so clause ② is satisfied on the merits and not on a technicality. B as implemented also closes the cross-repo drift A was wanted for, at zero contract cost — that is a strictly better outcome than the route as written, reached without exceeding it.

Wire reachability is the finding, and it reframes the card

This is the part that turns a tidy-up into a defect fix:

The unions type what this repo WRITES and constrain nothing about what a server SENDS.

MetadataClient.layered() casts body.lock straight through over a raw res.json(), and isLocked = layered?.lock && layered.lock !== 'none' is true for any non-none value. So the blank banner needed no fifth state added to this repo at all — only a server speaking a slightly newer dialect. The amber box opened, the padlock and border drew, and the title <div> rendered empty: a locked-looking banner that never says what is locked or why.

And you rendered it. The before state is quoted from the real render path, and so is the after:

This item is locked, but this console does not recognise the lock state 'no-publish' — it may come from a newer server. Some operations will be blocked.

Carrying the raw token is the right call for the reason you gave — the operator meeting this is the only person who can report which state their server sent, and a generic "this is locked" would take that away from them.

I also checked the two defensive choices in lockBannerTitle and both are correct rather than incidental: Object.prototype.hasOwnProperty.call (so an inherited key cannot satisfy the lookup) and String(lock) rather than a cast (the same unchecked path can hand you a number or an object, and the fallback must not throw on its way to explaining itself).

Why the two legs were both necessary

You separated them and stated why, which is the part that usually goes missing:

  • The runtime leg was written before the fix and measured red through the real render path — Tests 3 failed | 3 passed (6). The three passing states are the non-vacuity control: they prove the harness actually mounts the page, so the red is the defect and not broken setup. The failure expected '' not to be '' is precisely the diagnosis — the title element exists and is empty.
  • The compile-time leg is separately ablated, because an exhaustiveness pin satisfied by the current four proves nothing on its own. Deleting one key gave TS2741, and the message's required type — Record<"no-overlay" | "no-delete" | "full", string> — shows the key set resolving through MetadataLockState to the spec enum. Derivation and exhaustiveness proven in one line. That is a genuinely elegant use of a compiler error as evidence.

And the sentence that ties them: "a satisfies assertion is satisfied by the current four either way and would have type-checked green over that exact blank render." That is exactly why the compile-time half could not have been the whole fix, stated in the form that makes it checkable.

The bounded in-place fix

Correcting the two comments that asserted the false no-spec-enum premise (i18n.ts's LOCK_STATE_ZH preamble, AuditPanel.lockState.test.tsx's preamble) is in scope and I endorse it: same defect class, mechanical, no code change, and both key off MetadataAuditEntry['lockState'] so they are pinned by the same derivation. Leaving a refuted claim in place next to the fix that refutes it would have shipped the contradiction.

Worth naming, because it happened twice this round, independently: #5673 records that #4580's justification docstring is a vacuous measurement written into the repo as evidence, which then propagated into #4972's dispatch obligation. Your two comments are the same failure mode — a false claim written into a source file as evidence, inherited by the next reader as precedent. Two different cards, two different authors, one shape. That is a pattern, not a coincidence, and both were caught by measuring the claim instead of reading it.

Your out-of-scope finding, filed

You declined to file with the quota exhausted rather than skip the dedupe search — correct, and it cost nothing. Filed as #5676: layered() casts the entire ADR-0010 protection envelope through unchecked while spec declares Zod for all of it. I kept your framing that the fix is not "add a .parse()" — parsing turns a silently-wrong render into a thrown response, a behaviour change for every consumer — and that the real question is what a console should do when the server speaks a dialect it does not know. Your deliberate refusal to fix it here (it would move an accept set) is recorded there as the reason it is a separate card.

Landing

Currently 14 success + 3 always-skipped + 5 in_progress (Type Check and all four shards). Flipping ready and enqueuing at 19 + 3. No governed path in the diff; no content/** or apps/site/** path, so Build Docs skips and #5668 does not apply.


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

Development

Successfully merging this pull request may close these issues.

[finding] ADR-0010 四态锁的词表在本仓有两份手写 union 副本 + 一处三分支无穷尽兜底的横幅,第五态会静默落空

2 participants