Repository navigation
Description
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: nullin a typed task update,--property priority=nullfrom 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 totaskas well aspersonandproject. - 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 thenodemap withjson_to_cel(value), andjson_to_celmaps JSONnulltoValue::Null. A cleared field is therefore a present key holdingNull, 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 == nullandhas(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
- Add a test that pins the current behaviour: build the CEL context for a task with
{"task": {"status": "open", "priority": null}}and evaluatehas(node.priority)and!has(node.priority). - If
has()is true for thenullvalue, leavenull-valued properties out of thenodemap 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. - Check what that does to
node.priority == nullfor a missing key: the module's missing-path rule evaluates a missing path tofalse, so a condition that compares withnulland nohas()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. - If
has()is already false for anullvalue, 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 asnull, 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> == nullfor 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 andplaybook-engine.mdstate the rule -
bun run test:changedpasses;bun run quality:fixclean
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 insertjson_to_cel(value)), and the missing-path handling in condition evaluationpackages/core/src/playbook/graph_resolver.rs:get_node_property/get_node_property_at_scope, the parallel property readerpackages/core/src/methodology/jira.rs,packages/core/src/methodology/spec_driven.rs: seeded rules usinghas()packages/core/tests/it/playbook_invariant_integration_test.rs: play tests that drive a realtasknode throughupdate_task_nodepackages/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-interpreter0.10. Whether itshas()macro tests key presence or non-null is the first thing to establish; the rest follows from it. - Do not change the stored shape.
nullin the bucket is the pipeline's convention for a cleared field, and no migration is wanted.
Related Issues
- Define the task types once and route the typed task update through the shared pipeline #3441 (moved the typed task update onto the shared pipeline, which is where task fields started being stored as
nullwhen cleared)
Non-Goals
- Removing cleared keys from storage
- Changing how
nullreads anywhere outside play conditions
Activity
malibio commentedon Oct 1, 2026
Baseline — Frontend: 301 test files, 5560 tests passed
Read a cleared field as absent in play conditions (closes #3474)
Read a cleared field as absent in play conditions (closes #3474)