Skip to content

Make a cleared field read as absent in play conditions (CEL has() on a null property) #3474

Closed

Description

@malibio

Overview

A field cleared through the update pipeline is stored as JSON null in its bucket, not removed. Every reader checked so far treats that null as absent, except one that has not been checked: a play condition using CEL has(node.<field>). Confirm what it does, and make a cleared field read as absent there too.

Problem Statement

  • Stored shape. Clearing a field (priority: null in a typed task update, --property priority=null from the CLI, an agent update) leaves { "task": { "priority": null } }. The typed task update used to remove the key; it now goes through the shared pipeline like every other write, so this applies to task as well as person and project.
  • How the CEL context is built. node_to_cel_value_at_scope (packages/core/src/playbook/cel.rs) copies every key of each bucket in the scope chain into the node map with json_to_cel(value), and json_to_cel maps JSON null to Value::Null. A cleared field is therefore a present key holding Null, not a missing key.
  • Expected consequence, from reading the code, not yet run: has(node.priority) is true for a task whose priority was cleared, and !has(node.priority) is false. A rule written as "fire when the task has no priority" would then fire for a task that never had one and not for a task whose priority was cleared.
  • Evidence the seeded rules already work around it. The Jira methodology's conditions guard both ways: !has(node.start_date) || node.start_date == null and has(node.completed_date) && node.completed_date != null (packages/core/src/methodology/jira.rs). A user-authored rule has no reason to know it must.

Proposed Solution

  1. Add a test that pins the current behaviour: build the CEL context for a task with {"task": {"status": "open", "priority": null}} and evaluate has(node.priority) and !has(node.priority).
  2. If has() is true for the null value, leave null-valued properties out of the node map when the context is built, so a cleared field is a missing key. Apply the same rule in the parallel reader the code comment names, graph_resolver::get_node_property / get_node_property_at_scope, so a dot-path through a related node agrees.
  3. Check what that does to node.priority == null for a missing key: the module's missing-path rule evaluates a missing path to false, so a condition that compares with null and no has() guard may change meaning. Decide which of the two spellings a rule author is expected to use, and make the seeded Jira and spec-driven rules use only that one.
  4. If has() is already false for a null value, keep the test and close with no code change.

Architecture Impact

No ADR decides how a null property reads in CEL. components/node-types.md §1.2 (nodespace-docs) states the rule this issue extends: "A cleared field is stored as null … Readers treat null as absent", and deliberately says nothing about CEL because it was unverified. architecture/playbook-engine.md documents missing-path behaviour but not null. This issue extends that rule to play conditions; it needs a companion nodespace-docs edit: one sentence in node-types.md §1.2 and the condition semantics in playbook-engine.md.

Acceptance Criteria

  • A test evaluates has(node.<field>) and !has(node.<field>) for a field stored as null, for a field that is absent, and for a field with a value
  • A cleared field and a never-set field give the same result for has() in a play condition
  • The same holds for a field reached through a relationship dot-path (node.story.priority)
  • The behaviour of node.<field> == null for a cleared and for a never-set field is tested and documented
  • The seeded Jira and spec-driven rules use one spelling of "field has no value", and their existing tests pass
  • node-types.md §1.2 and playbook-engine.md state the rule
  • bun run test:changed passes; bun run quality:fix clean

Technical Specifications

Reference Files

  • packages/core/src/playbook/cel.rs: json_to_cel, node_to_cel_value_at_scope (the bucket loop and the flat-property loop both insert json_to_cel(value)), and the missing-path handling in condition evaluation
  • packages/core/src/playbook/graph_resolver.rs: get_node_property / get_node_property_at_scope, the parallel property reader
  • packages/core/src/methodology/jira.rs, packages/core/src/methodology/spec_driven.rs: seeded rules using has()
  • packages/core/tests/it/playbook_invariant_integration_test.rs: play tests that drive a real task node through update_task_node
  • packages/core/src/services/node_service/crud.rs: check_field_value ("a null value … clears a field"), the write side of the convention

Implementation Details

  • The CEL library is cel-interpreter 0.10. Whether its has() macro tests key presence or non-null is the first thing to establish; the rest follows from it.
  • Do not change the stored shape. null in the bucket is the pipeline's convention for a cleared field, and no migration is wanted.

Related Issues

Non-Goals

  • Removing cleared keys from storage
  • Changing how null reads anywhere outside play conditions

Activity

  1. malibio commented on Oct 1, 2026

    @malibio
    CollaboratorAuthor

    Baseline — Frontend: 301 test files, 5560 tests passed

  2. self-assigned this
    on Oct 1, 2026
  3. added 2 commits that reference this issue on Oct 1, 2026
    dd690ce
    33fe2d6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions