Repository navigation
Type person and project nodes like task (closes #2807) - #3033
Conversation
The frontend saw two property shapes: Tauri IPC flattens a node's storage bucket via node_to_typed_value, but the browser dev-proxy passed properties through in storage shape. PersonSchemaForm read the nested bucket, so in the desktop app every field rendered empty and typed values vanished once the daemon's flat node landed. - dev-proxy flattens properties exactly like flatten_namespaced_properties (storageNodeToApiFields), so both transports deliver the same shape - PersonSchemaForm and IdentityCard read/write bare keys - Delete the dual-shape read/write paths: TaskSchemaForm user fields, schema-field-resolution, schema-field-update's flat-to-nested migration, and node-normalize's nested merge/promotion branches - The backend's normalize_flat_properties_to_namespace re-buckets bare keys Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The children tree serialized raw storage-shaped nodes, so tree-loaded nodes
(how node pages populate the store) still carried { person: {...} } and would
have rendered empty fields and lost edits with the nested-read paths deleted.
get_children_tree now collapses each node's extends chain and emits every
node through node_to_typed_value, the same shape as single-node reads.
- Rust unit test and HTTP e2e test pin flat tree properties (both fail before)
- Fix stale merge comment; clarify why TaskSchemaForm reads the raw node
Skipped: sending only the changed field. The persistence coordinator replaces
a queued write with the newest one, so a delta-only payload would drop an
intermediate edit; the full flat bag carries it. With every ingress flat, the
bag can no longer carry a type bucket that trips the backend's early return.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ldren tree - extends test: a subtype child in a tree keeps its inherited field (fails without collapse_chain_for_wire) - tree unit test: a task child carries its promoted top-level status Skipped: task user-defined fields empty for DB-loaded tasks (nodeToTaskNode drops properties) - pre-existing on main, tracked separately. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Person and project are core types but had no typed representation, so the frontend read their fields out of the properties bag - which broke as soon as the two transports disagreed on its shape. - nodespace-types: PersonNode/ProjectNode wire structs + PersonNodeUpdate/ ProjectNodeUpdate; node_to_typed_value promotes their core fields, and promotion is now a move for task/person/project (core fields leave properties). promoted_fields/flat_properties_view give machine consumers (CLI, agent) the storage-keyed view back. - core: update_person_node/update_project_node route the typed patch through the generic update pipeline (validation, title_template, OCC, invariants, events) behind a node-type check. - proto/daemon/Tauri/dev-proxy/adapters: UpdatePersonNode/UpdateProjectNode end to end, mirroring UpdateTaskNode incl. the conflict current_node embed. - frontend: PersonNode/ProjectNode + converters, a TYPED_CORE_FIELDS registry, and one generic updateTypedNode store path (per-field sequence guards kept; queued typed fields accumulate so a superseded write no longer loses them). updateNode routes typed keys there; the plugin updater API is gone. Forms, kanban and schema-field writes resolve a field's slot from the registry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- update-typed-node.test.ts: person/project dispatch, superseded typed writes carried by their replacement, updateNode routing (typed field -> typed update, task extension properties -> generic update), mixed-write split, onPersistError, database-sourced writes stay local. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rename pre-flight moved out of rename_schema_field into validate_schema_field_rename after the guard landed, so main's guard flagged the new function and treated the old entry as stale. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pragmatic Code Review: PR #3033 (Type person and project nodes like task, #2807)Review TypeInitial review. No earlier reviews or comments on the PR. Scope: 62 files, +2922/−1454, covering nodespace-types, core, proto, daemon, Tauri, dev-proxy, adapters, store, forms, Kanban, CLI, agent and tests. Summary: This is a solid, well-scoped change and clearly a net positive. It implements the documented serialization contract instead of working around it:
I checked the consumer sweep broadly. No frontend code still reads a task, person or project core field through The one substantive gap is in the store's accumulation guarantee: it holds only between typed writes, not when a generic write takes the coordinator's queued slot. Details below. Requirements Check
Code Review Findings🟡 1. A typed write superseded by a generic write is never sent📁
Sequence:
Consequences:
The mixed-write split in Fix (small):
Tests:
Principle: an invariant a design claims should hold under every interleaving the scheduler allows, not only the typed-only ones. 🟡 2. Four hand-synced copies of the core field set, with no drift guard📁 The same field set, with its storage-to-wire mapping and defaults, now lives in four places kept in sync by hand. Earlier architecture notes flag exactly this seam, parallel representations kept in sync by hand, as where bugs live. Cheap guards:
Without these, adding a core field to person or project silently leaves it stuck in Principle: keep one source of truth, and where duplication is unavoidable, test it. 🟢 3. Dev-proxy and Rust conversion differ at the edges📁
None of this matters with data written through the typed paths, but the doc claims exact parity. Pick one rule. Given CLAUDE.md's no-compat stance, the cleanest is to drop the legacy camelCase fallback from both sides rather than extend it. 🟢 4. Callback semantics of routed and superseded typed writes📁
🟢 5. Typed routing assumes the node is already persisted📁 On 🟢 6. Two query readers still use their own field-resolution heuristic📁 Kanban moved to 🟢 7. Children tree now fails as a whole on one bad node📁
🟢 Nits
What's done well
Recommendation: APPROVEThis is a net improvement that meets every acceptance criterion. I strongly recommend fixing 🟡1 in this PR: it's a small change (drain pending typed fields in the generic closure, clear them in |
malibio
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review: APPROVE (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.
…parity - Typed fields left pending by a typed write that a generic or batch write superseded in the coordinator's queue are now sent first by whichever write for the node runs next (sendPendingTypedFields); clearAll drops them. The mixed-write test now checks the generic half. - Typed routing waits for the node to be persisted (create stays generic); a mixed write's callbacks fire once, on the typed half. - Drift guards: promoted_fields vs each core schema (Rust), and TYPED_CORE_FIELDS vs promoted_fields read from convert.rs (TS). - storageNodeToApiFields mirrors Rust at the edges: typed-key fallback is task-only, a present null wins, only offset-carrying datetimes reduce. Skipped: table-row/query-node-model resolution (they also resolve node metadata columns and are correct for the typed shape); per-node fallback in the children tree (a loud failure beats serving an untyped node); nits with no current trigger. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review addressed (commit e476d69)✅ 🟡1 A typed write superseded by a generic write is never sent.
✅ 🟡2 Drift guards.
✅ 🟢3 Dev-proxy parity at the edges.
✅ 🟢4 Callbacks. On a mixed write, ✅ 🟢5 Typed routing before create. Routing now requires the node to be in ⏭️ Skipped
Tests: frontend 5,301/5,301 and core lib 1,774 pass; the pre-push gate passes. Re-review needed: yes. 🟡1 changes the generic and batch persistence closures. |
Pragmatic Code Review (Re-Review): PR #3033, commit e476d69Review TypeRe-Review. Scope is only Previous Review Summary
The skips are reasonable.
The addressed items land.
Code Review Findings🟡 A. The persisted gate sends typed fields down a path that silently drops them📁 When a typed node is not yet in
Both branches then report success, so the optimistic value stays on screen until the next reload. Before this commit, the same write failed loudly with NOT_FOUND and a write-failure toast. The window is narrow (a node whose create hasn't resolved), but the failure changed from loud to silent data loss. The new test (keeps a node awaiting its create on the generic path) only asserts the optimistic value and that the typed RPC was not called, so it doesn't catch this. Fix (small):
The create keeps its own path, the typed field goes out at the confirmed version, and "whoever runs next flushes" becomes true for creates as well. Test: extend the new test so the create resolves, then assert that Principle: a fallback must preserve the write's semantics, not only avoid the error. 🟡 B. A typed-flush failure inside a generic or batch closure takes the unrelated generic write down with it, and the typed caller never hears about it📁 The flush error becomes the generic write's own error. For the generic closure:
The realistic trigger is a play-rule rejection (ADR-060 invariants on task status transitions) or a vocabulary validation error on the stranded typed field. The user's unrelated content edit is then dropped from the server, while local content stays optimistic until the next keystroke. Callbacks go wrong in the other direction too:
Fix:
Principle: isolate failure domains. An error should be reported to the caller that owns the failed intent. 🟢 C. The flush runs before the pending-move wait📁 Both closures wait on 🟢 D. Date normalization differs from Rust on the third branch📁
🟢 E. Pre-existing mirror race, worth a follow-up issue📁 This is the opposite direction of 🟡1, and it was not introduced here. A typed immediate write cancels a still-debounced generic content write for the same node, and the typed closure doesn't send content. Example: type in a task and click its checkbox within the 500 ms debounce. The fix is symmetric to this commit: the typed closure (or Nits
What's done well
Recommendation: APPROVEThe commit fixes the headline 🟡1 correctly and closes every previously requested item. I strongly recommend fixing 🟡A before merge: it's a few lines, and it turns a new silent loss back into a correct write. 🟡B's callback-carrying change is the principled way to make the method doc's claim true. C, D and E can be follow-ups. |
malibio
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review (re-review): APPROVE (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.
…e queue (closes #3036) - sendPendingTypedFields() now owns the typed write end to end: waits for a pending move, sends, confirms, and settles every staging write's callbacks and notifications. It never throws, so a typed failure can no longer abort (or be reported as) the unrelated generic/batch write whose closure flushed it; the typed caller's onPersistError fires instead. - A node not yet created stages its typed fields without a write of its own (an immediate write would cancel its debounced create); they are sent right after each create path lands. - Coordinator: an immediate write promotes a node's still-debounced write instead of cancelling it, so typing followed by a status change within the debounce window no longer drops the typed text. - Date normalization mirrors only the RFC 3339 branch of normalize_date_field (seconds + Z/±hh:mm offset). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Re-review addressed (commit b382365)✅ 🟡A: the persisted gate sent typed fields down a path that dropped them.
✅ 🟡B: a typed-flush failure took down the generic write and notified the wrong caller.
✅ 🟢C: the flush ran before the pending-move wait. The move wait now lives inside ✅ 🟢D: date parity. Only the RFC 3339 branch of ✅ 🟢E: an immediate write cancelled a still-debounced write (#3036). Fixed here rather than as a follow-up. When an immediate write arrives, the coordinator now starts the node's waiting debounced write instead of cancelling it, and the immediate write collapses behind it. A debounced write replacing another debounced write still cancels, since that's the intended keystroke collapse. Test: an immediate typed write does not discard a still-debounced content edit. ✅ Nit: Tests: frontend 5,303/5,303 pass; the pre-push gate passes. Re-review needed: yes. The coordinator and the typed failure path changed. |
Pragmatic Code Review (Re-Review 2): PR #3033, commit b382365Review TypeRe-Review. Scope is only Previous Review Summary
The fixes are verified.
Coordinator promotion analysis (focus item 1)📁
Code Review Findings🟡 1. Move-wait circularity is now reachable from the create and typed paths (a 5 s stall, not a hang). Follow-up, non-blocking📁
The early return fixed the common (nothing-staged) case, which the outdent-unpersisted suite caught. The staged case still stalls. The generic closure ( Fix (follow-up): don't await a move from inside a coordinator operation that the move itself is flushing. For the create paths, the cheapest fix is to hand the staged fields to a fresh coordinator write instead of flushing inline: PersistenceCoordinator.getInstance().persist(nodeId, () => this.sendPendingTypedFields(nodeId), { mode: 'immediate' });That write queues behind the executing create. The move's flush has already captured the create's promise, not the queued one, so the move completes and the queued typed write then awaits it without a cycle. Open an issue that covers the generic Principle: a lock-ordering or wait-for cycle. Never wait on a party that is waiting on you. 🟢 2. Promotion widens the existing single-slot supersede window for generic non-content writes📁 After a promotion, the immediate write sits in the queued slot for the whole promoted RPC, instead of executing at once. Any write that arrives during that RPC supersedes it, even a debounce-mode keystroke, because the
This is the same loss that already happens whenever a generic write is queued behind an in-flight one; promotion only adds a new way into it. The window is tens of milliseconds, and pointer-driven property changes (Kanban) can't overlap with typing. Fix (follow-up, same issue as 1 or its own): give generic non-content fields the same staging treatment Principle: latest-wins is only safe for idempotent full-state writes, and these are partial-field writes. 🟢 3. An OCC raised from a flush inside another closure can double-notify, and it drops staged callbacks unsettled📁
Fix (small, optional):
For the double toast, a boolean return from Principle: every promise or callback a caller registers should settle exactly once. 🟢 4. No coordinator-level test for promotion📁 The store-level test (an immediate typed write does not discard a still-debounced content edit) pins the #3036 scenario and the typed-first ordering well. The coordinator is exported for direct testing, though, and the new branch has invariants worth pinning in isolation:
These are cheap, and they guard a class the file has regressed on before (unsettled promises). Nits
What's done well
Recommendation: APPROVEEvery item from the last round is addressed, and the coordinator change holds up under the settlement, bookkeeping, delete and create-promotion analysis above. No finding here warrants another fix-and-review round before merge.
|
malibio
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review (re-review 2): APPROVE (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.
- persistence-coordinator-promotion.test.ts pins the promotion directly: both writes resolve in order, flushes wait for both, debounce-over- debounce still cancels, a pending edit runs before a following delete. - PendingOperation.debounced is required, set at every construction site. - The typed write's catch swallows only OperationCancelledError. Findings 1-3 (move/create wait cycle, queued generic non-content fields, OCC during a nested flush) are tracked in #3037. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Re-review 2 addressed (commit ed2dba2)✅ 🟢4: coordinator-level promotion tests. New
✅ Nits
⏭️ Tracked in #3037:
The reviewer marked all three as follow-ups. Each changes the write paths again, so each warrants its own review. Tests: frontend 5,307/5,307 pass; the pre-push gate passes. Re-review needed: no. This round adds tests, tightens a type, and adds logging for an unexpected error. No write-path logic changed. |
Closes #2807
Summary
personandprojectare core types but had no typed representation. The frontend read their fields out of thepropertiesbag, which broke person forms once the two transports disagreed on the bag's shape. This follows thetaskpattern end to end, so core types are strongly typed. The properties bag and the storage namespace are now internal only.Backend
nodespace-types:PersonNode/ProjectNodewire structs andPersonNodeUpdate/ProjectNodeUpdate(tri-state: absent /null= clear / value).node_to_typed_valuenow moves core fields out ofpropertiesfor task, person and project (the documented"properties": {}), so each core field has one home.promoted_fieldsandflat_properties_viewlet machine consumers rebuild the storage-keyed map.update_person_node/update_project_nodecheck the node type, then send the typed patch through the generic update pipeline. That brings schema/enum validation,title_template, OCC, invariant dispatch, events, embeddings and mentions along without another copy of the pipeline.UpdatePersonNode/UpdateProjectNodeend to end, mirroringUpdateTaskNode, including the typedcurrent_nodeembed on a version conflict.node_to_typed_valuewith the extends chain collapsed, so tree-loaded nodes match single-node reads. Salvaged from Read and write node properties flat on every transport (closes #2807) #2934.relationship getfolds promoted fields back under their storage keys (due_date, notdueDate).get_node, search summaries andcreate_nodeproperty count useflat_properties_view, so what the model sees is unchanged.Frontend
PersonNode/ProjectNodeinterfaces and converters, wired intonormalizeNodeData.nodeToTaskNodenow keepsproperties/title, which fixes user-defined task fields onmain.TYPED_CORE_FIELDSregistry (mirrorspromoted_fields). It drives:resolveFieldValue/buildFieldWrite, used byGenericSchemaForm, Kanban and viewer field edits.updateTypedNode()replaces the 356-lineupdateTaskNodebody.updateTaskNode/updatePersonNode/updateProjectNodeare thin wrappers.updateNode()routes typed keys there, withonPersistSuccess/onPersistErrorpassed through for Kanban reverts.updaterAPI is removed; it's dead now.firstName/lastName/emailand write through the typed update; an emptied field is cleared. Project's form reads its typed fields through the registry.Also in this PR
schema_chain_blindness_guard_testwas failing onmain: Harden rename_schema_field: in-tx validation, version bump, chunked closure #3030 moved the rename checks intovalidate_schema_field_renameafter the guard landed. The allowlist entry now points at the new function.Docs
The
nodespace-docsmain branch has the layer-by-layer core-type recipe increating-node-types.md, the serialization contract updated indata-layer.md(field access decided by type, not protection level), and the plugin-updater references removed.Test plan
wire_contracttests for person and project, the move semantics and the flat view;update-typed-node.test.ts(dispatch, accumulation, routing, mixed write,onPersistError);🤖 Generated with Claude Code