Repository navigation
Let an edge field declare a closed enum value set (closes #2387) - #2427
Conversation
`EdgeField` could be typed `"enum"` by string, but nothing declared what the permitted values were — so an access-control role on a relationship was free text end to end: unconstrained on write, a free-text box in the UI, and a `default` nothing checked. `"Owner"`, `"owner"` and `"onwer"` were three distinct roles, and a typo produced a permission that silently grants nothing. Takes option 2 from the issue: `core_values` only, not the full `SchemaField` mirror. An edge enum is a fixed vocabulary — the motivating case is an RBAC role, where a user-extensible value set would mean a permission level nothing downstream knows how to check. `user_values`/`extensible` can be added later if a real use case appears; adding them now would be surface with no caller. - `EdgeField` gains `core_values: Option<Vec<EnumValue>>`. One shared definition, re-exported into core and the Tauri types, so it propagates without a conversion boundary to drop it. - Declaration-time validation (`validate_edge_field_declarations`, wired into both the create and update schema paths): `coreValues` is required on an enum edge field and rejected on any other type, values must be unique, and a declared `default` must be a member of the set. - Write-time validation (`validate_edge_data_against_fields`) on `NodeService::create_relationship` and `update_relationship_properties` — the one chokepoint every caller routes through, so the CLI's `--edge-data`, the daemon, the Tauri commands and the agent tools are all covered rather than each boundary validating separately. Scoped to enum membership; an omitted or null value, a non-enum field, and an undeclared edge key are all left alone, so no existing caller changes acceptance. - The relationships modal renders a picker for such a field instead of a free-text input (both the per-row edit and the add-row form), and resolves a stored value to its declared label when read-only. `getEnumValues` / `enumValueLabel` are widened from `SchemaField` to an `EnumFieldLike` shape so edges reuse the node-side lookup rather than growing a second copy. - `skill_rules.rs` gains an `ENUM_EDGE_FIELDS` rule covering the RBAC shape, so an agent declaring one produces a valid value set. Also closes a drift gap found while adding that rule: `render_schema_rules_block` names each rule individually in a format string, so a rule registered in `SCHEMA_RULES` but omitted there is silently dropped from the shipped skill — and `checked_in_skill_md_is_up_to_date` still passes, because the generator and the checked-in copy agree on the same incomplete output. `every_schema_rule_reaches_the_skill` now asserts every registered rule actually lands in the skill; verified to fail on the omission it is written for. Round-trip coverage keeps the property the existing tests hold: an enum edge field serializes to `coreValues` and deserializes back identically, and a non-enum field gains no empty key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xcKSmajXi7D4fCMsvaGMi
Code Review SummaryVerdict: APPROVE with one Improvement worth resolving before merge (or filing as an explicit follow-up). This is a well-built, genuinely net-positive change. The design decision (option 2 — I verified the chokepoint claim, the builtin exclusion, the FindingsImprovements1. You asked for a straight answer on this, so: yes, Two independent gates reject it:
So the finding is not "the check is mis-scoped." It is that the issue's stated motivation is not actually delivered by this PR, and nothing in the PR or the issue says so. The issue's own example is literally This does not make the change wrong or unmergeable. What lands is real and correct: any custom relationship ( Concretely, the RBAC work this was billed as a prerequisite for still has an unresolved design question ahead of it: either (a) collection RBAC uses a new custom relationship rather than builtin Recommendation: Not a merge blocker. But before merge, either amend the PR body to state plainly that 2. The You flagged this as a possible skip-path. I checked: it is unreachable in a way that matters. If 3. A declared
So on the issue's own example — This is pre-existing behavior ( 4. The Both halves mislead an agent reading this as instruction:
Because this text is the shipped agent-facing contract, an agent will confidently emit exactly the issue's 5. You asked about That said, the layering is worth noting: Bypass audit (requested) — cleanI traced every production write path and found no bypass:
The PR's claim that Frontend6. The new picker mirrors Two small observations:
7. Widening Structural typing over a shared minimal interface, rather than a second copy of the lookup, is exactly right — and the docblock states the why (drift between two surfaces rendering the same value) rather than the what. The Test qualityTests genuinely assert what they claim. Verified by running them (14 pass across both modules). Particularly good:
Gaps, all minor:
Standards compliance — cleanChecked the full diff against CLAUDE.md: no lint suppression, no Nitpicks
What I'd do before mergeOnly finding 1 and finding 4 warrant action, and both are documentation-shaped rather than code-shaped:
Everything else is optional polish. The code itself is sound, well-tested, and clearly a net improvement to code health. |
malibio
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (posting as a comment — GitHub blocks self-approval on your own PR).
Full review posted as a PR comment above. Summary:
The change is sound, well-tested and a clear net improvement to code health. Validation is correctly placed at a single chokepoint, and I verified by tracing every production write path (ops/rel_ops, playbook actions, collection_service, direct store calls) that no bypass exists — the "one chokepoint" claim holds. All 14 new Rust tests pass locally. Standards compliance is clean: no lint suppression, no any, no raw console.*, no backward-compat/migration code, no docs added to this repo, no issue numbers in code comments.
Answers to the specific questions raised:
- Is NodeService genuinely the only chokepoint? Yes — verified, no bypass.
- Is the nested
if let Some(source)guard a skip-path? No. A missing source node means no edge can exist, so the store call returnsNoneand the write is rejected anyway. Only the error message differs. - Are builtins excluded, and does that break the motivating use case? This is the one finding that matters.
member_ofis excluded — but not because the check is mis-scoped.reject_reserved_relationship_namesandset_schema_relationshipsboth refuse any declaration named after a builtin, somember_ofhas no schema declaration and therefore noedge_fieldsto validate. The!is_builtinplacement is correct given that. The real issue: the issue's motivating RBAC-on-member_ofcase is not delivered by this PR, and nothing says so. The issue's ownmember_ofexample JSON is rejected before it reaches the new code. What does land is real and valuable (custom relationships get closed validated vocabularies end to end), but the PR body and skill prose imply more coverage than exists. - Playbook
set_schema_relationshipscallers? Both are inside#[cfg(test)]test fixtures, not production. No gap. - Svelte picker / empty-string
Select.Root? Behaves sanely and mirrors the establishedschema-field-leaf.sveltepattern at both render sites. - Do the tests assert what they claim? Yes, and better than typical — the rejection tests assert nothing was persisted, and the update test asserts the original value survives a rejected edit.
Two documentation-shaped items worth resolving before merge (neither is a code blocker):
- State plainly that builtin
member_ofedge fields remain undeclarable, so the RBAC-on-collections work is not yet unblocked — or file the follow-up. - Tighten the
ENUM_EDGE_FIELDSskill prose: it promises validation on "every write path" (true only for custom relationships) and modelsrequired: true, default: \"viewer\"as a working shape, but edge-fielddefaultis validated at declaration time and never applied on write, andrequiredis not enforced. Since this text is the shipped agent-facing contract, the imprecision has a direct downstream cost.
Not merging — that's your call.
Code review on #2427 confirmed that the RBAC case motivating #2387 is not delivered by it: `member_of` and `has_role` are builtin relationship names, rejected as declarations by `reject_reserved_relationship_names`, so they can never carry declared `edgeFields` for the new validation to check. Skipping builtins on the write path is correct given that — with no declaration there is no `edge_fields` to validate against — but the guidance shipped to agents implied otherwise. - `ENUM_EDGE_FIELDS` no longer claims validation on "every write path" and no longer models `required: true, default: "viewer"` as if either were enforced when an edge is written. It now states both limits outright: only self-declared relationships can carry edge fields (builtin names are reserved), and `required`/`default` are recorded but not applied, so an omitted enum key is stored absent rather than filled in. The example is an access level on a custom relationship rather than a role on `member_of`, which an agent cannot actually declare. - `validate_edge_field_declarations` documents that validating a default is not applying one, so a reader does not infer write-time defaulting from the presence of the check. - Six end-to-end tests through `handle_create_schema`/`handle_update_schema` cover the two call-site wirings, which had no coverage — a refactor could have dropped either and left every other test passing. One of them pins the builtin rejection, so the guidance and the behavior cannot drift apart. The open design question — whether collection RBAC gets a custom relationship or builtins gain an edge-field table — is filed rather than resolved here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xcKSmajXi7D4fCMsvaGMi
Review addressed — 26ffb05Thanks — the Finding 1 — Addressed as documentation plus a pin, not code:
Finding 4 — prose overstates coverage. Agreed on both halves. Finding 3 — validated-but-never-applied default. Kept out of scope as you suggested, but no longer silent: Test gap — uncovered call-site wirings. This was the most actionable item and is fixed: six end-to-end tests through Finding 2 (nesting), 5 (playbook callers), 6/7 (frontend): agreed with your verification, no change. Not done, deliberately: the second-line-of-defense re-check in Also left alone: the O(n²) duplicate scan (bounded by a handful of values, and clearer than a Verification: |
Closes #2387.
EdgeFieldcould be typed"enum"by string, but nothing declared what the permitted values were — so an access-control role on a relationship was free text end to end: unconstrained on write, a free-text box in the UI, and adefaultnothing checked."Owner","owner"and"onwer"were three distinct roles, and a typo produced a permission that silently grants nothing.Decision: option 2 (
core_valuesonly)Not the full
SchemaFieldmirror. An edge enum is a fixed vocabulary — the motivating case is an RBAC role, where a user-extensible value set would mean a permission level nothing downstream knows how to check.user_values/extensiblecan be added later if a real use case appears; adding them now would be surface with no caller. The rationale is recorded on the field's doc comment, not just here.What changed
EdgeFieldgainscore_values: Option<Vec<EnumValue>>. One shared definition re-exported into core and the Tauri types, so it propagates with no conversion boundary to drop it.validate_edge_field_declarations, wired into both the create and update schema paths, ahead of the write:coreValuesis required on an enum edge field and rejected on any other type, values must be unique, and a declareddefaultmust be a member of the set.validate_edge_data_against_fieldsonNodeService::create_relationshipandupdate_relationship_properties. That is the one chokepoint every caller routes through, so the CLI's--edge-data, the daemon, the Tauri commands and the agent tools are covered by one check rather than each boundary validating separately.getEnumValues/enumValueLabelare widened fromSchemaFieldto anEnumFieldLikeshape so edges reuse the node-side lookup rather than growing a second copy that could drift on how a value renders.ENUM_EDGE_FIELDSrule inskill_rules.rscovering the RBAC shape, so an agent declaring one produces a valid value set.Write validation is deliberately scoped to enum membership. An omitted or null value, a non-enum field, and an undeclared edge key are all left alone, so no existing caller changes acceptance — broader edge-value typing (numbers, dates, required-ness) is not enforced anywhere today and is not introduced here.
Drift gap closed along the way
render_schema_rules_blocknames each rule individually in a format string, so a rule registered inSCHEMA_RULESbut omitted there is silently dropped from the shipped skill — andchecked_in_skill_md_is_up_to_datestill passes, because the generator and the checked-in copy agree on the same incomplete output. My rule hit exactly this.every_schema_rule_reaches_the_skillnow asserts every registered rule actually lands in the skill; I verified it fails on the omission it is written for before restoring the template.Testing
bun run test:all— exit 0. Frontend 4867 passed vs. a 4863 baseline (+4, the new tests); 57 Rust test binaries green, zero failures.bun run quality:fixclean (eslint, svelte-check, tsc,cargo fmt, clippy with-D warnings).21 new backend tests and 4 new frontend tests, covering: the accepted RBAC shape; a value outside the set rejected and not persisted; wrong casing rejected; a non-string value rejected; omitted/null accepted; non-enum and undeclared keys passed through; the edit path validated with the prior value surviving a rejected edit; the declaration guards (missing/empty
coreValues,coreValueson a non-enum, out-of-set default, non-string default, duplicate values); and six end-to-end tests throughhandle_create_schema/handle_update_schemacovering the two call-site wirings, which review found had no coverage — a refactor could have dropped either and left every other test passing. Round-trip coverage keeps the property the existing tests hold — an enum edge field serializes tocoreValuesand deserializes back identically, and a non-enum field gains no empty key.Scope: what this does NOT unblock
Worth stating plainly, because #2387's framing implies otherwise. The RBAC edge the issue motivates this with is a builtin relationship, and builtins cannot carry declared
edgeFieldsat all —reject_reserved_relationship_namesrefuses any declaration namedmember_of/has_child/mentions/has_role, andset_schema_relationshipsre-checks it on the write path. So the issue's own example JSON ({"name": "member_of", ..., "edge_fields": [...]}) is rejected before it reaches any of this.That is not a mis-scoping of the new check: with no declaration there is no
edge_fieldsto validate against, so skipping builtins on the write path is the only correct behavior. But it means roles onhas_role— today literallycreate_relationship(person, "has_role", settings, {"role": "owner", "status": "active"})per ADR-037 — remain free text.What lands is still real: any relationship you declare now gets a closed, validated vocabulary end to end, and #2387's acceptance criteria as written are met. The remaining design question (custom relationship for collection RBAC, vs. giving builtins an edge-field table) is filed as #2429 rather than guessed at here.
Two consequences for this PR, both from review:
ENUM_EDGE_FIELDSguidance states both limits outright rather than promising "every write path" — an agent following it will not emit the rejectedmember_ofshape.schema_declaring_an_edge_field_on_a_builtin_relationship_is_rejectedpins that behavior, so the guidance and the code cannot drift apart.Also noted
addEdgeDraftstarts empty, so a declareddefaultis not pre-selected in the add-row picker, and nothing applies an edge field'sdefaultat write time either — an omitted enum key is stored absent. Both are pre-existing and true for every edge field type, not specific to enums; the declaration validator now says so explicitly, and the agent guidance warns against relying on a default. Left as-is rather than widening this change.🤖 Generated with Claude Code
https://claude.ai/code/session_015xcKSmajXi7D4fCMsvaGMi