Files
Uni-Lab-OS/plan/2026-05-20_11_resource_material_sync_guidance_draft.md
2026-05-20 19:17:48 +08:00

39 KiB

Draft: Resource And Material Sync Guidance For Sirna And Similar Bioyond Systems

Status: draft for discussion, not an implementation mandate.

This note triangulates across five source categories with different authority:

  1. docs/developer_guide/examples/workstation_architecture.md: desired shape and vocabulary. It is a design target, not proof that the current code has every behavior.
  2. BioyondWorkstation, BioyondResourceSynchronizer, and shared graphio code: the current compatibility anchor. New guidance should mostly preserve this lifecycle and extend it deliberately.
  3. Existing non-Sirna Bioyond stations: practical examples of how the shared base is used, including shortcuts that should not become policy.
  4. Sirna implementation, plans, findings, and guide notes: stress-test evidence. They expose real missing cases, but the current Sirna code is not the architecture authority because parts were added without fully aligning to the shared base.
  5. UniLabOS resource framework behavior around PLR resources, ResourceTreeSet, serialize/deserialize, and update_resource(resources=...).

The short recommendation is:

Keep the shared Bioyond workstation lifecycle as the center of gravity. Evolve the shared synchronizer with small project hooks for classification, ID-based resolution, and non-slot material handling, rather than letting Sirna become a parallel synchronization model. For Sirna, those hooks should resolve placement by Bioyond IDs, distinguish physical slot labware from reagent liquid contents, preserve Bioyond IDs in unilabos_extra, mutate the local PLR deck, and publish the full deck through update_resource(resources=[deck]).

Do not treat every Bioyond stock row as a deck resource.

Evidence Weighting

This task is not a Sirna implementation retrospective. It is a best-practice alignment pass across the architecture target, the shared base class, observed station behavior, and Sirna's newly exposed edge cases.

Use the sources this way:

  • Architecture doc: ask "what shape should this system eventually have?"
  • Shared Bioyond base: ask "what behavior must remain compatible today?"
  • Other Bioyond stations: ask "what patterns are already working in practice?"
  • Sirna current code and notes: ask "what did the base model fail to handle, and which Sirna fixes conflict with the shared lifecycle?"
  • Live API/schema evidence: ask "what is true for this deployment's material, warehouse, and coordinate data?"

The Sirna AGENT_GUIDE is useful for finding caveats and prior investigations, but it should not be cited as an independent source of truth when source code, framework behavior, live API evidence, or architecture docs can answer the same question.

Mental Model

There are three resource worlds. Confusing them is the main source of bugs.

World Owner Purpose Recommended truth
Bioyond/LIMS Bioyond APIs through BioyondV1RPC External stock, material IDs, warehouse IDs, location IDs, inbound/outbound side effects External material truth
PLR deck Workstation driver Runtime workstation material layout, warehouse occupancy, liquid contents Local mutation surface
UniLabOS resource tree ResourceTreeSet / ROS node / host resource APIs Canonical UniLabOS/cloud representation Framework/cloud truth

The architecture guide describes Deck as the local material system and ResourceSynchronizer as the optional external-system bridge (docs/developer_guide/examples/workstation_architecture.md:221). The broader framework serializes PLR objects into ResourceTreeSet, whose resource dicts carry id, uuid, parent_uuid, type, class, pose, config, data, and extra (unilabos/resources/resource_tracker.py:107).

Therefore the correct path is:

Bioyond rows
  -> normalized material records
  -> preserve Bioyond materialTypeMode: Sample / Consumables / Reagent
  -> resolve material/location/warehouse IDs
  -> choose UniLabOS handling for that mode
  -> mutate PLR deck
  -> ResourceTreeSet.from_plr_resources([deck])
  -> ROS update_resource(resources=[deck])
  -> host/cloud resource-tree update

Avoid direct cloud JSON patches and avoid treating Bioyond records as already being UniLabOS resource nodes.

Target Architecture

The ideal architecture in workstation_architecture.md is still the right direction:

  1. WorkstationBase owns the local deck, workflow state, and hardware interface.
  2. ResourceSynchronizer owns external material synchronization.
  3. BioyondResourceSynchronizer owns the Bioyond-specific use of BioyondV1RPC.
  4. ROS2WorkstationNode.update_resource(resources) owns the UniLabOS/cloud resource-tree update.
  5. Optional HTTP report handlers may trigger local deck mutation, external sync, and cloud publication.

The documented startup sequence is:

  1. Create workstation.
  2. Initialize PLR deck and warehouses.
  3. Create Bioyond RPC hardware interface.
  4. Create resource synchronizer.
  5. sync_from_external().
  6. Initialize ROS node and children.
  7. post_init(ros_node).
  8. Upload resources=[deck].

See docs/developer_guide/examples/workstation_architecture.md:308, docs/developer_guide/examples/workstation_architecture.md:497, and docs/developer_guide/examples/workstation_architecture.md:737.

Important caveat: that document is the hope. It is still valuable because it names the desired responsibilities, but current Bioyond stations implement a more limited, best-effort side-effect sync. When the doc and current code diverge, use the doc to choose direction and the shared base code to choose the next compatible step.

Current Practical Behavior

The shared BioyondWorkstation implementation is the current behavioral baseline. It does this today:

  1. Requires a deck.
  2. Reconstructs deck.warehouses from deck children/config when needed.
  3. Creates BioyondV1RPC.
  4. Installs BioyondResourceSynchronizer.
  5. Immediately calls sync_from_external().
  6. Later, in post_init, publishes the whole deck with ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, resources=[self.deck]).

Relevant code:

  • BioyondResourceSynchronizer is defined in unilabos/devices/workstation/bioyond_studio/station.py:117.
  • sync_from_external() fetches stock typeMode 0, 1, and 2, then passes all rows to resource_bioyond_to_plr(...) (unilabos/devices/workstation/bioyond_studio/station.py:147).
  • BioyondWorkstation.__init__ installs the synchronizer and syncs immediately (unilabos/devices/workstation/bioyond_studio/station.py:856).
  • post_init() uploads the deck (unilabos/devices/workstation/bioyond_studio/station.py:893).
  • resource_tree_add() performs Bioyond create/inbound side effects (unilabos/devices/workstation/bioyond_studio/station.py:958).

This practical behavior works for simple physical-resource stock import, but it does not provide full continuous two-way sync, conflict resolution, or reliable stale-state cleanup. In particular:

  • Re-fetching stock does not clearly clear stale local deck state first.
  • Local-to-external update no-ops unless unilabos_extra["update_resource_site"] is present.
  • process_material_change_report() is mostly TODO-level in the base station.
  • Some station-specific code bypasses the shared synchronizer with direct LIMS calls.
  • Some existing stations contain shortcuts that should not be copied, such as duplicate initialization, stale globals, or hardcoded warehouse/axis cases.

Treat current Bioyond behavior as operationally useful and compatibility important, not as proof that the ideal architecture is already achieved. The goal is to tighten this base path, not replace it with a Sirna-only path.

For Sirna and similar Bioyond systems, the base synchronizer should evolve from the current shared path into one shared pipeline with small project hooks:

sync_from_external()
  fetch stock rows from Bioyond
  update RPC material cache
  normalize rows into a common internal shape
  preserve materialTypeMode: Sample / Consumables / Reagent
  resolve warehouse/location by Bioyond IDs when available
  choose handling for the row's mode
  apply Sample / Consumables rows as mapped slot labware by default
  apply Reagent rows as physical reagent labware or liquid content by evidence
  report unknown modes or unmapped handling loudly
  publish deck if deck changed and ROS node is available

Suggested extension points:

class BioyondResourceSynchronizer(ResourceSynchronizer):
    def external_material_mode(self, row: dict) -> str:
        return row["materialTypeMode"]  # Sample | Consumables | Reagent

    def resolve_external_row(self, row: dict) -> dict:
        ...

    def apply_external_row(self, row: dict, mode: str, resolved: dict) -> None:
        ...

The base class should continue to own:

  • stock fetches for typeMode 0/1/2;
  • material-cache refresh;
  • common normalization;
  • mode validation for Sample / Consumables / Reagent;
  • publication orchestration;
  • error aggregation;
  • stale-state policy once agreed.

Project code should own only the parts that are truly deployment-specific:

  • material type/mode to PLR class mapping;
  • project-local handling inside Sample / Consumables / Reagent;
  • project-local warehouse ID/name mapping;
  • project-local coordinate conventions;
  • special row handling such as Sirna reagent-as-liquid.

This keeps existing Bioyond stations close to the same lifecycle while still absorbing the Sirna lessons that the earlier base did not model. The default hook behavior can remain "Sample/Consumables/Reagent rows become mapped slot labware" for simpler stations; Sirna should override Reagent handling where evidence requires liquid-content behavior.

Sirna Findings To Feed Back Into Shared Design

Treat Sirna as a stress test for the shared base, not as a replacement design. It raises real questions that earlier stations did not need to answer:

  • within the Reagent mode, some external rows may be liquid contents rather than slot-occupying reagent labware;
  • placement should be ID-first where Bioyond supplies material/location IDs;
  • warehouse and axis metadata must survive serialize/deserialize; project-specific mode handling is available.

For Sirna-like deployments, the old implicit rule "stock row equals PLR resource" is too coarse. For simpler deployments, it can remain the default mode-handling behavior.

IDs Win

Placement identity should prefer:

materialId
locationId
materialTypeId
warehouseId / whid

Display/debug fields are not identity:

materialName
materialCode
locationCode
locationShowName
warehouseName / whName

locationCode such as 1-1 is not globally unique. It can exist in multiple warehouses. Code-only resolution may be kept as a diagnostic fallback, but it must raise on ambiguity.

The current Sirna ID-first resolver is useful source evidence for this direction (unilabos/devices/workstation/bioyond_studio/sirna_station/sirna_station.py:3659). The Sirna mega plan captures the same concern, but the implementation and live Bioyond IDs are the stronger evidence.

Bioyond Modes And UniLabOS Handling Are Different

Bioyond has three primary material modes in this context:

Sample
Consumables
Reagent

Those modes should be preserved as the external taxonomy. UniLabOS still needs a handling decision inside the mode: should the row create/place a physical PLR resource, or update contents on an already-placed parent resource?

Physical slot resources include things like:

  • tip racks;
  • plates;
  • cell culture plates;
  • empty trough/bottle labware;
  • other objects that occupy a warehouse slot.

Reagents are contents of a parent labware when the row describes a liquid rather than a physical holder. For Sirna, Bioyond stock_material(typeMode=2) returns materialTypeMode="Reagent" rows; those rows still need evidence-based handling as either physical reagent labware or reagent liquid content. The finding in temp_benyao/sirna/_findings/2026-05-07_reagents_vs_resources.md:6 records the bug: the generic path can fall back to RegularContainer, fail registry lookup, and drop reagent rows such as 试剂槽裂解液/Betame.

Recommended behavior:

  1. Validate materialTypeMode as Sample, Consumables, or Reagent.
  2. For Sample and Consumables, default to mapped slot labware: instantiate the mapped PLR class and place it into the resolved warehouse slot.
  3. For Reagent, decide whether the row represents physical reagent labware or reagent liquid content from material type evidence, row shape, and live data.
  4. For physical reagent labware, place the mapped PLR resource in the resolved slot.
  5. For reagent liquid content, find the parent trough/bottle and attach liquid metadata to its tracker.
  6. Preserve Bioyond IDs in parent.unilabos_extra["reagent_bioyond_ids"].
  7. Make liquid attachment idempotent by Bioyond material ID.
  8. If the parent labware is missing or the mode/handling cannot be resolved, defer or log with IDs; do not guess.

The existing Sirna helper _attach_liquid_to_parent() already follows the idempotent metadata direction (unilabos/devices/workstation/bioyond_studio/sirna_station/sirna_station.py:4074).

The 0003 Question Must Stay Evidence-Based

Do not blindly map materialTypeCode 0003 to BioyondSirna_ReagentTrough.

If live/schema evidence says a 0003 row is physical trough labware, map it to a PLR resource class. If evidence says it is liquid content, attach it to the parent trough. If evidence is contradictory, mark it unsupported and log the Bioyond IDs.

Treat this as an open design decision until source/schema/live evidence settles the row shape. The Sirna plan records the question, but it should not decide the mapping by itself.

Sirna Warehouse And Axis Rules Are Project-Local

Sirna warehouse layout and axis conventions must be verified from Sirna source, Sirna schema, current deck behavior, and live/read-only Sirna APIs when available. Do not import Peptide, reaction, dispensing, or cell station layout truth.

For Sirna specifically, the current integrated deck display should be treated as the good baseline. The prior col-row slot-key fix is already present in the deck/graphio path, and any remaining x/y or y-reverse correction should be handled through shared warehouse metadata and shared graphio/display mapping rather than by reshaping the Sirna station display ad hoc.

Display Geometry: Evidence Before Axis Values

Peptide resource sync should not be treated as a validated model, but its UniLabOS display work is useful and should influence this guidance.

Confirmed Peptide behavior:

  • Peptide live warehouse evidence found 自动化堆栈 with 170 locations where code="10-17" appears at x=17, y=10 (../Uni-Lab-OS-Peptide/temp_benyao/peptide/_findings/2026-05-13_1404_peptide_col_row_deck.md:6).
  • Peptide's current display convention is Peptide-specific evidence: bioyond_axis="xy_col_row" and bioyond_key_axis="col_row" in the current Peptide station implementation. Do not copy that pair into Sirna or any other project without live warehouse evidence for that target system. With this Peptide combination, graphio does not apply the legacy x/y swap (../Uni-Lab-OS-Peptide/unilabos/resources/graphio.py:868).
  • Peptide models the main automation stack as 17 visual rows by 10 visual columns while preserving labels such as 10-17 (../Uni-Lab-OS-Peptide/unilabos/resources/bioyond/decks.py:308).
  • Peptide warehouses and deck child positions use frontend_y_flip=True / _frontend_y_flipped_coordinate(...) so stored PLR coordinates compensate for the frontend y-axis inversion (../Uni-Lab-OS-Peptide/unilabos/resources/bioyond/decks.py:299; temp_benyao/peptide/_findings/2026-05-13_1514_frontend_y_flip_layout.md:6).
  • The older Peptide graph-layout note is stale for the Sirna discussion. The current Sirna integrated station/deck display is a good baseline; the reusable Peptide lesson is axis metadata and frontend y-reverse compatibility.
  • Peptide tests encode the intended behavior: 10-17 lands at row 17 / column 10, and after frontend y-flip the displayed site positions match the expected top-to-bottom layout (../Uni-Lab-OS-Peptide/temp_benyao/peptide/tests/test_peptide_deck_layout.py:60).

Guidance for Sirna and similar systems:

  1. Treat bioyond_axis and bioyond_key_axis as two separate concepts:
    • bioyond_axis describes how Bioyond numeric x/y map to visual axes.
    • bioyond_key_axis describes how slot labels such as 10-17 are generated or preserved.
  2. Do not infer display orientation from label strings alone. Both row_col and col_row can preserve the same final label text while producing different visual layouts and graphio swap behavior.
  3. Use live/read-only code/x/y evidence for each project and each warehouse family before choosing axis metadata.
  4. Keep visual orientation fixes separate from material identity. IDs and slot labels determine registration; display dimensions and frontend y-flip only determine how the deck appears.
  5. Author intended display coordinates first, then convert stored y coordinates for the active frontend convention:
    • deck child stored y = deck_height - display_y - child_height;
    • warehouse site stored y = warehouse_height - display_y - site_height;
    • graph-level y values should be transformed only when the active frontend convention requires it.
  6. Preserve the current Sirna display as the baseline unless concrete frontend evidence shows a y-reverse problem. Do not change station/deck graph semantics just to match stale Peptide layout notes.
  7. Add layout tests that assert:
    • warehouse num_items_x, num_items_y, capacity, first key, and last key;
    • representative code/x/y examples land on the intended site key;
    • frontend y-flip produces expected displayed positions;
    • generated deck children do not overlap in displayed coordinates.
  8. When borrowing from Peptide, borrow the evidence pattern: live discovery, axis/key-axis metadata, y-flip tests, and display fixtures. Do not borrow a literal axis pair or Peptide resource-sync behavior as a validated path.

Concrete live discovery workflow:

  1. Prefer the reusable read-only probe pattern before reading old findings:
python3 temp_benyao/sirna/tests/probe_readonly_storage_inventory.py \
  --base-url <api_host> \
  --api-key <api_key> \
  --output temp_benyao/<project>/_logs/<timestamp>_readonly_storage_inventory_probe.json

This probe checks swagger candidates, project storage/location endpoints, material type endpoints, warehouse-info-by-mat-type-id, and stock-material, then writes a redacted evidence file and a merged warehouse summary.

  1. For Sirna quick checks, temp_benyao/sirna/tests/discover_sirna_warehouses.py is the narrower historical helper. Treat it as a template unless it has been parameterized for the target config.
  2. Query /api/storage/location/locations-by-type?type=0&typeMode=0&materialType=0 first for stack names, warehouse IDs, full slot lists, code, x, y, z, and display mode. This endpoint is the topology source when available.
  3. Cross-reference with /api/lims/storage/material-types and /api/lims/storage/warehouse-info-by-mat-type-id to learn material-type placement constraints. Use stock-material only for occupied-slot evidence; it cannot reveal empty topology.
  4. Infer bioyond_key_axis from how slot labels must be generated or preserved, and infer bioyond_axis from how raw Bioyond x/y must map to PLR holder indices. A label such as 10-17 alone is ambiguous; compare it to the same record's x/y, and use boundary examples from non-square stacks.
  5. Encode the discovered convention on the warehouse resource, not in a one-off station branch. The metadata must serialize/deserialize with the warehouse because graph load and cloud sync rebuild resources.
  6. Add or update layout tests before changing Sirna display behavior. For Sirna, test against the current good display first, then only change shared x/y or y-reverse mapping if the fixture demonstrates a real mismatch.

Deserialize Must Be Idempotent

This is confirmed framework behavior, not a possible risk. Resource publication builds ResourceTreeSet from live PLR resources by calling resource.serialize() and resource.serialize_all_state() (unilabos/resources/resource_tracker.py:547). Readback/reconstruction goes through ResourceTreeSet.to_plr_resources(), finds the PLR subclass, calls sub_cls.deserialize(...), then restores PLR state, UUIDs, and unilabos_extra (unilabos/resources/resource_tracker.py:637). ResourceTreeSet.dump() also serializes resource nodes while excluding children from each individual node record (unilabos/resources/resource_tracker.py:914), so parent/child relationships and object state must survive the framework tree shape rather than only an in-memory PLR object graph.

Sirna resource/deck classes therefore must survive:

  1. registry-time construction;
  2. Resource.deserialize() round trips;
  3. cloud-synced deck state with serialized children.

If a synced deck already has serialized children, default setup must not create duplicate/stale child resources. Sirna noticed this problem, but the reason is framework-level: cloud/resource-tree sync reconstructs PLR objects from serialized state, so constructors and setup logic must be idempotent.

Existing Sirna deck code already acknowledges the issue: BIOYOND_Sirna_Deck turns setup=False during deserialize when serialized children are present (unilabos/resources/bioyond/decks.py:153). Sirna material classes also use registry-visible @resource(...) decorators and tolerant constructors with *args, **kwargs, defaults, and ordering fallbacks (unilabos/resources/bioyond/sirna_materials.py:14). Treat those as required patterns for any new synced resource class.

Guidance:

  • Do not add a deck/resource class until it round-trips through Resource.serialize() / Resource.deserialize() and ResourceTreeSet.from_plr_resources(...).to_plr_resources().
  • Preserve unilabos_uuid, parentage, unilabos_extra, and liquid state across the round trip.
  • Make default setup conditional: if serialized children exist, do not recreate default warehouses or default child resources on top of them.
  • Itemized PLR subclasses must provide ordered_items or ordering; otherwise deserialization can fail or rebuild a different structure.

Startup And Resync Recommendation

The current Sirna code installs SirnaResourceSynchronizer after super().post_init(ros_node) (sirna_station.py:363). That is useful as a phase patch and as evidence that classification hooks are needed, but it should not become the long-term lifecycle. Base initialization may already have run generic stock sync and base post-init may already have published the deck before the Sirna synchronizer is installed.

Recommended pathway:

  1. Keep the BioyondWorkstation lifecycle as the base path.
  2. Add a synchronizer factory or hook registration point before eager sync, for example create_resource_synchronizer().
  3. Let Sirna provide classification/resolution hooks through that shared synchronizer shape.
  4. Make startup sync use the installed hook-aware synchronizer before the first deck publication.
  5. Make manual resync, reset resync, report-triggered resync, and future periodic sync all call self.resource_synchronizer.sync_from_external().
  6. Do not instantiate fresh base synchronizers inside Sirna actions, because that bypasses the installed project logic.

This directly addresses the overlap finding in temp_benyao/sirna/_findings/2026-05-12_sirna_synchronizer_overlap.md:6.

Material Registration From Create-Order Results

Create-order allocation registration should remain separate from stock sync, but it should use the same classification and apply logic.

Recommended create-order pipeline:

  1. Normalize allocation records into:
{
    "materialId": "...",
    "materialCode": "...",
    "materialName": "...",
    "materialTypeId": "...",
    "materialTypeCode": "...",
    "materialTypeMode": "Sample|Consumables|Reagent",
    "materialTypeName": "...",
    "locationId": "...",
    "locationCode": "...",
    "locationShowName": "...",
}
  1. Resolve warehouse and slot by:
material-info(materialId)
  -> warehouse-info-by-mat-type-id(materialTypeId) matched by locationId
  -> code-only diagnostic fallback only if unambiguous
  1. Classify as slot_labware, liquid_content, or unsupported.
  2. Apply mutation to the PLR deck.
  3. Publish the full deck once after the batch.

The current Sirna _register_materials_to_tree() already documents this flow (sirna_station.py:3659) and should be the local reference implementation until the shared hook is designed.

Cloud / UniLabOS Publication Rules

Always mutate real tracked PLR resources first. Then publish through the framework.

Correct call shape:

ROS2DeviceNode.run_async_func(
    self._ros_node.update_resource,
    True,
    **{"resources": [self.deck]},
)

The real method is:

async def update_resource(self, resources: List["ResourcePLR"])

See unilabos/ros/nodes/base_device_node.py:727.

Do not call:

update_resource(resource_name=..., resource_data=...)

Do not manually serialize the deck for this path. update_resource() creates a ResourceTreeSet, sends it to /c2s_update_resource_tree, and applies returned UUID mappings. Missing root parent UUIDs are auto-mounted to the current device, so parentage should be preserved on PLR objects before publication.

Metadata Contract

Every Bioyond-originated slot resource should carry enough metadata for unload, audit, and later sync:

plr_resource.unilabos_extra = {
    "material_bioyond_id": mat["materialId"],
    "material_bioyond_code": mat["materialCode"],
    "material_bioyond_name": mat["materialName"],
    "material_bioyond_type_id": mat["materialTypeId"],
    "material_bioyond_type_code": mat["materialTypeCode"],
    "material_bioyond_type_mode": mat["materialTypeMode"],
    "location_bioyond_id": mat["locationId"],
    "location_code": resolved["location_code"],
    "warehouse_bioyond_id": resolved["warehouse_id"],
    "warehouse_bioyond_name": resolved["warehouse_name"],
    "location_resolution_source": resolved["source"],
}

Every Bioyond-originated liquid content should preserve equivalent metadata on the parent labware:

parent.unilabos_extra.setdefault("reagent_bioyond_ids", []).append({
    "material_bioyond_id": mat["materialId"],
    "material_bioyond_code": mat["materialCode"],
    "material_bioyond_name": mat["materialName"],
    "material_bioyond_type_id": mat["materialTypeId"],
    "material_bioyond_type_code": mat["materialTypeCode"],
    "location_bioyond_id": mat["locationId"],
    "quantity": mat.get("quantity"),
    "location_resolution_source": resolved["source"],
})

This metadata is not decorative. It is required for correct unload, audit, duplicate prevention, and future round-trip sync.

Stale State And Conflict Policy

This is confirmed current behavior, not just a theoretical risk.

Current stock import places resources into empty warehouse slots and skips occupied slots. That prevents overwrites, but it can leave stale local resources after external moves/deletes.

Evidence:

  • BioyondResourceSynchronizer.sync_from_external() fetches Bioyond stock and delegates to resource_bioyond_to_plr(...), but it does not compute a before/after diff or remove local resources absent from the snapshot (unilabos/devices/workstation/bioyond_studio/station.py:147).
  • resource_bioyond_to_plr(...) only assigns when the target warehouse position is empty or a placeholder; when a real resource already occupies the slot, it logs "跳过放置" and leaves the existing object in place (unilabos/resources/graphio.py:936).
  • Bioyond outbound/removal logic exists in separate local-to-external hooks such as resource_tree_remove(...), but that is not invoked by stock refresh for Bioyond rows that disappeared externally (unilabos/devices/workstation/bioyond_studio/station.py:987).

Recommended direction:

  1. Define Bioyond as the source of truth for external stock snapshots unless a local operation is in progress.
  2. Before applying a stock snapshot, compute a diff by Bioyond material ID.
  3. Remove or mark local resources whose Bioyond IDs disappeared from the snapshot, subject to workflow safety checks.
  4. Move local resources whose Bioyond location ID changed.
  5. Attach/detach liquid contents idempotently by Bioyond material ID.
  6. Publish once after applying the batch.
  7. In ambiguous or active-operation cases, log and require manual confirmation.

Until this policy exists, call the current implementation "refresh/import" rather than "authoritative synchronization."

External-To-Local And Local-To-External Boundaries

External-to-local:

  • Bioyond stock and create-order allocation rows mutate the local PLR deck.
  • Then UniLabOS publication derives resource trees from the PLR deck.

Local-to-external:

  • resource_tree_add / update / remove may call Bioyond add, inbound, outbound, or move APIs.
  • These paths should require Bioyond IDs or explicit creation parameters.
  • Movement should use unilabos_extra["update_resource_site"] only as an explicit request, not as hidden ambient state.
  • After successful Bioyond side effects, refresh or update the PLR deck and publish the deck.

Avoid station-level direct LIMS calls that bypass the synchronizer unless the action explicitly reconciles the deck afterward.

Error Handling Rules

Fail loudly on:

  • unknown material type code/name;
  • unresolved warehouse ID/name;
  • location ID not found in warehouse-info-by-mat-type-id;
  • code-only location ambiguity;
  • missing parent labware for liquid content;
  • invalid PLR resource class or Resource.deserialize() failure;
  • Bioyond RPC returning empty/fallback values where an ID is required.

Do not silently create RegularContainer to hide mapping failures. The Sirna finding at temp_benyao/sirna/_findings/2026-05-07_reagents_vs_resources.md:52 calls this out as a reusable trap.

Tests And Validation

Minimum offline tests:

  1. update_resource is called only with resources=[deck].
  2. ResourceTreeSet.from_plr_resources([deck]) preserves UUIDs, parentage, data, and unilabos_extra.
  3. Sirna allocation records resolve by material-info first.
  4. warehouse-info-by-mat-type-id resolves by locationId.
  5. Code-only fallback raises on ambiguous slots.
  6. Sample and Consumables rows become slot labware.
  7. Reagent content rows become parent liquid metadata.
  8. Re-running registration does not duplicate liquid entries.
  9. Missing parent labware is deferred/logged, not guessed.
  10. Unknown material type emits Bioyond IDs.
  11. Deck deserialize does not recreate duplicate default children.
  12. Warehouse coordinate mapping is tested per warehouse, not globally.
  13. A synced Sirna deck round-trips through PLR deserialize and ResourceTreeSet conversion without duplicate default children.
  14. Stock refresh with a missing Bioyond material ID proves current add/skip behavior first, then verifies the chosen diff/delete policy once implemented.
  15. Display geometry tests cover project-local live discovery fixtures, bioyond_axis, bioyond_key_axis, representative code/x/y mappings, frontend y-flip, and no-overlap deck layout.

Focused Sirna command:

pytest temp_benyao/sirna/tests/test_sirna_resource_system.py -q

Shared conversion command, when changing graphio or Bioyond converters:

pytest tests/resources/test_converter_bioyond.py -q

Live/read-only validation should capture fixtures for:

  • stock_material(typeMode=0/1/2, includeDetail=true);
  • material-info(materialId);
  • warehouse-info-by-mat-type-id(materialTypeId);
  • create-order allocation records;
  • frontend/cloud resource-tree readback after publication.

Discrepancies To Discuss

1. Architecture Is Direction; Base Code Is The Compatibility Anchor

The architecture guide presents ResourceSynchronizer as bidirectional. Current Bioyond code is mostly eager external import plus selected add/remove/update side effects. It has no complete continuous conflict-resolution loop.

Sirna current code should be read in this context: it exposes missing capabilities, but parts of it conflict with the existing base lifecycle because it was layered on after the base sync behavior already existed.

Recommendation: keep the bidirectional architecture as the target, preserve BioyondWorkstation as the implementation anchor, and introduce shared hooks for the missing Sirna-class problems. Describe the current implementation as best-effort refresh plus Bioyond side effects until a diff/conflict policy exists.

2. The Doc Centers Deck; The Framework Centers ResourceTreeSet

The architecture guide uses PLR Deck as the material system. UniLabOS resource tracking uses ResourceTreeSet as the canonical serialized representation.

Recommendation: phrase the model as "Deck is the local PLR mutation surface; ResourceTreeSet is the UniLabOS/cloud representation derived from it."

3. Base Bioyond Sync Treats Every Stock Row As PLR Resource

BioyondResourceSynchronizer.sync_from_external() currently feeds typeMode 0/1/2 rows into resource_bioyond_to_plr(...).

Sirna evidence shows this is wrong for reagent/content-like rows.

Recommendation: add classification hooks before conversion. Rows classified as liquid content must not go through standalone PLR resource conversion.

4. Sirna Fix Exists As A Fork, Not A Shared Hook

Current SirnaResourceSynchronizer captures the important reagent-as-liquid question, but it duplicates or bypasses shared sync mechanics. Startup can run base sync before the Sirna synchronizer is installed, and manual/reset paths have used fresh base synchronizers.

Recommendation: keep the discovered behavior where evidence supports it, but refactor the shape. A factory/hook in the shared synchronizer is preferable to a long-lived parallel sync class.

5. Sirna Create-Order Registration Is Stronger Than Stock Sync

Create-order registration now has ID-first resolution and a clearer classifier. That is valuable evidence, not automatically the canonical sync design. The stock sync path still resolves external liquid rows by warehouse name/code and stores weaker metadata.

Recommendation: extract the stronger resolver/classifier ideas into shared hooks, then converge stock sync and create-order registration on the same functions.

6. Graphio Has Shared Fragility

resource_bioyond_to_plr() contains hardcoded warehouse/coordinate cases and a fallback to RegularContainer. Hardening it affects multiple Bioyond projects.

Recommendation: after Sirna is green, discuss shared graphio hardening in a separate change: no silent RegularContainer, explicit unknown-type diagnostics, and project-specific axis metadata instead of hardcoded names.

7. Serialize/Deserialize Is A Gating Contract

This is not optional documentation neatness. Any new resource/deck class or sync metadata must pass the framework serialize/deserialize path before it can be trusted in cloud-synced operation.

Recommendation: make round-trip tests part of acceptance for resource-system changes, especially for Sirna deck setup, warehouse metadata, reagent_bioyond_ids, liquid tracker state, and itemized labware ordering.

8. Existing Stock Sync Is Add/Skip-Biased, Not Delete-Aware

Current sync_from_external() imports and places observed Bioyond resources, but it does not delete local resources missing from the latest stock snapshot. When a target slot is occupied by a real resource, graphio skips placement rather than replacing it.

Recommendation: describe the current behavior as "stock refresh/import" until a Bioyond-ID diff policy is implemented. Deletion/removal must be a deliberate phase, with workflow-safety checks and tests, not implied by the current stock sync name.

9. Peptide Display Is Useful; Peptide Sync Is Not Yet Authority

Peptide's latest display work is useful evidence for Peptide's own warehouse axis/key-axis metadata and for the shared need to account for frontend y-axis inversion. It does not define Sirna's axis values. Its resource sync remains untested/problematic and should not be treated as the model for Sirna sync behavior.

Recommendation: fold the Peptide display lesson into shared guidance as a discovery-and-test pattern, not as a literal axis pair. Keep Sirna display behavior grounded in Sirna live evidence and the current good deck baseline; keep resource synchronization recommendations grounded in the Sirna ID-first registration path and the shared Bioyond synchronizer refactor.

10. Some Existing Stations Should Be Examples Only, Not Templates

Non-Sirna stations show useful patterns, but also shortcuts:

  • direct LIMS calls bypassing the synchronizer;
  • duplicate super().__init__() in reaction station;
  • stale/undefined WAREHOUSE_MAPPING references;
  • RPC methods collapsing failures into empty values;
  • no robust stale deck cleanup on stock refresh.

Recommendation: borrow the shared deck publication and Bioyond ID metadata patterns, not the incidental shortcuts.

For the next implementation discussion, decide these in order:

  1. What is the smallest shared-base extension that lets project hooks exist before eager sync without breaking existing Bioyond stations?
  2. Should BioyondWorkstation gain a synchronizer factory, a hook registry, or a delayed eager-sync point?
  3. Is materialTypeCode 0003 in current Sirna live data physical labware, liquid content, or both depending on row shape?
  4. Where should Sirna warehouse Bioyond IDs live: station config, deck metadata, or both?
  5. Should stock refresh clear/diff deck state now, or remain append/skip until after create-order registration is stable?
  6. Should update_resource remain async/non-blocking for material registration, or should selected user-facing actions fail if publication fails?
  7. Which parts of the Sirna reagent-as-liquid rule are project-local, and which should become shared Bioyond behavior after Peptide/cell validation?
  8. Should graphio fallback hardening land before or after Sirna stock sync is fully validated?
  9. Should any remaining Sirna x/y or y-reverse issue be fixed in shared graphio/display mapping after a live fixture proves it, or should the current Sirna deck remain untouched?

The pragmatic path is:

  1. Preserve and test the current BioyondWorkstation lifecycle as the shared compatibility baseline.
  2. Treat Sirna create-order registration as a local proof of the missing classification/ID-resolution behavior, not as the architecture shape to copy.
  3. Validate live/read-only Sirna fixtures for 0003, warehouse IDs, and stock reagent rows.
  4. Refactor the shared Bioyond synchronizer to expose classification, resolution, and non-slot row hooks with default behavior matching existing stations.
  5. Make Sirna install hooks before the first external sync and first deck publication through the shared lifecycle.
  6. Route startup, manual resync, reset resync, and report-triggered resync through the installed synchronizer.
  7. Converge stock sync and create-order registration on one resolver/classifier/apply implementation.
  8. Add stale-state diffing only after the basic ID-first path is stable.
  9. Harden graphio fallback as a shared follow-up.

This gives Sirna the necessary behavior without locking in a second Bioyond sync system that future projects will have to debug around.