feat(ndo): per-NDO-cell model A with DnaHash binding - #128
Conversation
Review round applied (2026-08-10)Self-review of this PR raised 2 blockers and 6 suggestions. All 8 are addressed.
Blockers
Suggestions
Verification
Not done on purpose: prettier formatting. All four touched TS files were already prettier-unclean at 18 files changed, +480 / -217, plus the new ADR. |
Review verdict: APPROVE (merge after #137)Reviewed per REVIEW.md's six areas. 1. Holochain entry patterns — clean. 2. Capability and security — the right call landed in integrity, not the coordinator: 3. ValueFlows — no economic entries touched; N/A. 4. Sweettest — new 5. Zome boundaries — shared types cross via 6. Documentation — ADR-010-013 added, Known gap, accepted: the ADR-013 mirror validation covers name/regime/nature only; Sequence note: merging after #137 (dev e2e fix + sweettest gate) so this PR's merge gets the full backend gate it has never had in CI. |
- Add NdoDnaProperties (immutable Layer 0 fields, excludes lifecycle_stage) to nondominium_shared so both integrity zomes and the UI consume one shape. - zome_resource_integrity::validate_create_nondominium_identity now checks the entry agrees with dna_info().modifiers.properties when they deserialize to NdoDnaProperties (ndo cells cloned with properties). On the shared nondominium cell (properties: ~) deserialization fails and the check is skipped, so legacy shared-cell NDOs keep working. - Add holochain_serialized_bytes dep to nondominium_shared for the SerializedBytes derive.
…ification-only binding - NdoDnaProperties drops description (entry-only) to match the Sweettest NdoCellProperties mirror exactly, so client and tests derive the same DnaHash. - Binding check validates name/regime/nature only (the classification fields). initiator/created_at are bound by the DnaHash but not entry-validated, since the entry sets them at create_ndo runtime and the Sweettest agent-key-per-cell artifact prevents equality. Full mirror validation deferred to the create_ndo refactor. Non-breaking: existing ndo_anchor Sweettests keep passing.
holochain 0.6.0 transports create_clone_cell properties as YamlProperties(serde_yaml::Value), which has no binary variant and cannot carry an AgentPubKey. An initiator in the properties made client createCloneCell hang. NdoDnaProperties is now 4 YAML-safe fields (name, property_regime, resource_nature, created_at); the DnaHash binds classification + microsecond-unique creation time, and initiator stays authoritative on the entry and cached on the NdoAnchor for display.
After an NDO lifecycle transition, lobby/group cards read the anchor's cached lifecycle_stage; without a refresh the card stays at the creation-time stage. refresh_ndo_anchor_lifecycle_stage resolves the anchor by NDO identity (not action hash) via the GroupToNdoAnchors link and follows the NdoAnchorUpdates chain to the latest version, so repeated refreshes stay visible to get_ndo_anchors. The client knows only the NDO identity; original-vs-latest anchor action hashes stay inside the group link graph. Refactored latest_anchor_record into latest_anchor (returns latest hash + record) so both get_ndo_anchors and the resolver share the chain walk. Two new sweettests: refresh updates the cache (peer-visible); no-op when the identity is absent.
Each NDO gets its own cloned ndo cell whose DnaHash is cryptographically bound to the NDO identity via YAML-safe DNA properties (ADR-010 model A, ADR-013 binding). createNdo provisions the clone, writes the genesis NondominiumIdentity inside, and anchors it in the group cell with full clone coordinates. Lobby/group grids read NdoAnchors; NDO detail resolves the ndo cell from the anchor and reads the live entry. Lifecycle transitions run on the ndo cell and refresh the group anchor so cards converge without a reload. ensureNdoCloneCell does not re-authorize existing-enabled cells (avoids source-chain-head-moved races). Connect path skips disabled clones; lobby descriptor builder skips disabled clones. e2e read-backs migrated to per-cell reads; multi-agent live-transition discriminator added.
An anchor update may only refresh the cached descriptor (name, description, lifecycle_stage). The identity coordinates are what a peer re-derives the NDO cell from, so rewriting them silently repoints a group at a different network. Any group member can update any member's anchor, so the rule belongs in integrity rather than in the coordinator. validate_ndo_anchor_update rebuilds the original with the mutable fields swapped in and compares, so a field added to NdoAnchor later is immutable by default and must be opted into mutability deliberately. The new Sweettest exercises the attack through the real API: update_ndo_anchor takes original_action_hash and previous_action_hash separately, so passing two different anchors writes A's coordinates over B. Also drops the hand-kept NdoDnaProperties mirror from the test crate in favour of the shared definition (that mirror had already drifted once on initiator; a drifted mirror derives a different DnaHash, the exact failure these tests exist to catch), and corrects the created_at doc comment: it is micros from a millisecond clock, not a uniqueness source, which is the per-NDO network_seed.
…SoftLink NdoView exposes "Associate with a group" on every NDO page. It wrote a SoftLink, but after the anchor migration both read paths follow NdoAnchors only, so the modal reported success and the NDO never appeared in the target group. NdoService.associateNdoWithGroup now writes a second NdoAnchor there, copying the source anchor's clone coordinates, and no-ops if already anchored. createSoftLink and getSoftLinkTargetHashes lose their last callers and are marked deprecated for NDO association. Two related hardenings: - createNdo no longer swallows the anchor write. The anchor is the only pointer any read path follows, so a swallowed failure left a cell nobody could reach. The destination group is now resolved before the clone cell is provisioned, so a bad group fails cheaply instead of orphaning a cell. - One scanGroupAnchors() pass answers every "where is this NDO anchored" question. The detail read, getAssociatedGroupIds and the post-transition refresh share it, and the refresh now touches only the groups that actually anchor the NDO instead of walking every group cell. NdoAnchorInput becomes an alias of NdoAnchorEntry: the coordinator builds the entry straight from the payload, so a second declaration could only drift.
The per-NDO-cell change touches the NDO Layer 0 identity model, which TELOS.md flags as load-bearing, but ADR-010/011/012/013 lived only in .local/ so the repo carried no record of it. Adds documentation/specifications/adr/ ADR-010-013-per-ndo-cells.md: the four decisions, the YamlProperties transport constraint that forced initiator out of the DnaHash, the shared-cell skip path, the open follow-ups, and a map of where each piece lives in code. Then corrects the statements the migration made stale: - specifications.md 7.1 gains NdoDnaProperties and NdoAnchorEntry; 7.2 replaces the SoftLink-backed ndo.service table with the anchor-backed one - ui_architecture.md and ui_design.md describe anchors and per-NDO cells - IMPLEMENTATION_STATUS.md lists NdoAnchor and the four anchor externs - lobby-dna.md marks the NDO DNA and UI cutover rows shipped - API_REFERENCE.md gains an NDO Anchors section for all four externs - requirements.md gains REQ-UI-GRP-06 for the anchor model SoftLink stays documented as a planning-level link, no longer as the group to NDO pointer.
6a2eec2 to
b89cb22
Compare
…rdown The rebase onto dev (which now carries the clone-guard teardown and the expectEmptyLobby precondition from #137) dropped this branch's changes to the same file. Both are needed: the guard teardown and CTA precondition come from dev, while the two DHT read-backs must enumerate the agent's ndo clone cells rather than the shared nondominium cell, since under the per-NDO-cell model the UI writes NondominiumIdentity into its own clone.
…fix) Five conflicts, all resolved by keeping both sides rather than picking one: - crates/shared/types.rs: #132's Rivalry/ResourceScope/OperationalState and dev's NdoDnaProperties are independent additions; both kept. - zome_resource integrity: kept #132's regime-semantics hook AND dev's ADR-013 binding check. Dropping the latter would silently remove the guarantee that a GovernanceRule's classification cannot diverge from Layer 0. - ndo_anchor tests: took dev's shared-crate import (#128 deliberately replaced the hand-kept mirror, which had already drifted on `initiator`), then re-applied #132's additions on top: rivalry_override on the NdoInput/NdoEntry mirrors and the Public-regime anchor test. That test was written against the old mirror, so it needed adapting: NdoDnaProperties has no `initiator` field, and anchor_input_from now takes the initiator as a separate argument. - ndo.service.ts: took dev's side. #132's mapListingToDescriptor is dead under the anchor model (zero callers) and its identityToDescriptor was a duplicate definition. rivalry_override survives in the retained field mapper. - IMPLEMENTATION_STATUS.md: neither side was accurate. Arbitrated against the code: 20 externs, and no get_all_groups.
PR #128 moved every NDO into its own cloned `ndo` cell, but the Layer 1 and Layer 2 UI kept calling `zome_resource` and `zome_gouvernance` on the shared provisioned `nondominium` cell, where the NDO identity was never written. Creating a resource specification therefore failed with `Guest("Entry operation failed: Linked NondominiumIdentity not found")` from `resource_specification.rs:53`, and every NDO-scoped read returned nothing. `NdoService.resolveCellIdForNdo` resolves the NDO's clone cell from its anchor coordinates, provisioning it for a peer who never joined, and returns null for legacy NDOs still living in the shared cell so those keep working. `NdoView` resolves it once per NDO and passes `ndoCellId` to the resources, governance and activity tabs and to the four modals below them. An optional `cellId` threads through `zome-helpers` into both zome services and both stores; every call that does not pass one keeps its previous role-name routing. Two further defects surfaced while verifying this in the browser: - The governance tab's "+ New rule" handler carried a second, unrouted `fetchSpecificationsForNdo`. It returned nothing, so the rule was written with no `specification_hash` and no read path could surface it again. Routed, and guarded so the editor refuses to open when the NDO has no Layer 1 spec. - `anchorToDescriptor` omitted `rivalry_override`, leaving `bun run check` red on the branch. The anchor caches only the card fields, so it is null there and the live read on open supplies the real value. Verified in real Chrome against the 2-agent dev network: a specification and a typed AccessRequirement rule both create and read back. A probe through the app's own client confirms placement, `get_specifications_for_ndo` returning 0 for the shared cell, 0 for the provisioned `ndo` cell and 1 for the NDO's own clone. `bun run check` reports 0 errors and 0 warnings.
Intent
Ship the v0.1.0 peer-mesh release on
dev: two peers share a Group and an NDO over a live DHT via per-NDO cloned cells (ADR-010 model A). Each NDO gets its own clonedndocell whoseDnaHashis cryptographically bound to the NDO identity through YAML-safe DNA properties (ADR-013 binding), so the group-to-NDO anchor alone lets any member derive, verify, and join the NDO network.Changes
NdoDnaProperties(name, property_regime, resource_nature, created_at) rides in thendoclone's properties;validate_create_nondominium_identityrejects acreate_ndowhose classification diverges.initiatoris intentionally absent (YamlProperties has no binary variant); it stays on the entry and the anchor.ndoclone, write the genesisNondominiumIdentityinside, anchor it in the group cell with full clone coordinates (ndo_dna_hash,network_seed, identity action hash, cached classification).NdoAnchors without joining ndo cells. NDO detail resolves the ndo cell from the anchor (ensureNdoCloneCell) and reads the live entry; shared-cell legacy fallback retained.refresh_ndo_anchor_lifecycle_stageresolves by NDO identity and updates the cached stage, so cards converge without a reload (walks theNdoAnchorUpdateschain so repeated refreshes stay visible).ensureNdoCloneCellno longer re-authorizes existing-enabled cells (source-chain-head-moved); connect path and lobby builder skip disabled clones.Decisions
update_ndo_anchorafter transitionNdoAnchorUpdateslink must originate from the original create hash, which only the group link knows. A Rust resolver by identity is correct.initiatorin DNA propertiesYamlProperties(serde_yaml::Value)has no binary variant; anAgentPubKeyhangscreateCloneCell. The DnaHash binds classification plus microsecond creation time instead.NdoAnchors (ADR-011): the group DHT is the discovery scope.How to test
bun run build:happCARGO_TARGET_DIR=target/native-tests cargo test -p group_sweettest --test ndo_anchor -- --test-threads 4-> 9 passedCARGO_TARGET_DIR=target/native-tests cargo test -p nondominium_sweettest --test nondominium -- --test-threads 3-> 10 passed (suite over 8 tests needs limited threads)bun run test:e2e-> 19 passed on a live 2-conductor DHT (create, browse, detail, transition, cross-dimension filters; two-agent group invite, shared NDO, live cross-agent transition read)Documentation
No in-repo
documentation/changes here. The ADRs (010/011/012/013) and design of record live in.local/(kept local); a separate docs pass will sync the NDO specification.Related