Skip to content

skills(objectstack-formula): "every predicate reads a record total over declared fields" contradicts the canonical sparse action-binding rule (declared-fields.ts, #4953), so agents drop the has() guard a list-row action needs #29094

Closed

Description

@objectstack-fleet

domain:devx#2 · marchtian · session_017ihL2gLghEqeDKTbDTC7Zd

Filing gate: ① a named contradiction between a shipped skill and the code's canonical rule. Found while landing #28960, whose PR #29088 first removed a page's has() guard to match the skill. The seat put the guard back until this is settled.
Reader: domain:skills (skills/objectstack-formula/SKILL.md). Which side is canonical is a design call, so this goes to the maintainer before any edit.
Dedupe (open AND closed): "has() sparse action visible" → #28542 (open, cloud invitations page: Copy Invite Link hides on every row because the list does not project expires_at, which is live evidence of the sparse face), #28960 (open, the docs page). "has() is NOT a null check" → #28960 only. None names the contradiction.
reach: every coding agent that installs the bundled skills and writes an action visible / disabled predicate for a list_item location.
type: Bug

Business reason: the skill is the instruction an agent follows. It tells the agent has() is meaningless on any record binding. The code's own canonical rule says the action binding on a list row is sparse by decision and must be guarded with has(). An agent that follows the skill writes a list-row predicate that faults on an unprojected column, and the button hides silently, as #28542 shows.

The two statements (read on main 2026-11-02)

  • skills/objectstack-formula/SKILL.md § "has() is NOT a null check" (about lines 92–104): "Every predicate reads a record … that is total over the object's declared fields … So has(record.<declared_field>) … [is] uniformly true and tell[s] you nothing at all."
  • packages/objectql/src/declared-fields.ts (about lines 28–45): "One binding is still sparse, and by decision rather than by gap: objectui's action visible / disabled binds whatever record the client already fetched — a record-detail read, or a LIST ROW carrying only the view's $select projection. That one is a DECISION (#4649 的「记录对已声明字段全量」只落在两个接缝上 —— 另外三处求值仍是稀疏绑定 #4953 item 2) … has(record.x) && record.x != null … This doc comment is the CANONICAL statement of that rule."

Pages and examples follow one side or the other: content/docs/getting-started/quick-start.mdx (about lines 113–117), content/docs/protocol/objectui/concept.mdx (about line 615), examples/app-crm/src/actions/convert-lead.action.ts with examples/app-crm/test/action-predicate-sparse-face.test.ts, and examples/app-showcase (src/ui/actions/index.ts, src/ui/pages/task-detail.page.ts) follow the objectql rule. #29088's page now keeps the guard too.

Counter-evidence to measure: the #28960 dev read that list, grid and related-list views fetch every column an action predicate reads (ui/packages/core/src/utils/predicate-fields.ts, about lines 278 and 298). Kanban, calendar and gallery were not checked. If every face now fetches predicate columns, the objectql comment is the stale side.

Wanted

  • A ruling on which statement is canonical, after measuring every view face that binds an action predicate.
  • Then one PR on the losing side: either the skill gains the action-binding exception (a skills edit, so it owes the full review), or declared-fields.ts, the CRM test and the pages drop the sparse rule.

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    domain:skillsDomain lane — seat card indexed by label:pm:seat
    on Nov 2, 2026
  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:devx#2 · marchtian · session_017ihL2gLghEqeDKTbDTC7Zd

    Ruling recorded · option A · 2026-11-02T14:13Z

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Nov 2, 2026
  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:devx#2 · marchtian · session_017ihL2gLghEqeDKTbDTC7Zd

    Claim: PM loop round 3
    Session: session_017ihL2gLghEqeDKTbDTC7Zd
    Account: marchtian
    Branch: claude/issue-29094-formula-skill-action-sparse
    Worktree: objectstack-issue-29094
    Domain: domain:skills (the surface), taken by domain:devx#2 on the coordinator's routing while the skills lane is clocked out
    Seat: domain:devx#2
    File surface: skills/objectstack-formula/SKILL.md (the "has() is NOT a null check" section and its Surfaces table), plus the skill's evals if the repo requires one for a changed rule, and one comment line in content/docs/getting-started/build-with-claude-code.mdx
    Container & model: S/M fix, mode:subagent, model: default tier, effort: high; Tier H (skill text) → full Fable review before merge
    Clause-②: no
    Metadata-protocol: none
    Responsibility: the formula skill tells agents that has() is meaningless on every record, which contradicts the sparse action binding ruled in #4953 item 2
    Thread-read: 6579342813
    Premise: holds
    Serial constraints cleared: none (checked before dispatch)

    Ruled: option A (6579342813, the maintainer's 「同意」). The ruling comment carries the four-point edit and the review rule.

  5. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:devx#2 · marchtian · session_017ihL2gLghEqeDKTbDTC7Zd
    os-dev-report

    {
      "issue": 29094,
      "status": "done",
      "branch": "claude/issue-29094-formula-skill-action-sparse",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/29439",
      "head_sha": "a39d022314cb623f1853e00b92e7f08b1885888a",
      "session": "session_017ihL2gLghEqeDKTbDTC7Zd",
      "premise_still_valid": true,
      "summary": "Ruling 6579342813 (option A) applied to skills/objectstack-formula/SKILL.md: the total-record sentence is scoped to the server-evaluated surfaces with != null as the whole guard there; a new paragraph names action visible / disabled as client-side and sparse and teaches the canonical guard from packages/objectql/src/declared-fields.ts; the Surfaces table row reads Action | visible / disabled, client-side, sparse. record.?x.orValue is not taught. The Build with Claude Code page's Resolve comment now points at declared-fields.ts; the guard is unchanged. Patch changeset for @objectstack/skills. Draft PR, no reviewers, no assignees, no labels.",
      "files_changed": [
        "skills/objectstack-formula/SKILL.md (+22 / -6)",
        "content/docs/getting-started/build-with-claude-code.mdx (+2 / -2, the comment above the Resolve action's visible only)",
        ".changeset/29094-formula-skill-action-sparse.md (new, @objectstack/skills patch)"
      ],
      "line_budget": {
        "skills/objectstack-formula/SKILL.md lines": "473 -> 489 (+16)",
        "all skills/*/SKILL.md lines": "4529 -> 4545 (+16)",
        "skills/objectstack-formula/SKILL.md tokens": "5686 -> 5920 (+234), ceiling 5997 unchanged, headroom 77",
        "bundle total tokens": "224069 -> 224303 (+234)",
        "ceiling_moved": false,
        "rewrap_used_as_payment": false,
        "readings_tree": "objectstack-ai/objectstack base 7f277a86f6 vs head a39d022314"
      },
      "tests": "No code changed; no unit test applies. Evidence is the gate run below.",
      "gates": {
        "derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack :: exit 0, 69 commands, tree a39d022314",
        "local": "69 run on head a39d022314, each exit captured before any pipe: 68 exit 0, 1 exit 3",
        "named": [
          "node scripts/check-skills-token-ratchet.mjs :: exit 0 (formula SKILL.md 5920 / 5997, headroom 77)",
          "node scripts/check-skills-token-ratchet.mjs --self-test :: exit 0",
          "pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 (281 prose examples type-check)",
          "pnpm --filter @objectstack/spec run check:skill-docs :: exit 0",
          "pnpm check:pm-skill-id-lint :: exit 0",
          "pnpm check:skill-identifier-liveness :: exit 0",
          "pnpm check:skill-frame-sync :: exit 0",
          "pnpm check:skill-compatibility :: exit 0",
          "pnpm check:doc-authoring :: exit 0",
          "pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0",
          "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
          "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
          "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0"
        ],
        "not_measured": [
          "pnpm check:dual-build-cjs-loads :: exit 3, PREREQUISITE NOT MET (reads every package dist; only the spec, lint, objectql and client-react closures were built; the diff touches no package source)"
        ],
        "ran_reconciliation": "node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack :: exit 0, 69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN",
        "ci": "PR check-runs on a39d022314 at report time: 22 total, 18 in_progress, 1 queued, 3 skipped; not waited on"
      },
      "dogfood_battery": "n/a, no ! in the PR title and no (narrowing) arm",
      "claims": [
        {
          "claim": "The record is total over declared fields on validation rules, requiredWhen, option visibleWhen, readonlyWhen, hook condition and the flow trigger record",
          "backed_by": "packages/objectql/src/declared-fields.ts:4-13"
        },
        {
          "claim": "Action visible / disabled bind the row the client already holds and are sparse by decision",
          "backed_by": "packages/objectql/src/declared-fields.ts:28-34"
        },
        {
          "claim": "A list_item row lacks a key outside the view's projection",
          "backed_by": "packages/objectql/src/declared-fields.ts:30-31; harvest is best effort: ui/packages/core/src/utils/predicate-fields.ts"
        },
        {
          "claim": "A list_item row lacks a key when field-level security refuses the column",
          "backed_by": "ui/packages/core/src/utils/predicate-fields.ts:265; ui/packages/plugin-list/src/ListView.tsx:1648"
        },
        {
          "claim": "A list_item row lacks a key that was never written (mongo and memory drivers)",
          "backed_by": "packages/drivers/driver-mongodb/src/mongodb-driver.ts:83; packages/drivers/driver-memory/src/memory-driver.ts:1922"
        },
        {
          "claim": "Reading an absent key faults No such key and the action silently vanishes",
          "backed_by": "packages/objectql/src/declared-fields.ts:61-64 and 142-143"
        },
        {
          "claim": "Guard is has(record.x) && record.x != null",
          "backed_by": "packages/objectql/src/declared-fields.ts:38"
        },
        {
          "claim": "has() alone is enough beside a literal == / !=",
          "backed_by": "packages/objectql/src/declared-fields.ts:128-132"
        },
        {
          "claim": "A nested path takes one has() per segment",
          "backed_by": "packages/objectql/src/declared-fields.ts:77 and 104-105"
        },
        {
          "claim": "Action visible is a CEL-or-boolean surface beside disabled",
          "backed_by": "packages/spec/src/ui/action.zod.ts:1675 and 1700"
        },
        {
          "claim": "The skills bundle publishes, so a skills text change owes a patch changeset",
          "backed_by": "packages/skills/package.json (publishConfig public, files dist, build scripts/sync-catalog.mjs)"
        }
      ],
      "deviations": [
        "origin/main was merged once (d93acbbe55) before pr_create; it moved 5 more commits after that, none touching this card's gate inputs per dispatch-gates; not merged again (AGENTS.md multi-agent section 10).",
        "Merge commit message amended before push to carry the model-free trailer pair.",
        "Commit trailers follow AGENTS.md and the order (Co-Authored-By: Claude, model-free), not the harness reminder's model-named form.",
        "No eval added: the repo requires one only for a protocol addition, and this is Metadata-protocol: none."
      ],
      "mcp_calls": "0",
      "api_writes": "2: one fleet-write relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches) whose executor ran pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft, no labels, no assignees) -> #29439, read back identical (7117 bytes); this os-dev-report comment via post-stamped (POST /repos/objectstack-ai/objectstack/issues/29094/comments). git push is not a REST write.",
      "open_questions": [
        {
          "question": "Should the formula skill carry an eval that pins the action-surface guard?",
          "options": [
            "A: no eval now; the rule is pinned by the CRM test and the canonical comment",
            "B: add eval 9 (a list_item action visible predicate must contain has(record.)), not token-ratcheted"
          ],
          "recommendation": "A, the order scoped evals to what the repo requires, and it requires none here; B is a cheap follow-up if the Fable review asks for it"
        }
      ],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: none · noted, not filed: eval 5 of skills/objectstack-formula/evals/cel-predicates-formulas.json teaches has(record.discount) as always true for a field visibleWhen, which is neither on the ruling's server-evaluated list nor the action surface; inference only, not measured",
        "carrier: seat · the decision analysis section 4 D follow-ups (engine read path pads unreturned declared columns with null; predicate harvest recognises record.?x) remain the seat's to dispatch"
      ]
    }
  6. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:devx#2 · marchtian · session_017ihL2gLghEqeDKTbDTC7Zd

    Landed · domain:devx#2 · 2026-11-03T05:19Z

    PR #29439 was merged by os-zhuang as e82bf64253 (Tier H, skills/**), and the card closed on its Fixes. That human merge is the approval record.

    The seat releases the card: pm:dispatched comes off, and the worktree and branch are removed.

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

Metadata

Metadata

Assignees

Labels

domain:skillsDomain lane — seat card indexed by label:pm:seat

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions