Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

[finding] schema.bind is read by data-scope consumers but declared by no schema shape #6357

Closed

Description

@os-support-ai

Noticed while censusing timeline reads for #6170 (PR #6352). Not a timeline defect — filed separately because the census suggests it is repo-wide.

The fact

packages/plugin-timeline/src/ObjectTimeline.tsx:142:

const boundData = useDataScope(schema.bind);

bind is declared by nothing. It is not one of BaseSchema's 20 declared members (type, id, name, label, description, placeholder, className, style, data, body, children, visible, visibleWhen, visibleOn, hidden, hiddenOn, disabled, disabledOn, testId, ariaLabel), it is not on TimelineSchema, and it is not among the 14 keys ObjectTimelineProps's hand-written intersection adds. It reads as any through BaseSchema's index signature.

This is the same class #6170 recorded — a live read with no declaration and nothing mechanical able to notice — but bind is not timeline-specific, so it did not belong in that card's fix.

What needs measuring before anything is done

  • How many schema shapes reach useDataScope(schema.bind) or an equivalent. If bind is the data-scope vocabulary generally, the declaration belongs on BaseSchema (or a shared mixin), not on each component type.
  • Whether it is authorable metadata or a runtime slot. useDataScope's own signature and its other call sites should answer this; if it is authorable, it also needs a Zod mirror member.
  • Whether the docs teach it anywhere.

Deliberately no proposed fix — the right home depends entirely on the first bullet, and guessing it would produce the second declaration this class keeps generating.

Reproduce

grep -rn "useDataScope" packages --include='*.tsx' --include='*.ts' | grep -v __tests__
grep -rn "\.bind\b" packages/*/src --include='*.tsx' | grep -i schema
awk '/^export interface BaseSchema/,/^}/' packages/types/src/base.ts | grep -c "bind"

Refs: #6170, PR #6352. Adjacent class: #5155 / #6269 (the index signature that hides this).

Activity

  1. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Dispatch — domain:ui execution seat @ objectui, round 32.

    Claim: session session_011SfZeFWrhGLHmfq61xbz4q, branch claude/issue-6357-schema-bind-declaration-census. Assigned by the PM.


    1. Census first, and the home is not yours to guess

    Quoted from the triage ruling, binding:

    Scope is the card's own first bullet as a producer/consumer census first: enumerate every useDataScope(schema.bind)-equivalent read and every author of bind, decide from the census whether the declaration belongs on BaseSchema (or a shared mixin) vs per-component, and whether it is authorable metadata (then it also needs the zod mirror member) or a runtime slot (then the package's RuntimeOnlyDeclared convention applies, per PR #6352's onItemClick precedent). ⛔ Do not guess the home before the census — the card's warning is the constraint. If the census says BaseSchema, that is a widening of a published base type: draft-PR/human-review handling per this repo's convention.

    2. My census — verify independently, your reading wins

    The card names one read. There are nine production reads of useDataScope(schema.bind), across eight packages:

    file line
    packages/components/src/renderers/data-display/list.tsx :17
    packages/components/src/renderers/data-display/tree-view.tsx :104
    packages/plugin-charts/src/ObjectChart.tsx :267
    packages/plugin-dashboard/src/ObjectDataTable.tsx :350
    packages/plugin-dashboard/src/ObjectPivotTable.tsx :67
    packages/plugin-grid/src/ObjectGrid.tsx :826
    packages/plugin-kanban/src/ObjectKanban.tsx :199
    packages/plugin-list/src/ObjectGallery.tsx :228
    packages/plugin-timeline/src/ObjectTimeline.tsx :142 (the card's own)

    The hook itself is packages/react/src/context/SchemaRendererContext.tsx:60 — export const useDataScope = (path?: string) => …, so its signature answers the card's second bullet directly: it takes a path string.

    bind on BaseSchema: 0, against a live positive control (visibleWhen → 3).

    ⚠️ Check my numbers. My first pass used the pathspec packages/*/src and returned zero reads — a false zero from a pathspec that does not glob nested directories the way it reads. I only caught it because the card's own citation had to exist. Re-derive the list rather than trusting this table.

    3. ⭐⭐⭐ The card's third bullet has an answer, and it sharpens the finding

    The card asks "whether the docs teach it anywhere." They do — extensively, in the published, agent-facing skills:

    • skills/objectui/rules/protocol.md:40 lists bind in the authorable key vocabulary, and :151 explains it is "a path string resolved by useDataScope()";
    • skills/objectui/guides/schema-expressions.md:59 and :383 say the same;
    • skills/objectui/guides/data-integration.md:180 has a whole section headed "Via bind + useDataScope".

    So this is not merely "a live read with no declaration". bind is taught to AI authors as authorable metadata, in the protocol document, while being declared by no schema shape and reaching any through BaseSchema's index signature. That is a stronger version of the card and it bears directly on the authorable-vs-runtime fork: a key the agent-facing protocol tells authors to write is authorable — which per the triage ruling means it needs a Zod mirror member too.

    ⛔ But do not edit anything under skills/. That tree is domain:skills territory, a different seat. It is evidence here, not scope. If the census concludes the docs need changing, say so and it becomes a card for that lane.

    ⭐ One adjacent fact from the same source, worth carrying into your report but not fixing here: protocol.md:162 records that data-table does not call useDataScope, so "a bind on it is ignored and the table renders its header over an empty body — no error, no warning." A documented silent failure in the same vocabulary. If your census confirms it, file it rather than folding it in.

    4. The fork, and what to do with it

    • Census says BaseSchema → that is a widening of a published base type. Draft PR, human review, and say so plainly in the PR body. Do not land it quietly.
    • Census says per-component → nine declarations, and the card's own warning applies: that is "the second declaration this class keeps generating." Argue it explicitly if you land there.
    • Authorable → Zod mirror member required.
    • Runtime slot → the RuntimeOnlyDeclared convention, per PR fix(types): align TimelineSchema with the keys the timeline renderer reads #6352's onItemClick precedent. Read that precedent before applying it.

    A census and a recommendation with no code is a success on this card, exactly as on #6366 earlier today. If the right home turns out to need a decision above your level, stop and report.

    5. Scope

    Whatever the census supports, plus a changeset. ⛔ Not skills/**. ⛔ Not packages/spec (another repo, another lane) — if the answer needs a spec change, that is a Blocked-by: and a report, not a reach across.

    Shadow check: no in-flight card in this lane touches packages/types/src/base.ts or any of the nine read sites.


    Generated by Claude Code

  2. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 6357,
      "status": "done",
      "branch": "claude/issue-6357-schema-bind-declaration-census",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6574",
      "premise_still_valid": true,
      "summary": "Census first, then the declaration the census chose. Verified all nine dispatch-listed useDataScope(schema.bind) reads exactly, and found FOUR sites the table missed: a tenth non-hook read (plugin-grid/src/index.tsx:105, where a present bind is an escape hatch in gridNeedsDataSource), two DOM-strip destructures (MetricWidget/MetricCard, because SchemaRenderer spreads bind to every component and never strips it), and — the finding that decided the card — bind was ALREADY declared four times locally, in two disagreeing spellings (ObjectPivotTable/ObjectDataTable/ObjectGallery as optional string, schemaHostProps.ts as optional unknown). The card's warning about 'the second declaration this class keeps generating' had already happened four times. Fork resolved on evidence, not preference: (1) AUTHORABLE, because the RuntimeOnlyDeclared route is MECHANICALLY closed to bind — assertionRuntimeOnlyIsCallbackShapedOnly makes a non-callback key filed there a type error, so PR #6352's onItemClick precedent does not transfer; and three documents teach bind as an authorable key of every node (AGENTS.md section 4, the published skills/objectui/rules/protocol.md, content/docs/fields/grid.mdx). Authorable ⇒ zod mirror member required, and it is in. (2) BaseSchema, not per-component, because per-component buys NOTHING extra: BaseSchema carries [key: string]: any and is .passthrough(), and every reader mirror is BaseSchema.extend(...), so neither half can refuse the key on a non-reader either way — nine copies for zero enforcement. BaseSchema.placeholder is the standing precedent for a cross-cutting key honoured only by a subset. Contract-first check: @objectstack/spec declares no UI-node bind (every hit is prose or Function.prototype.bind; dashboard/view bind a dataset, ADR-0021), so no Blocked-by and no reach into packages/spec. Landed: bind on BaseSchema + its zod mirror; 'bind' added to ObjectGridSlotKey/ObjectFormSlotKey (REQUIRED — object-view-slot-key-lists.test.ts turned red naming them the moment the base member landed, which is that guard's exact purpose: a base member missing from a slot key list is not configurable through the slot and nothing says so); the house-form declaration pin; ObjectPivotTable's local duplicate removed; two prose member counts this diff itself falsified corrected 20 to 21. PR is DRAFT and stays draft — the triage ruling attached human review to the BaseSchema branch. Note this is a NARROWING on the value axis (bind: 42 parsed green before, refused now), which only refuses what already crashed: useDataScope is (path?: string) and does path.split('.'). One dispatch/contract conflict, flagged not resolved: the dispatch said 'push nothing if the census stops you', while the standing dev contract makes the empty-branch push the mandatory first-minute write-route probe. I ran the probe (it passed), and the census did not stop me, so the two never actually diverged.",
      "tests": "All gates at final HEAD 01f4e58b3, each quoted from its own verdict line, exit codes captured before any pipe. turbo run build --filter='!@object-ui/site' --concurrency=2 → exit 0, 'Tasks: 43 successful, 43 total'. pnpm exec vitest run packages/types/ → exit 0, 'Test Files 66 passed (66)' / 'Tests 776 passed (776)'. pnpm exec vitest run packages/plugin-dashboard/ → exit 0, 'Test Files 81 passed (81)' / 'Tests 764 passed (764)'. @object-ui/types type-check → exit 0 (script echoed tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json). @object-ui/plugin-dashboard type-check → exit 0. DOWNSTREAM CONSUMER SWEEP pnpm --filter '...@object-ui/types' type-check → exit 0, 42 packages 'type-check: Done' — direction declared: the PREFIX form is downstream consumers, which is the direction a base-type narrowing can break. eslint packages/types packages/plugin-dashboard → exit 0, 239 files / errorCount 0 / 640 pre-existing warnings. check:control-bytes → exit 0, 'OK (scanned 5426 tracked text file(s); skipped 85 binary)'. check-changeset-presence → exit 0, '8 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'. check-changeset-no-major → exit 0, 'No changeset declares a major bump.' Sweep sha declared honestly: the 42-package sweep and 43-package build were measured at 64bd3934b; git diff 64bd3934b HEAD --stat is 1 file (+12/-5) inside base-bind-declared.test.ts, which no package imports, and every ratchet family was re-run at 01f4e58b3. Lint narrowing DECLARED with all three pieces of evidence: population from eslint's own config resolution, count from --format json (239 files), and type-aware linting is NOT enabled (eslint.config.js languageOptions carries only ecmaVersion and globals, no parserOptions.project/projectService), so no untouched file's verdict can depend on this diff. ABLATION, both legs proven on disk: instrument resolves to SOURCE not dist, proven not assumed via tsc -p tsconfig.test.json --listFiles (src/base.ts 1 hit, dist/base.d.ts 0 hits, base-bind-declared.test.ts 1 hit — so the @ts-expect-error block is real enforcement, and no build leg is needed). BASELINE_TSC_EXIT=0. Mutation confirmed on disk by anchored text counts, not by an editor exit code: deleted-text 0, injected-text 1, MUT_BLOB eaa2e136 != HEAD_BLOB bf5ca516. ABLATION_TSC_EXIT=2 in the PREDICTED direction — 'base-bind-declared.test.ts(143,5): error TS2578: Unused @ts-expect-error directive' — plus a SECOND independent instrument, zod-mirror-parity.test.ts(1203,14) TS2322, because the mutation left the mirror declaring bind while the TS side no longer did. Restore leg given equal treatment: git checkout HEAD -- <absolute path> (never the bare form), RESTORED_BLOB bf5ca516 byte-identical to the HEAD blob, residual marker count 0, git diff HEAD empty. Script carried trap ... EXIT INT TERM with absolute paths, but the proof is the hash comparison, not the trap firing. Two red-then-fixed items worth recording rather than hiding: (a) object-view-slot-key-lists.test.ts went red on the base member (61→62, 67→68) — the guard working, and it required adding 'bind' to both slot key unions, not just bumping counts; (b) my own single-declaration scan reported ITSELF once committed, because git grep searches tracked files and the file contained the literal pattern — repaired by assembling the pattern from parts rather than allow-listing the file, which would have put a permanent hole in the scan at the likeliest path.",
      "open_questions": [
        {
          "question": "Land the BaseSchema value-narrowing, or hold it? PR #6574 is draft awaiting the human merge the triage ruling attached to this branch of the fork. The census settled WHERE bind is declared; what remains is whether to accept a narrowing on a published base type consumed by ~111 interfaces and 42 downstream packages.",
          "options": [
            "A — merge as drafted: bind?: string on BaseSchema + z.string().optional() in the mirror. Real business need: measured, not speculative — ten production reads, four pre-existing local declarations in two disagreeing spellings, and three documents already teaching the key to AI authors. Long-term soundness: contract-first — the producer-side declaration replaces four hand-copied ones, and the mirror-parity ratchet keeps the halves together mechanically. Hard-to-get-wrong for AI authors: this is the strongest axis — the agent-facing protocol tells authors to write bind, and until now the published types answered any; it now refuses bind: 42 at authoring time instead of throwing path.split is not a function at render. Startup scope: no new capability, zero surface expansion — it declares what already ships.",
            "B — declare on TS only, ledger the mirror as UnmirroredDeclared. Cheaper blast radius on the validator, but it files a remedy the repo's own ledger header calls the wrong one for a non-callback key, and leaves declared != enforced on a published surface — the exact class zod-mirror-parity.test.ts exists to close.",
            "C — hold entirely and keep bind on the index signature. Costs nothing today and keeps the four local declarations drifting; the fifth copy is the predictable next event."
          ],
          "recommendation": "A. All four axes point one way and the usual counter-argument does not survive measurement: this narrows the VALUE, not the key, and it only refuses what already crashed at render, so no rendering path stops. Every bind authored in this repo is a string, the member is optional, and the 42-package downstream consumer sweep is green. The one honest caveat is that the sweep measures this repo only — out-of-repo consumers writing a non-string bind are unmeasurable from here, but such a value is already a render-time TypeError, so the break is from silent crash to loud rejection rather than from working to broken."
        }
      ],
      "out_of_scope_findings": [
        "filed as #6575: `data-table` accepts a `bind` and silently renders its header over an empty body — it never calls useDataScope; documented at protocol.md:162 and already pinned in skill-guide-data-table-binding.test.tsx, but carrying no card. Searched all 242 open issues, no duplicate. Explicitly NOT caused or cured by PR #6574: the key was accepted on every node before the declaration existed.",
        "filed as #6576: ObjectGalleryProps.schema and ObjectDataTableProps.schema are hand-rolled inline object types with no BaseSchema in their ancestry, so none of its 21 members exists on them and each key must be hand-copied — which is exactly why both carry their own bind, and why those two could not be removed when the base declared it. They also disagree on strictness (one has an index signature, one does not). Adjacent to #5155/#6269 but distinct: those types never reach BaseSchema at all.",
        "checked and NOT filed: `events` as a universally-taught-but-undeclared node key is already covered by #6497 (EventableSchema / UIEventMap do declare it)."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions