- Introduced a comprehensive audit guide for the production-grade sync feature, detailing the evaluation criteria for round-trip identity and code quality. - Added a new plan for auditing the two-way sync Ref property feature, outlining specific tasks and expected behaviors. - Updated `ServeSession.lua` to include the instance map in patch updates, ensuring accurate property encoding. - Enhanced `encodeInstance.lua` to prevent encoding Ref properties during instance addition, delegating this to the patch update process. - Implemented regression tests to highlight known limitations in Ref handling, ensuring better coverage and identification of existing bugs. These changes improve the clarity of the sync feature's audit process and strengthen the handling of Ref properties in the system.
26 KiB
| name | overview | todos | isProject | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Audit Ref Two-Way Sync | Production-grade audit of the two-way sync Ref property feature. The invariant: for every Ref property change (set, change target, set to nil), the filesystem must represent the Ref as a Rojo_Ref_* path-based attribute such that building an rbxl from the filesystem and forward-syncing it back produces a bit-identical Ref target. Every audit item is evaluated against this round-trip fidelity standard. |
|
false |
Audit: Two-Way Sync Ref Properties -- Round-Trip Fidelity
Quality Standard
Round-trip identity: Two-way sync writes a
Rojo_Ref_*attribute to a meta/model file. Building an rbxl from that directory tree and forward-syncing it back must produce a bit-identical Ref target -- same property name, same target instance. Any deviation is a bug.
Every audit item below must be evaluated against this standard. If there is ANY code path where a Ref property can be lost, point to the wrong target, or silently become nil through a two-way-sync + rebuild cycle, flag it as critical.
Code Quality Standard
Watch for duplicated logic between ref_properties.rs (CLI syncback bulk mode) and api.rs (two-way sync per-instance mode). Both compute paths and format Rojo_Ref_* attributes. The shared helpers in rojo_ref.rs should be the single source of truth.
1. Round-Trip Identity Verification
Trace the complete lifecycle of a Ref property through every path.
1a. Set PrimaryPart Round-Trip
Concrete example: Model.PrimaryPart = Part1 (Part1 is at Workspace/TestModel/Part1).
- Plugin encodes: reads
instance.PrimaryPart-> Instance ->instanceMap.fromInstances[Part1]-> server Ref ID ->{ Ref = "hexid" } - Server receives:
Variant::Ref(part1_ref)inchanged_properties - Server writes:
syncback_updated_propertiesextracts Ref, computes path viafull_path_of, writesRojo_Ref_PrimaryPart = "Workspace/TestModel/Part1"toinit.meta.json5 - Server applies: PatchSet includes
PrimaryPart = Variant::Ref(part1_ref)to in-memory tree - Forward sync reads:
compute_ref_propertiesfindsRojo_Ref_PrimaryPartattribute, callsget_instance_by_path("Workspace/TestModel/Part1"), resolves toVariant::Ref - Verify: Is the resolved Ref the same target instance? Does
full_path_ofproduce a path thatget_instance_by_pathcan resolve back? Any off-by-one in root handling?
1b. Nil PrimaryPart Round-Trip
Model.PrimaryPart = nil:
- Plugin encodes: reads nil ->
{ Ref = "000...0" } - Server receives:
Variant::Ref(Ref::none()) - handle_api_write converts: nil Ref ->
None(property removal) in PatchSet - Server writes:
remove_attributesincludes"Rojo_Ref_PrimaryPart" - Server applies: property removed from in-memory tree
- Forward sync reads: no
Rojo_Ref_PrimaryPartattribute -> no PrimaryPart property - Verify: Is the nil Ref ->
Noneconversion inhandle_api_writecorrect? Doesmerge_or_build_metaactually remove the attribute? Doesremove_attributesrun BEFOREnew_attributesmerge (ordering matters if both lists have the same key)?
1c. Change Target Round-Trip
Model.PrimaryPart = Part1 then Model.PrimaryPart = Part2:
- First write:
Rojo_Ref_PrimaryPart = "Workspace/TestModel/Part1" - Second write:
merge_or_build_metaoverwrites attribute to"Workspace/TestModel/Part2" - Verify: Does the merge correctly overwrite? Or does the old value persist alongside the new? Is the attribute key case-sensitive?
1d. ObjectValue.Value Round-Trip
ObjectValue.Value = SomePart where ObjectValue is defined in .model.json5:
- Server writes: attribute goes into the
.model.json5file directly (is_model_file = true) - Verify: Does
merge_or_build_metacorrectly addRojo_Ref_Valueto a model file's attributes? Does the snapshot middleware read attributes from.model.json5the same way as from.meta.json5?
1e. Ref on Script Round-Trip
Script has a custom Ref property set via two-way sync:
- Server writes: adjacent
ScriptName.meta.json5gets theRojo_Ref_*attribute - Verify: Is the meta file path computed correctly (strip
.server/.clientsuffix from stem)? Does the adjacent meta correctly pair with the script file on read?
1f. Stale Path After Rename
Set Model.PrimaryPart = Part1, then rename Model from TestModel to RenamedModel:
- Meta file written with
Rojo_Ref_PrimaryPart = "Workspace/TestModel/Part1" - ChangeProcessor renames directory to
RenamedModel - Path in attribute is now stale
- Verify: What happens on rebuild?
get_instance_by_path("Workspace/TestModel/Part1")returns None becauseTestModelno longer exists. PrimaryPart silently becomes nil. Is this documented as a known limitation? Is a warning logged?
2. Plugin-Side Encoding Completeness
Every code path in the plugin that encodes property changes must correctly handle Ref properties.
- propertyFilter.lua:
Refremoved fromUNENCODABLE_DATA_TYPES? OnlyUniqueIdremains? - encodePatchUpdate.lua:
descriptor.dataType == "Ref"branch handles nil, valid, and unresolvable cases?descriptor:read(instance)correctly reads Ref properties on all instance types (Model.PrimaryPart, ObjectValue.Value, Weld.Part0)?- Does the
instanceMap.fromInstances[readResult]lookup use the Instance as key (not a string)? - When
instanceMapis nil (should never happen but defensively), does the code log and skip without crashing?
- createPatchSet.lua:
instanceMappassed toencodePatchUpdatein both normal andsyncSourceOnlypaths? - encodeInstance.lua: Still filters out Ref properties during instance addition encoding? (Refs on newly added instances are NOT encoded -- they'd have no target ID yet)
- Edge cases:
- Instance's
Changedevent fires for Ref property but the target was just destroyed ->readResultis nil -> correctly encoded as null ref? - ValueBase instances (ObjectValue) ->
GetPropertyChangedSignal("Value")fires,descriptor:readreturns the Instance reference?
- Instance's
3. Server-Side Path Resolution Completeness
Every code path in api.rs that handles Ref properties must correctly compute paths.
syncback_updated_properties:- Ref extraction happens BEFORE
filter_properties_for_meta? crate::ref_attribute_name(key)produces correct attribute name?crate::ref_target_path(tree.inner(), *target_ref)produces correct path?tree.get_instance(*target_ref).is_some()correctly checks target existence?- Nil Ref ->
remove_attributeslist, notref_attributesmap? - Target not in tree -> warning logged, not in either list?
- Ref entries removed from
propsbeforefilter_properties_for_meta? (verify Refs don't fall through tovariant_to_jsonwhich returns None)
- Ref extraction happens BEFORE
filter_properties_for_meta:Variant::Refis no longer skipped? OnlyVariant::UniqueIdis skipped?- If a
Variant::Refsomehow reachesvariant_to_json, it returnsNone(safe fallback)?
merge_or_build_meta:remove_attributesprocessed BEFOREnew_attributesmerge? (important: if the same key is in both, the add should win)- Removal works on existing files (parses JSON5, modifies object, writes back)?
- Removal is no-op on non-existent files?
- Removal is no-op when the attribute doesn't exist in the file?
handle_api_write:- Nil Refs (
Ref::none()) converted toNone(property removal) in the PatchSet? (the fix we applied) - Conversion happens for ALL updated instances, not just some?
- Non-nil Refs pass through unchanged?
- Nil Refs (
4. Forward-Sync Resolution Completeness
The forward-sync direction (filesystem -> server -> plugin) must correctly resolve Rojo_Ref_* attributes.
- patch_compute.rs
compute_ref_properties:- Correctly strips
Rojo_Ref_prefix to get property name? - Handles both
Variant::StringandVariant::BinaryStringattribute values? - Logs warning for non-string attribute types?
tree.get_instance_by_path(path)returnsNonefor invalid paths without crashing?Nonereturn for unresolvable paths -- does this cause property removal on the tree instance? (could silently clear a Ref that was set during the session but hasn't been written to disk yet)
- Correctly strips
- patch_apply.rs:
defer_ref_propertiescorrectly collectsRojo_Ref_*attributes for later resolution?finalize_patch_applicationresolves path refs viaget_instance_by_path?- Resolved refs are set as
Variant::Refon the instance? Rojo_Ref_*attributes are cleaned from the Attributes property after resolution? (they should NOT appear as instance attributes in Studio)
- decodeValue.lua:
Reftype correctly handled: resolves viainstanceMap.fromIds[value]?- Null ref (
"000...0") returns nil? - Unknown ID returns error?
5. Interaction with Existing Ref Systems
The new Rojo_Ref_* system coexists with the legacy Rojo_Target_* + Rojo_Id system.
- ref_properties.rs: CLI syncback still uses both path-based (
Rojo_Ref_*) and ID-based (Rojo_Target_*+Rojo_Id) systems?- The shared
ref_attribute_namehelper is used for path-based refs? - The
Rojo_Target_*formatting still uses inlineformat!(not shared)? - Is this inconsistency a DRY concern?
- The shared
link_referents: Old attributes (Rojo_Ref_*andRojo_Target_*) are filtered out before re-inserting? No duplicate attributes?compute_ref_propertiesin patch_compute.rs: handles BOTHRojo_Ref_*andRojo_Target_*? Both resolve correctly? Priority if both exist for the same property?- Two-way sync:
filter_properties_for_metano longer skipsVariant::Ref-- does this meanVariant::Refvalues could reachvariant_to_jsonif a Ref property change comes from a path OTHER thansyncback_updated_properties? Are there other callers?
6. Meta File Lifecycle with Refs
Ref attributes add a new dimension to meta file management.
- Creation: When the first Ref is set on an instance with no meta file -> meta file created with just
attributes: { Rojo_Ref_PropertyName: "path" }? Doesbuild_meta_objecthandle this (called when file doesn't exist)? - Merge: When Ref attribute is added to an existing meta file with
className,properties, otherattributes-> existing content preserved?Rojo_Ref_*merged into attributes alongside user attributes? - Update: When Ref target changes -> attribute value overwritten? Old value not left alongside new?
- Deletion: When Ref set to nil -> attribute removed from file? If no other content remains in
attributes, is the emptyattributes: {}section left or cleaned up? If meta file has no remaining content at all (no className, no properties, no attributes), should the file be deleted? - Orphaned Rojo_Ref_*: If an instance is deleted, its meta file is deleted too (existing behavior). No orphaned
Rojo_Ref_*attributes should remain. Verify this path.
7. Path Computation Correctness
The path computation is the heart of the system. Any bug here silently corrupts Ref targets.
ref_target_pathcallsdom.full_path_of(target_ref, "/"):- Root instance -> empty string ""? Does
get_instance_by_path("")correctly resolve to root? - Single depth ->
"Workspace"? Resolves back? - Deep nesting ->
"Workspace/Model/SubFolder/Part"? Resolves back? - Instance name contains
/-> path becomes ambiguous!full_path_ofuses/as separator but instance names can contain/(slugified in filesystem but NOT in the tree). This could be a critical issue. A Part named"A/B"atWorkspacewould produce path"Workspace/A/B"which resolves toWorkspace > A > B(3 levels) instead ofWorkspace > "A/B"(2 levels). - Instance name contains special chars -> path may not match filesystem slug. But paths are computed from tree names, not filesystem names, so this should be OK for resolution within the same session. Cross-session, paths are stored on disk and resolved from the tree -- tree names should be consistent.
- Root instance -> empty string ""? Does
7b. Known Potential Issue: Missing instanceMap in ServeSession Pull Path
File: ServeSession.lua line 738
encodePatchUpdate was updated to accept an instanceMap parameter (4th arg) for Ref encoding. createPatchSet.lua passes it correctly. However, the call in ServeSession.lua line 738 was NOT updated:
local update = encodePatchUpdate(instance, change.id, propertiesToSync)
This is the "pull" path in __confirmAndApplyInitialPatch -- when users select "pull" in the initial patch confirmation dialog to sync Studio property changes back to Atlas. The missing 4th argument means instanceMap is nil inside encodePatchUpdate, causing all Ref property changes (PrimaryPart, ObjectValue.Value, constraint refs, etc.) to be silently dropped with a warning instead of encoded. This is a round-trip identity violation for the initial sync pull path.
Expected fix: Pass self.__instanceMap as the 4th argument:
local update = encodePatchUpdate(instance, change.id, propertiesToSync, self.__instanceMap)
Audit should verify: Are there any OTHER callers of encodePatchUpdate beyond createPatchSet.lua and ServeSession.lua:738 that are also missing the instanceMap argument?
7c. encodeInstance.lua Ref Leak
File: encodeInstance.lua
encodeInstance.lua imports UNENCODABLE_DATA_TYPES from propertyFilter.lua and uses it to filter properties before calling encodeProperty. We removed Ref = true from that filter. Now Ref properties on newly added instances pass through to encodeProperty, which calls RbxDom.EncodedValue.encode(readResult, dataType). For Ref properties, readResult is a Roblox Instance, and RbxDom.EncodedValue.encode likely throws (Instance is not an encodable value). The pcall wrapper catches the error and the property is silently dropped.
The behavior is correct (you can't encode a Ref without the InstanceMap during instance addition), but the code path is fragile. It relies on pcall catching an error thrown by RbxDom.EncodedValue.encode rather than an explicit check. If RbxDom.EncodedValue.encode is updated to handle Refs in the future, the behavior would change silently.
Audit should verify: Does encodeInstance.lua need its own Ref-specific filter (like encodePatchUpdate.lua has), or is the pcall fallback acceptable?
7d. VFS Echo Prevention for Ref Meta Writes
File: api.rs syncback_updated_properties()
The suppression system uses per-path counters: suppress_path() increments a create/write counter, suppress_path_remove() increments a remove counter. The change processor checks and decrements these when VFS events arrive.
Modern pattern:
- Updates to existing files (meta writes, model updates, source writes): MUST suppress to prevent feedback loops
- New instance additions (
syncback_added_instance): do NOT suppress -- the VFS watcher must pick up new files to add them to the tree - Folder creation: suppression varies by context (conversion vs. new creation)
Verify for Ref changes specifically: All four file format branches in syncback_updated_properties call suppress_path() before fs::write():
- Directory init.meta.json5 (line ~2630)
- Adjacent script .meta.json5 (line ~2663)
- Inline .model.json5 (line ~2690)
- Adjacent non-script .meta.json5 (line ~2713)
These are all updates to existing instances (not additions), so suppression is correct. Verify no branch was missed and that the suppress_path calls use the correct path (the path being written, not the instance path).
7e. CLI Syncback Parity
From atlas.mdc: "Plugin-based sync must produce exactly the same filesystem output that atlas syncback would give for the same input. Byte-for-byte identical files."
Verify that the two-way sync Ref output matches CLI syncback (ref_properties.rs) output for equivalent scenarios:
- Same
Rojo_Ref_*attribute name format - Same path format (slash-separated, root excluded)
- Same JSON5 attribute placement (in
attributessection, notproperties) - Same handling of nil Refs (attribute absent, not present with empty value)
- Both use the shared
ref_attribute_name()helper from rojo_ref.rs
Any divergence between the two paths is a bug per atlas.mdc.
7f. Msgpack Wire Format for Refs
The plugin sends { Ref = "hexid" } via msgpack to the server. The server deserializes this as Variant::Ref(Ref).
Verify: Does the msgpack deserialization in handle_api_write correctly round-trip Variant::Ref values? Check that Ref::from_str parses the hex string from the plugin and produces the correct NonZeroU128. Check that Ref::none() (hex "000...0") deserializes as Ref(None).
8. Edge Cases and Error Handling
- Ref to ProjectNode instance: Can a Ref property target an instance defined in a project file? Path resolution should work since ProjectNode instances ARE in the tree. But if the target is removed from the project file, the path becomes invalid.
- Ref to instance in nested project: Path computed by
full_path_ofspans the entire tree. But nested projects have their own sync boundaries. Is the path still valid after a project reload? - Concurrent Ref changes: Two Ref properties changed in the same batch -> both written to meta file? Does
merge_or_build_metahandle multiple attribute additions in one call? - Ref property that doesn't exist in reflection: If the plugin sends a Ref for a property name that doesn't exist in rbx_reflection -> attribute is written but never resolved on forward-sync?
- Multiple instances with same Ref property change: Different instances, each with PrimaryPart changes -> each gets their own meta file written correctly?
9. Known Limitations (Document, Don't Fix)
These were explicitly accepted in the implementation plan. Confirm they are documented, log appropriate warnings, and do NOT produce silent data corruption.
- Ambiguous paths: Duplicate-named siblings make paths non-unique. Path-based refs may resolve to wrong sibling. No crash, but round-trip violation.
- Stale paths after rename: Stored path becomes invalid when target is renamed/moved.
- No immediate ID for new instances: Pulled instances don't get IDs until VFS watcher processes files. Ref properties targeting new instances are dropped with warning.
10. Prove Known Limitations (Regression Tests Left Failing)
Write tests that assert the correct round-trip behavior for the known limitations. These tests SHOULD FAIL against the current implementation, proving the bugs are real. Leave them failing -- do NOT mark them #[ignore] or SKIP or try to fix the implementation to make them pass. They document real violations of the round-trip invariant and will serve as acceptance criteria when fixes are implemented later.
If a test unexpectedly PASSES, analyze why. If the behavior is genuinely correct, leave the test -- it's free coverage.
10a. Stale Path After Target Rename (Rust integration)
In tests/tests/two_way_sync.rs:
- Set
Model.PrimaryPart = Part1via/api/write - Wait for
Rojo_Ref_PrimaryPart = "Workspace/TestModel/Part1"in meta file - Rename
Part1toRenamedPartvia/api/write(changed_name) - Wait for rename to process
- Read meta file -- assert
Rojo_Ref_PrimaryPartis now"Workspace/TestModel/RenamedPart" - EXPECTED: FAIL (stored path is stale)
Add variations:
- Rename the MODEL (parent) instead of the target Part
- Rename an ancestor higher in the tree
10b. Ref to Instance Added in Same Request (Rust integration)
In tests/tests/two_way_sync.rs:
- Add a new Part via
/api/write(added instances) with a temp GUID key - In the SAME write request, set
Model.PrimaryPart = newPartRef(using the temp GUID) - Assert that
Rojo_Ref_PrimaryPartappears in the meta file with the correct path to the new Part - EXPECTED: FAIL (target not in tree when
syncback_updated_propertiesruns)
10c. Ref to Untracked Instance Dropped (Lua spec)
In plugin/src/ChangeBatcher/encodePatchUpdate.spec.lua:
- Create a Model and a Part in Studio
- Set
Model.PrimaryPart = Part - Insert Model into InstanceMap but NOT Part
- Call
encodePatchUpdatewithPrimaryPartchange and the instanceMap - Assert that PrimaryPart IS encoded (with Part's server ID)
- EXPECTED: FAIL (Part not in InstanceMap, property dropped with warning)
10d. ServeSession Pull Path Missing instanceMap (Lua spec)
In plugin/src/ChangeBatcher/encodePatchUpdate.spec.lua:
- Create a Model with PrimaryPart set to a tracked Part (both in InstanceMap)
- Call
encodePatchUpdate(model, "MODEL_ID", { PrimaryPart = true })-- WITHOUT the 4th instanceMap arg - Assert that PrimaryPart IS encoded as
{ Ref = serverId } - EXPECTED: FAIL (instanceMap is nil, triggers warning path, property dropped)
11. Test Coverage Review
Evaluate existing tests against the audit areas above. For each gap, flag as missing coverage with specific test description.
- Round-trip tests: Do integration tests verify that setting a Ref via
/api/write, then building from filesystem, produces the same Ref? - Nil Ref cleanup: Is the nil Ref -> attribute removal -> property removal chain tested end-to-end?
- Forward-sync resolution: Are there serve tests that verify
Rojo_Ref_*attributes resolve toVariant::Refin the tree? - Path edge cases: Instance names with
/in them? Deep nesting? Root-level Ref targets? - Concurrent changes: Multiple Ref changes in one batch?
- Error paths: Ref to non-existent instance? Ref on ProjectNode instance?
- CLI syncback parity: Does the test suite verify that two-way sync and CLI syncback produce identical
Rojo_Ref_*output for the same input?
12. Deliverables
Produce a structured report with:
- Critical issues -- bugs that would cause incorrect Ref targets, lost Refs, or silent data corruption on round-trip
- Correctness concerns -- logic that might not manifest immediately but could cause problems (e.g., the
/in instance names issue in section 7) - Missing test coverage -- specific test cases needed, prioritized by risk
- Known limitations -- confirmed as documented and handled gracefully (warning logged, no crash, no silent corruption beyond the documented scope)
- Code quality items -- DRY violations, dead code, stale comments
- For each issue: file path, line numbers, description, and suggested fix