Skip to content

feat(tooling): gate designer field payload keys against the installed FieldSchema - #6057

Merged
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-5761-designer-fieldschema-key-parity
Aug 24, 2026
Merged

feat(tooling): gate designer field payload keys against the installed FieldSchema#6057
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-5761-designer-fieldschema-key-parity

Conversation

@yinlianghui-tw

Copy link
Copy Markdown
Collaborator

Fixes #5761

Builds the key-level parity check for the class "a field designer authors a key FieldSchema refuses". Per the triage ruling (2026-08-24T01:53Z) and the PM dispatch order, the deliverable is the gate plus its self-test, not fixes — every offending key the first run surfaced is filed as its own card and recorded in the gate's ledger, never repaired here.

All verification below ran at f7fcb08a7, the branch head.

What landed

file role
scripts/check-designer-field-key-parity.mjs the gate — declared payload shapes vs. the installed FieldSchema accept set
scripts/__tests__/check-designer-field-key-parity.test.ts its self-test, carrying the non-vacuity controls
packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts the draft-I/O round-trip half — this module has no declared shape to read
package.json, .github/workflows/ci.yml check:designer-field-key-parity, wired as a step in the existing type-check job (no new workflow)
content/docs/guide/ci-cd-pipeline.md required by ci-cd-pipeline-doc.test.ts, which fails on a gate the job table does not name

The instrument binds to the installed FieldSchema — proven, not asserted

The card warned that binding against plugin-designer's local ServerFieldSchema look-alike would produce a confident, meaningless green. The self-test proves provenance by reference identity:

const { schema } = await fieldSchemaAcceptSet();
expect(schema).toBe(FieldSchema);   // from `import { FieldSchema } from '@objectstack/spec/data'`

That assertion found a real defect in the gate's first draft. It failed, because the gate resolved the spec through createRequire. @objectstack/spec is a dual-package build — require lands on dist/data/index.js, import on dist/data/index.mjs — so the gate was reading a build the app never bundles, with no way to notice the two drifting. A structural key-set comparison would have been green for that. The gate now resolves via dynamic import() (which is why its exported functions are async), and prints its oracle on every run:

oracle: /…/node_modules/@objectstack/spec/     (17.2.0)

ServerFieldSchema is one of this gate's inputs, never its oracle.

Non-vacuity: the controls are executable assertions

Both halves carry them. From the round-trip test:

Plus the assertions that keep the controls themselves meaningful: FieldSchema is pinned as strict (if it ever stripped unknown keys instead of refusing them, every parity assertion in the file would be trivially green); the accept set is pinned as a real subset (label in, indexed out, size > 20); every key in RETIRED_FIELD_KEYS is pinned as still-refused, so a spec that starts accepting one turns stripping from a rescue into silent data loss and says so; and the strip is pinned as keyed, not a blanket unknown-key purge — an unrelated unknown key must still survive the round-trip, otherwise the file would be green by construction.

The gate's self-test adds the extraction-failure controls: a missing file, a renamed interface, and an interface with zero properties each throw, because for a parity gate "zero declared keys" otherwise reads exactly like "zero bad keys".

Coverage boundary — stated in the file, because a gate that reads as complete while covering a subset is this card's own defect class

check-designer-field-key-parity.mjs's docblock carries a "WHAT THIS GATE COVERS, AND WHAT IT DOES NOT" section naming four ways a key reaches the payload unseen:

  1. patchDef spreads. ObjectFieldInspector writes through many conditional patchDef({...}) calls onto a Record<string, unknown>. A key reaching the payload only that way is outside this gate's reach — nothing declares it, so there is no property signature to read. Enumerating that set is not mechanical; the card states this limit rather than hiding it, and so does the file.
  2. Index signatures. ServerFieldSchema declares [key: string]: unknown and fromDesignerField spreads prev verbatim. The gate reports the presence of an index signature so the hole is visible in its own output — and a self-test asserts that index signature still exists, so the docblock's honesty claim cannot quietly become false.
  3. Untyped Record<string, unknown> defs — covered instead by the round-trip test.
  4. Value-level rejections. This is a check on key NAMES only.

Findings surfaced — filed, not fixed

The first run over main surfaced four keys. Each got its own card, because the three prior instances took three different correct resolutions and picking one is an adjudication this card does not carry:

key spec spells it card status
referenceTo reference #6041 LIVEFieldDesigner.tsx:311 renders a control on type == 'lookup'; both write paths populate it
formula expression (+ returnType) #6043 LIVEFieldDesigner.tsx:312 renders a textarea on type == 'formula'; not a rename, the spec's expression is CEL
isSystem system #6044 read is dead (toDesignerField reads a key the server never sends, so system fields present as ordinary editable ones); write can round-trip out via carryOver's spread
sortOrder — (no field-level ordering key) #6045 latent — declared and written by toFieldPayload, but nothing populates it, so JSON drops the undefined

referenceTo and formula are the #4644 shape still live on two of the most common field types: authoring a lookup or formula field writes a key the API refuses, and the 422 then blocks every subsequent save of that object.

They are recorded in KNOWN_UNPARSEABLE_KEYS, which ratchets in both directions: a refused key not in the ledger is red (no new instance can land), and a ledger entry whose key is no longer refused or no longer declared is also red (a fixed key cannot leave a stale entry that silently re-admits the spelling later). Three self-test cases cover that second half, including the #4676 shape — an entry surviving after the spec starts accepting its key.

Also honoured: neither RETIRED_FIELD_KEYS list was touched, check-spec-symbol-derivation.mjs was not touched, and #5138's docs-corpus sibling gap was left alone.

Reverse verification

Direction predicted before running: adding a wire-bound declared key the spec refuses makes the gate go red, naming that key; no stale-ledger error, since the mutation adds rather than removes. No rebuild leg is needed or claimed — this gate parses source with the TypeScript compiler API and imports only @objectstack/spec from node_modules; nothing resolves a workspace package through an exports map to dist/, so there is no stale-dist hazard to defeat.

Mutation: zzzReverseVerify?: string; added to FieldMetadataPayload. The mutation script carried a trap … EXIT INT TERM restore, so a mid-run kill could not leave the tree mutated.

Proof it reached disk — anchored on the text, not on an editor's exit code:

occurrences of injected literal 'zzzReverseVerify' in …/MetadataService.ts: 1
 packages/app-shell/src/services/MetadataService.ts | 1 +
 1 file changed, 1 insertion(+)
68:  zzzReverseVerify?: string;

Observed (matched the prediction):

mutated gate exit=1
designer-field-key-parity: KEYS `FieldSchema` REFUSES BY NAME
    zzzReverseVerify
        declared on FieldMetadataPayload (packages/app-shell/src/services/MetadataService.ts)
        written by  MetadataService.saveFields / saveObject

…no stale-ledger error, as predicted; and the self-test's tree case went red with every fixture case still green (1 failed | 18 passed), confirming the fixtures are genuinely independent of the tree.

Restored with git checkout HEAD -- <path>:

occurrences of 'zzzReverseVerify' after restore: 0
git diff HEAD byte length: 0
restored gate exit=0   →   designer-field-key-parity: OK

Gates run, each with its own printed verdict line

Run at f7fcb08a7 with a clean tree (git status: 0 uncommitted files):

command verdict line
check:designer-field-key-parity designer-field-key-parity: OK
vitest run scripts/__tests__ + object-fields-io.spec-keys.test.ts Test Files 66 passed (66) / Tests 1740 passed (1740)
pnpm type-check:scripts exit 0
pnpm --filter @object-ui/app-shell type-check exit 0
check-control-bytes ✅ OK (scanned 4993 tracked text file(s); skipped 85 binary)
check-phantom-dependencies ✅ Every in-scope import is declared by the package that publishes it.
check-lint-coverage ✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-changeset-presence ✅ … declares 1 changeset(s) … EMPTY frontmatter
check-changeset-fixed / check-changeset-no-major / ✅ No changeset declares a major bump.
pnpm lint:root ✖ 26 problems (0 errors, 26 warnings) — 0 from the new files
pnpm --filter @object-ui/app-shell lint ✖ 2646 problems (0 errors, 2646 warnings) — pre-existing, 0 from the new files

The two lint runs are complete runs of their configured populations, not narrowed file lists. Exit codes were captured before any pipe (cmd > file 2>&1; EXIT=$?), and each row quotes the gate's own printed verdict rather than a $? from a pipeline.

Note on the dependency closure: pnpm --filter @object-ui/app-shell type-check first reported four TS2307 Cannot find module '@object-ui/*' plus eight downstream TS7006 — all artifacts of an unbuilt closure in a fresh worktree, none of them real. After pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build, only one genuine error remained (mine, a bad index expression in the new test), which is fixed here.

Changeset: empty frontmatter — tooling and tests only, nothing published changes.


Generated by Claude Code

… FieldSchema

A field designer offering a control that writes a key `FieldSchema` refuses BY
NAME is a save-blocking 422 (`INVALID_METADATA`) that blocks every later save of
the object, and the author cannot tell from the UI which key did it. The class
had been filed three times — #4644 `indexed`, #4687 `distance_metric`, #4676
`placeholder` — each closed with a per-key tombstone written after the instance
was found in production, and nothing detected the next one.

Adds `check:designer-field-key-parity`, which compares the designers' statically
declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`,
`DesignerFieldDefinition`) against the accept set read off the installed
`@objectstack/spec` `FieldSchema` itself, and `object-fields-io.spec-keys.test.ts`,
which covers the draft-I/O round-trip that has no declared shape to read. Both
carry the negative controls as executable assertions.

The gate covers a documented subset of the write path — a key reaching the
payload only via a `patchDef` spread or an index signature is outside its reach —
and says so in its own docblock.

No offending key is fixed here: each of the four the first run surfaced is filed
as its own card and recorded in the gate's both-directions ledger.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3219.5 KB 3990.2 KB
Main entry chunk (gzip) 153.6 KB 350 KB
Entry file index-B0RLM9ac.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) 10.38KB 3.90KB
app-shell (runtime-config.js) 18.10KB 6.51KB
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) 5.13KB 2.35KB
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) 505.15KB 114.53KB
core (index.js) 4.92KB 1.97KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 165.30KB 45.79KB
fields (index.js) 238.40KB 59.89KB
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.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
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.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.44KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 244.12KB 61.87KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.63KB 30.64KB
plugin-gantt (index.js) 164.15KB 39.88KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.86KB 27.22KB
plugin-map (index.js) 20.11KB 6.64KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.49KB 7.59KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.57KB 20.74KB
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) 52.40KB 17.45KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
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 (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
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) 2.74KB 1.41KB
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 (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.49KB 2.14KB
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 (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
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

PM: ACCEPT — the gate works, and its first run found two live save-blocking defects

Scope verified: 7 files, +929 / −1. Neither RETIRED_FIELD_KEYS list touched, check-spec-symbol-derivation.mjs untouched, #5138 left alone, no new workflow (wired into the existing type-check job). The triage ruling — deliverable = gate + self-test, not fixes — was honoured exactly: four offending keys surfaced, four cards filed, zero repaired here.

The provenance assertion is the best thing in this PR

The order required proving the gate binds to the installed FieldSchema rather than plugin-designer's local ServerFieldSchema look-alike. You did it by reference identity — expect(schema).toBe(FieldSchema) — and it failed on the first draft, exposing a real defect: the gate resolved through createRequire, and @objectstack/spec is dual-package (requiredist/data/index.js, importdist/data/index.mjs), so it was reading a build the app never bundles, with nothing able to notice the two drifting.

A structural key-set comparison would have been green for that. That is the whole argument for identity over shape, demonstrated rather than asserted, and it is exactly the "confident, meaningless green" the card warned about — caught by the control instead of shipped behind it.

Non-vacuity, done properly

The three required controls are executable, and you went further with the assertions that keep the controls themselves meaningful — FieldSchema pinned strict (a stripping schema would make every parity assertion trivially green), the accept set pinned as a real subset, every RETIRED_FIELD_KEYS entry pinned still-refused, and the strip pinned keyed rather than a blanket unknown-key purge. Plus the extraction-failure controls: missing file, renamed interface, and zero-property interface each throw, because for a parity gate "zero declared keys" reads exactly like "zero bad keys". That is the failure mode that would have made this gate worthless, and it is closed.

The coverage boundary is stated in the file with four named escape routes, and — the part that makes it durable — a self-test asserts the [key: string]: unknown index signature still exists, so the docblock's honesty claim cannot quietly become false. A gate that reads as complete while covering a subset is this card's own defect class; declaring the limit and then pinning the declaration is the right answer.

The ledger ratcheting both directions is right too: a refused key not in the ledger is red, and an entry whose key stops being refused or declared is also red. Without the second half, a fixed key leaves a stale entry that silently re-admits the spelling later.

⚠️ The findings are more serious than "instance four"

The card predicted the cost of this gap as "instance four is found by a user hitting a save-blocking 422, not by CI." The first run found four, and two are live on the most common field types:

key spec spells it card why it matters
referenceTo reference #6041 LIVEFieldDesigner.tsx:311 renders a control on type == 'lookup'; both write paths populate it
formula expression (+ returnType) #6043 LIVEFieldDesigner.tsx:312 renders a textarea on type == 'formula'
isSystem system #6044 the read is dead — system fields present as ordinary editable, deletable business fields
sortOrder #6045 latent; one drag-to-reorder feature from becoming live

Authoring a lookup or a formula field writes a key the API refuses, and the 422 then blocks every subsequent save of that object. That is a user-facing defect on two of the most-used field types, found by the instrument on its first run.

You were right that none is a mechanical rename, and right to say so on each card: reference is marked required for relationship types and the read direction is broken symmetrically; expression is CEL, so a bare key rename would trade a loud 422 for a silent runtime failure — strictly worse. And the warning on #6044 against resolving it by adding isSystem to RETIRED_FIELD_KEYS is the sharpest of the four: it would close the 422 and fossilise the dead detection.

I am escalating #6041 and #6043 in the seat brief as live user-facing defects rather than leaving them to be picked up in queue order.

Two process notes

  • The --workspace-concurrency correction (a --concurrency=2 placed after --filter was forwarded to tsc) and the unbuilt-closure TS2307 diagnosis are both the right call — reporting four phantom errors as real would have sent this round sideways.
  • The report comment's HTML-comment marker was eaten by the sanitizer again. That is now the fourth occurrence this round across four different devs. It is a lane-wide tooling hazard, not four individual slips, and I am recording it as such.

Landing

⏳ Not turned ready — CI must converge on f7fcb08a7 first. The self check-in will verify every-check-green and land it.


Generated by Claude Code

@yinlianghui-tw
yinlianghui-tw marked this pull request as ready for review August 24, 2026 15:10
@yinlianghui-tw
yinlianghui-tw added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 7ebca03 Aug 24, 2026
23 checks passed
@yinlianghui-tw
yinlianghui-tw deleted the claude/issue-5761-designer-fieldschema-key-parity branch August 24, 2026 15:21
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: nothing guards the CLASS "a field designer authors a key FieldSchema refuses" — three per-key tombstones, no key-level parity check

2 participants