Skip to content

Refuse a schema field named after a declared field behind a prefix (custom:status beside status) #3726

Closed

Description

@malibio

Overview

A schema edit can declare an extension field whose bare name is already a declared field of the type: custom:status on task, beside status. A rule or a query reads an extension field by its bare name, and the declared field always wins that name, so the new field can be stored and written but never read. Refuse the name when the schema is edited, as a schema is already refused a core type's name.

Problem Statement

  • create_schema, update_schema (add_fields, rename_fields) and the extends re-target all accept a field named custom: / org: / plugin: + the name of a field declared in the type's chain.
  • The field is unreadable: node.status in a Play condition and a status query filter resolve to the declared field. Nothing tells the user.
  • The node write path refuses an undeclared custom:status on such a node, but has to let a schema-declared one through so that a declared field with a default does not fail every create. The two validators disagree about which names are legal.

Proposed Solution

  • One check, run at every schema-edit entry point over the fields the edit would leave on the schema: a field whose name is an extension prefix + X is refused when X is declared as an unprefixed field anywhere in the type's chain (own fields, the fields in the same call, and every ancestor's).
  • The error names the declared field and the schema that declares it, in the shape of the write-side refusal ("'custom:status' cannot be declared: 'status' is a declared field of the type 'task' ...").
  • Remove the write path's exemption for a schema-declared key, and the test that pins it: once no schema can declare such a field, the exemption has nothing to protect.
  • No migration: a database whose schema already holds such a field is reset.

Documentation

  • ADR-097 §6 - the rule: a write and a schema edit both refuse an extension field named after a declared field, checked against the whole chain
  • ADR-063 - where a prefix is required (extending a core type) and where it is not (a user-defined type)
  • ADR-078 - composition is additive only; the existing redeclaration check this one sits beside
  • ADR-086 - closed core schemas and the registry
  • Updates: the extension-field sentence in the task sheet's Typed update bullet in components/node-types.md (it describes the write-side refusal only)

Architecture Impact

Extends ADR-097 §6, which is already amended and pushed to say a schema edit is refused the same way as a write. No decision is reversed. No node type, field, relationship or structural rule is added or changed, so the node type sequence does not apply.

Acceptance Criteria

  • update_schema add_fields with custom:status on task is refused, and the error names status and task
  • The same for org: and plugin: prefixes, and for a name declared by an ancestor (a user subtype of task adding custom:status)
  • rename_fields to such a name is refused
  • create_schema is refused when its own fields hold both X and custom:X, and when it extends a type that declares X
  • Giving a schema a new parent is refused when one of its prefixed fields is named after a field the new chain declares
  • A prefixed name that matches no declared field is still accepted (custom:effort on task)
  • org:custom:x beside a declared custom:x is not refused: it reads as custom:x, which the declared field (read as x) does not shadow
  • The schema is unchanged after each refusal
  • The write path no longer exempts a schema-declared key, and its test is removed
  • The friendly-name disambiguation tests no longer rely on custom:status beside status
  • Each new test fails with the check removed
  • Code passes bun run quality:fix
  • bun run test:changed passes

Technical Specifications

Reference Files

  • packages/core/src/schema/mod.rs - handle_create_schema, handle_update_schema, and validate_no_field_redeclaration, which already resolves the chain's field owners (resolve_field_owners) and is called from all three paths: creation, add_fields, and the re-target. The core-type-name refusal ("is a core type; a schema cannot take its name") is the precedent for the message
  • packages/core/src/services/node_service/crud.rs - reject_shadowed_extension_fields: the write-side refusal, and the owners.contains_key(key) exemption to remove
  • packages/core/src/services/node_service/mod.rs - extension_field_bare_name: the one definition of the prefixes and of the name a field is read by. Reuse it
  • packages/core/src/services/node_service/schema.rs - rename_schema_field, which carries its own inherited-field check
  • packages/core/src/playbook/cel.rs (node_to_cel_value_at_scope) and graph_resolver.rs - why the field is unreadable: unprefixed keys are taken across the whole chain before any prefixed one
  • packages/core/src/schema/schema_test.rs - test_update_schema_add_fields_disambiguates_friendly_name_colliding_with_existing_field, test_update_schema_add_fields_does_not_rewrite_an_explicit_colliding_friendly_name and test_rename_field_friendly_name_empty_string_disambiguates_against_a_sibling_field build their label collision from custom:status / custom:due_date beside the bare name. Give them another pair of fields that derive the same label
  • packages/core/tests/it/core_type_registry_test.rs - an_extension_field_named_after_a_declared_field_is_rejected, a_subtype_refuses_an_extension_field_named_after_any_field_in_its_chain (the write-side tests), and an_extension_field_the_schema_declares_is_still_written (to remove)

Implementation Details

  • validate_no_field_redeclaration only runs when the schema has a parent. A schema with none (task itself, or a user-defined type) needs the check against its own fields, so the check cannot live only there.
  • Compare with unprefixed declared names only: a bare name that is itself prefixed shadows nothing.
  • The error text reaches the agent as a tool result. Say what to do next: use the declared field, or pick another name.

Related Issues

Non-Goals

  • Two extension fields that read as the same name under different prefixes (org:estimate beside custom:estimate)
  • An extension field named after a relationship (custom:spec on a task)
  • A prefixed key stored beside a node's own bucket

Activity

  1. malibio commented on Oct 11, 2026

    @malibio
    CollaboratorAuthor

    Frontend baseline: 324 test files passed, 5873 tests passed (shared worktree with #3728)

  2. self-assigned this
    on Oct 11, 2026
  3. added a commit that references this issue on Oct 12, 2026
    ccb6f8b
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