diff --git a/src/ada/assets/ifc/__init__.py b/src/ada/assets/ifc/__init__.py index 69b029f25..6070bee39 100644 --- a/src/ada/assets/ifc/__init__.py +++ b/src/ada/assets/ifc/__init__.py @@ -1,15 +1,17 @@ """The shipped IFC asset provider -- the only place in ``ada.assets`` allowed to know IFC. -Registers the ``asset-build-ifc`` builder from :mod:`ada.assets.builders`. This import itself must -stay CHEAP: :func:`ada.assets.builders.ensure_core_builders` imports this module from every -process that touches the asset store (Decision 1's dispatch-by-capability needs every builder -registered so it can answer "no builder for X" accurately, even on a pool that will never serve -``asset-build-ifc``). Neither ``ifcopenshell`` nor ``adacpp`` is imported at module scope here -- -only inside :func:`_make_builder`, which runs once, lazily, the first time something actually asks -for this capability. - -``walk`` / ``index`` / ``publish`` / ``build`` are separate submodules on purpose: a caller that -only needs the publish-time logic (a future ``asset-publish-ifc`` job handler, or a test) imports +Registers the ``asset-build-ifc`` builder (:mod:`ada.assets.builders`) and the ``ifc`` publisher +(:mod:`ada.assets.publishers`, Phase 4). This import itself must stay CHEAP: +:func:`ada.assets.builders.ensure_core_builders` AND :func:`ada.assets.publishers. +ensure_core_publishers` both import this module from every process that touches the asset store +(Decision 1's dispatch-by-capability needs every builder/publisher registered so it can answer "no +builder/publisher for X" accurately, even on a process that will never serve ``asset-build-ifc`` or +publish IFC). Neither ``ifcopenshell`` nor ``adacpp`` is imported at module scope here -- only +inside :func:`_make_builder` / :func:`_make_publisher`, which run once, lazily, the first time +something actually asks for the capability or the provider id. + +``walk`` / ``index`` / ``publish`` / ``publisher`` / ``build`` are separate submodules on purpose: +a caller that only needs the publish-time logic (a job handler, or a test) imports ``ada.assets.ifc.publish`` directly without pulling in the builder's own lazy import of the native GLB path. """ @@ -19,10 +21,12 @@ import importlib.util from ada.assets.builders import register_asset_builder +from ada.assets.publishers import register_asset_publisher __all__ = ["IFC_BUILD_CAPABILITY"] IFC_BUILD_CAPABILITY = "asset-build-ifc" +IFC_PROVIDER_ID = "ifc" def _available() -> bool: @@ -50,9 +54,21 @@ def _make_builder(): return IfcAssetBuilder() +def _make_publisher(): + from ada.assets.ifc.publisher import IfcAssetPublisher + + return IfcAssetPublisher() + + register_asset_builder( IFC_BUILD_CAPABILITY, _make_builder, label="IFC (core)", available=_available, ) + +register_asset_publisher( + IFC_PROVIDER_ID, + _make_publisher, + label="IFC (core)", +) diff --git a/src/ada/assets/ifc/index.py b/src/ada/assets/ifc/index.py index 6caa5665b..9a4cd6696 100644 --- a/src/ada/assets/ifc/index.py +++ b/src/ada/assets/ifc/index.py @@ -19,12 +19,18 @@ sets included or not" -- an unmeasured, provider-internal choice). Widening this hash to include property sets is additive and does not change the artefact's shape. -**Why STEP entity-instance numbers are excluded.** ``get_info`` embeds the numeric ``#id`` of -every referenced entity by default only through nested dicts (each referenced entity is itself -expanded to its own attribute dict, not left as a bare ``#123`` token), so two files that assign -different STEP line numbers to logically identical entities still hash the same -- which matters -here because ``v1`` and a re-exported ``v2`` are independently written files, not the same file -re-saved. +**Why STEP entity-instance numbers must be stripped, recursively.** ``get_info(recursive=True)`` +expands every referenced entity into its own nested dict rather than leaving a bare ``#123`` +token -- but EACH of those nested dicts still carries its own numeric ``"id"`` key alongside the +expanded attributes (verified against ifcopenshell's own output, not assumed), at every depth: the +product's own top-level id, its placement's id, its placement's axis' id, and so on down the whole +representation graph. Two files that assign different STEP line numbers to logically identical +entities -- the ordinary case for ``v1`` and an independently re-exported ``v2``, where adding or +removing ANY entity anywhere in the file shifts numbering for everything written after it -- would +therefore hash as "changed" everywhere, not just where content actually moved, if only the +top-level ``"id"`` were dropped. :func:`_strip_step_ids` walks the WHOLE nested structure and drops +every ``"id"`` key it finds, which is what makes the hash a statement about content rather than +about write order. """ from __future__ import annotations @@ -32,6 +38,7 @@ import hashlib import json from dataclasses import dataclass +from typing import Any import ifcopenshell @@ -57,6 +64,17 @@ class IfcIndexEntry: hash: str # sha256 of representation + placement + own attributes +def _strip_step_ids(node: Any) -> Any: + """Drop every ``"id"`` key (the STEP entity-instance number) at every depth of a + ``get_info(recursive=True)`` tree -- see the module docstring for why this must be recursive, + not just applied to the top-level dict.""" + if isinstance(node, dict): + return {k: _strip_step_ids(v) for k, v in node.items() if k != "id"} + if isinstance(node, (list, tuple)): + return [_strip_step_ids(v) for v in node] + return node + + def _product_hash(product: ifcopenshell.entity_instance) -> str: info = product.get_info(recursive=True) # OwnerHistory carries a per-write timestamp that moves on every re-export even when nothing @@ -64,6 +82,7 @@ def _product_hash(product: ifcopenshell.entity_instance) -> str: # the entry's own key already. Both would make an untouched product hash as "changed". info.pop("OwnerHistory", None) info.pop("GlobalId", None) + info = _strip_step_ids(info) blob = json.dumps(info, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") return hashlib.sha256(blob).hexdigest() diff --git a/src/ada/assets/ifc/publish.py b/src/ada/assets/ifc/publish.py index cf00eb011..6fdf6684f 100644 --- a/src/ada/assets/ifc/publish.py +++ b/src/ada/assets/ifc/publish.py @@ -24,6 +24,18 @@ becomes independently buildable only once ITS OWN scoped publish (``--root``/``--leaf``) gives it a manifest -- that is the whole of Decision 3's leaf-addressable publishing, expressed as "only the declared root of THIS publish gets `delivery='build'`". + +**Phase 4: this module PLANS, it does not decide whether to write (Decision 2a's "a provider +PLANS; core WRITES", ``ada.assets.publish``).** :func:`_derive_ifc_plan` is the one derivation -- +it builds the ordered write list and, when ``enforce_occupancy=True``, the SAME occupancy refusal +:func:`publish_ifc` has always made (kept here because :func:`publish_ifc` is still a direct, +side-effecting entry point used by tests and any caller without a job queue in front of it). +``ada.assets.ifc.publisher.IfcAssetPublisher.derive()`` calls the identical function with +``enforce_occupancy=False`` -- core's own ``apply_publish_plan`` decides occupancy and ``replace`` +for a job-driven publish, and a provider deciding it twice would just be two places that could +disagree. Both callers get the OTHER guarantee this split does not change: a plan half-derived +because a node id does not exist, or because the file has no instant, still raises +:class:`IfcPublishError` before anything is written. """ from __future__ import annotations @@ -60,9 +72,11 @@ from ada.assets.manifest import ( HIERARCHY_FILENAME, MANIFEST_FILENAME, + Actor, ArtefactEntry, AssetManifest, BuildSpec, + ChangeRecord, ) from ada.assets.projection import build_hierarchy from ada.cadit.ifc.store import IfcStore @@ -107,6 +121,19 @@ class PublishResult: written: tuple[str, ...] # every key written (or, under dry_run, that WOULD be written) +@dataclass(frozen=True) +class _DerivedIfcPlan: + """What :func:`_derive_ifc_plan` computes -- the shared shape both :func:`publish_ifc` (which + writes it) and ``IfcAssetPublisher.derive()`` (which hands it to core as a ``PublishPlan``, + unwritten) build on.""" + + collection: str + revision: str + subjects: tuple[str, ...] + writes: tuple[tuple[str, bytes], ...] # IN ORDER -- see the module docstring's write-order note + counts: dict[str, int] + + def _sha(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @@ -126,6 +153,55 @@ def _instant_from_project(ifc_store: IfcStore) -> str | None: return datetime.fromtimestamp(oh.CreationDate, tz=timezone.utc).isoformat() +def _actor_from_owner_history(oh: Any) -> Actor | None: + """``IfcOwnerHistory`` -> ``Actor`` (Decision 7's adopt-renamed carrier). Best-effort: a + history missing ``OwningUser`` entirely (legal in the schema) yields ``None`` rather than a + half-filled actor with no identity.""" + if oh is None: + return None + person_org = getattr(oh, "OwningUser", None) + person = getattr(person_org, "ThePerson", None) if person_org is not None else None + org = getattr(person_org, "TheOrganization", None) if person_org is not None else None + ident: str | None = None + display: str | None = None + if person is not None: + parts = [p for p in (getattr(person, "GivenName", None), getattr(person, "FamilyName", None)) if p] + display = " ".join(parts) if parts else None + # `IfcPerson.Identification` -- NOT `.Id` (that is ifcopenshell's own STEP line number, + # `entity.id()`, an unrelated concept this actor's identity must never be keyed by). + ident = getattr(person, "Identification", None) or display + if ident is None and org is not None: + ident = getattr(org, "Name", None) + display = display or ident + if ident is None: + return None + application = None + app = getattr(oh, "OwningApplication", None) + if app is not None: + name = getattr(app, "ApplicationFullName", None) + version = getattr(app, "Version", None) + if name: + application = f"{name} {version}".strip() if version else str(name) + return Actor(id=str(ident), display=display, application=application) + + +def _relay_source_actor(ifc_file: Any, project: Any, product: Any) -> Actor | None: + """Decision 6's IFC rule, verbatim: relay ``source_actor`` from the product's own + ``IfcOwnerHistory`` ONLY when the file has more than one owner history, or this product's + history differs from the project's -- otherwise a single file-wide author says nothing about + this one leaf and is left out rather than repeated on every subject.""" + product_oh = getattr(product, "OwnerHistory", None) + if product_oh is None: + return None + project_oh = getattr(project, "OwnerHistory", None) if project is not None else None + histories = ifc_file.by_type("IfcOwnerHistory") + has_multiple = len(histories) > 1 + differs_from_project = project_oh is not None and product_oh.id() != project_oh.id() + if not has_multiple and not differs_from_project: + return None + return _actor_from_owner_history(product_oh) + + def _hierarchy_revision_for(store: IfcAssetStore, collection: str, ancestors: list[str]) -> str | None: """The newest revision of the nearest ancestor (in the STAGED file's real structure, not any one subject's spine -- Decision 3, rule 5) that already has a published subject in this @@ -183,9 +259,50 @@ def publish_ifc( already-published source blob instead of uploading a fresh one, and record ``hierarchy_revision`` against the nearest already-published ancestor. - Core's ``AssetPublisher.derive()`` protocol is the REST publish job's contract (Phase 4, not - wired here); this function is that logic with the staging/scope plumbing left to the caller, - so it is directly callable from a test or a future job handler alike. + A direct, side-effecting entry point: derives the plan (:func:`_derive_ifc_plan`, occupancy + enforced) and writes it here. ``ada.assets.ifc.publisher.IfcAssetPublisher.derive()`` is the + OTHER caller of the same derivation -- see the module docstring's Phase-4 note for why + occupancy is enforced in exactly one of the two places for each. + """ + plan = _derive_ifc_plan( + store, + collection=collection, + staged_key=staged_key, + root=root, + leaf=leaf, + source=source, + extracted_at=extracted_at, + published_at=published_at, + enforce_occupancy=True, + replace=replace, + ) + written: list[str] = [] + _write(store, list(plan.writes), written, dry_run=dry_run) + return PublishResult( + dry_run=dry_run, + collection=plan.collection, + revision=plan.revision, + subjects=plan.subjects, + written=tuple(written), + ) + + +def _derive_ifc_plan( + store: IfcAssetStore, + *, + collection: str, + staged_key: str, + root: str | None = None, + leaf: str | None = None, + source: str | None = None, + extracted_at: str | None = None, + published_at: str | None = None, + enforce_occupancy: bool, + replace: bool = False, +) -> _DerivedIfcPlan: + """The one derivation behind both ``publish_ifc`` and ``IfcAssetPublisher.derive()`` -- + identical logic, only whether occupancy is enforced HERE differs (see the module docstring). + Never writes: the caller decides that. """ if root is not None and leaf is not None: raise IfcPublishError("pass at most one of root= / leaf=, not both") @@ -213,22 +330,26 @@ def publish_ifc( raise IfcPublishError(str(exc)) from exc published_at = published_at or datetime.now(timezone.utc).isoformat() + projects = ifc_store.f.by_type("IfcProject") + project = projects[0] if projects else None + nodes = walk_full(ifc_store.f) by_id = {n.id: n for n in nodes} - written: list[str] = [] subjects: list[str] = [] planned: list[tuple[str, bytes]] = [] + counts: dict[str, int] = {} if root is None and leaf is None: root_ids = declared_site_roots(nodes) if not root_ids: raise IfcPublishError("no IfcSite reachable from IfcProject -- pass root= or leaf= explicitly") - for subject in root_ids: - if not replace and _occupied(store, collection, subject, revision): - raise IfcPublishError( - f"{ASSET_PREFIX}/{collection}/{subject}/{revision}/ is already occupied; pass replace=True" - ) + if enforce_occupancy: + for subject in root_ids: + if not replace and _occupied(store, collection, subject, revision): + raise IfcPublishError( + f"{ASSET_PREFIX}/{collection}/{subject}/{revision}/ is already occupied; pass replace=True" + ) source_key = asset_key(collection, collection, revision, SOURCE_FILENAME) planned.append((source_key, raw)) source_ref = {"key": source_key} @@ -237,6 +358,7 @@ def publish_ifc( _publish_one_subject( store, ifc_file=ifc_store.f, + project=project, subject_nodes=subtree_nodes(nodes, subject), collection=collection, subject=subject, @@ -262,6 +384,7 @@ def publish_ifc( index_bytes = index_slice.to_json() index_hierarchy_key = asset_key(collection, collection, revision, HIERARCHY_FILENAME) planned.append((index_hierarchy_key, index_bytes)) + counts = {"sites": len(root_ids), "nodes": len(index_nodes)} collection_manifest = AssetManifest( provider=IFC_PROVIDER_ID, collection=collection, @@ -277,7 +400,7 @@ def publish_ifc( ), ArtefactEntry(role="source", file=SOURCE_FILENAME, sha256=_sha(raw), size=len(raw)), ), - counts={"sites": len(root_ids), "nodes": len(index_nodes)}, + counts=counts, ) planned.append((asset_key(collection, collection, revision, MANIFEST_FILENAME), collection_manifest.to_json())) @@ -285,7 +408,7 @@ def publish_ifc( target = root if root is not None else leaf if target not in by_id: raise IfcPublishError(f"no node {target!r} reachable from IfcProject in this file") - if not replace and _occupied(store, collection, target, revision): + if enforce_occupancy and not replace and _occupied(store, collection, target, revision): raise IfcPublishError( f"{ASSET_PREFIX}/{collection}/{target}/{revision}/ is already occupied; pass replace=True" ) @@ -302,9 +425,10 @@ def publish_ifc( source_ref = {"key": source_key} hierarchy_revision = _hierarchy_revision_for(store, collection, _ancestor_chain(nodes, target)) - _publish_one_subject( + counts = _publish_one_subject( store, ifc_file=ifc_store.f, + project=project, subject_nodes=subtree_nodes(nodes, target), collection=collection, subject=target, @@ -318,9 +442,12 @@ def publish_ifc( ) subjects.append(target) - _write(store, planned, written, dry_run=dry_run) - return PublishResult( - dry_run=dry_run, collection=collection, revision=revision, subjects=tuple(subjects), written=tuple(written) + return _DerivedIfcPlan( + collection=collection, + revision=revision, + subjects=tuple(subjects), + writes=tuple(planned), + counts=counts, ) @@ -328,6 +455,7 @@ def _publish_one_subject( store: IfcAssetStore, *, ifc_file: Any, + project: Any, subject_nodes: list[IfcNode], collection: str, subject: str, @@ -338,11 +466,12 @@ def _publish_one_subject( source_ref: dict, hierarchy_revision: str | None, planned: list[tuple[str, bytes]], -) -> None: +) -> dict[str, int]: """One subject's own hierarchy.json + ifc.index.json + asset.json, appended to ``planned`` in that order -- the per-subject half of the write-order contract (the manifest is written last because it is the one file whose mere presence a reader treats as "this revision is - complete").""" + complete"). Returns this subject's own ``counts``, for a scoped (``--root``/``--leaf``) + publish's plan to report.""" slice_ = build_hierarchy( provider=IFC_PROVIDER_ID, collection=collection, @@ -364,6 +493,18 @@ def _publish_one_subject( planned.append((index_key, index_bytes)) leaves = sum(1 for n in subject_nodes if n.leaf) + counts = {"nodes": len(subject_nodes), "leaves": leaves} + + # Decision 6's IFC rule: relay `source_actor` from THIS subject's own product only when the + # file has more than one owner history, or this product's differs from the project's -- + # `change` stays absent otherwise (never `published_by`/`published_via`: those are core's, + # and a provider that set them is refused at the publish job, `ada.assets.publish`). + change = None + subject_product = ifc_file.by_guid(subject) if hasattr(ifc_file, "by_guid") else None + relayed = _relay_source_actor(ifc_file, project, subject_product) if subject_product is not None else None + if relayed is not None: + change = ChangeRecord(source_actor=relayed) + manifest = AssetManifest( provider=IFC_PROVIDER_ID, collection=collection, @@ -373,6 +514,7 @@ def _publish_one_subject( produced_at=instant, published_at=published_at, delivery="build", + change=change, hierarchy_revision=hierarchy_revision, build=_build_spec(source_key_ref=source_ref, hierarchy_key=hierarchy_key, node_id=subject), artefacts=( @@ -384,9 +526,10 @@ def _publish_one_subject( role=IFC_INDEX_ROLE, file=IFC_INDEX_FILENAME, sha256=_sha(index_bytes), size=len(index_bytes) ), ), - counts={"nodes": len(subject_nodes), "leaves": leaves}, + counts=counts, ) planned.append((asset_key(collection, subject, revision, MANIFEST_FILENAME), manifest.to_json())) + return counts def _max_depth(nodes: list[IfcNode]) -> int: diff --git a/src/ada/assets/ifc/publisher.py b/src/ada/assets/ifc/publisher.py new file mode 100644 index 000000000..1b809232a --- /dev/null +++ b/src/ada/assets/ifc/publisher.py @@ -0,0 +1,111 @@ +"""``IfcAssetPublisher`` -- the ``AssetPublisher`` (Phase 4) for provider id ``ifc``. + +**A provider PLANS; core WRITES (Decision 2a).** This class wraps ``_derive_ifc_plan`` -- the +SAME derivation ``publish_ifc`` has always used -- with ``enforce_occupancy=False``: core's own +``ada.assets.publish.apply_publish_plan`` decides occupancy and ``replace`` for a job-driven +publish (it lists the scope's storage itself, ``formats/asset_publish.py``'s "Occupancy is read +HERE, not inside the provider"), so this class must not decide it a second, possibly +disagreeing, time. What IS still enforced here -- because it is about the STAGED FILE, not the +store -- is everything ``_derive_ifc_plan`` always checked: root/leaf mutual exclusion, an +unknown node id, a file with no instant. + +**``derive()`` reads through ``storage``, never through the caller's own facade.** The protocol +(``ada.assets.provider.AssetPublisher.derive``) hands this class the SAME synchronous storage +facade a builder gets -- ``get_bytes`` / ``list_keys`` / ``put_bytes`` -- and this class uses only +the read half (:class:`_ReadOnlyIfcStoreAdapter`): writing through it would bypass the owner gate +and the manifests-last ordering, which are the two reasons core does the writing at all. +""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping + +from ada.assets.ifc.publish import ( + IFC_PROVIDER_ID, + SOURCE_FILENAME, + IfcPublishError, + _derive_ifc_plan, +) +from ada.assets.publish import PlannedWrite, PublishPlan + +__all__ = ["IfcAssetPublisher"] + + +class _ReadOnlyIfcStoreAdapter: + """Adapts the ``AssetPublisher`` storage facade (``get_bytes`` / ``list_keys`` / ``put_bytes``) + to the read-only slice ``ada.assets.ifc.publish`` needs (``IfcAssetStore``'s ``list_prefix`` / + ``get_bytes``). ``put_bytes`` is refused outright: ``derive()`` must not write.""" + + def __init__(self, storage: Any): + self._storage = storage + + def list_prefix(self, prefix: str) -> Iterable[str]: + return self._storage.list_keys(prefix) + + def get_bytes(self, key: str) -> bytes: + return self._storage.get_bytes(key) + + def put_bytes(self, key: str, data: bytes) -> None: # pragma: no cover - structural guard + raise RuntimeError( + "IfcAssetPublisher.derive() must not write (Decision 2a: a provider PLANS, core " + "WRITES) -- this adapter's put_bytes should never be called" + ) + + +def _resolve_staged_key(staged: Mapping[str, str]) -> str: + """Which staged blob is the IFC source. ``staged`` maps ROLE (the staged filename) -> key + (``routes/assets.py``'s publish route: everything under ``assets/_staging//`` keyed by + what follows the prefix). The IFC publisher's own role is ``source.ifc`` + (:data:`ada.assets.ifc.publish.SOURCE_FILENAME`); a caller staging exactly one file under any + other name is accepted too, since there is nothing else it could mean, but two or more staged + files with none of them named ``source.ifc`` is refused rather than guessed.""" + if SOURCE_FILENAME in staged: + return staged[SOURCE_FILENAME] + if len(staged) == 1: + return next(iter(staged.values())) + raise IfcPublishError( + f"staged={sorted(staged)}: expected a {SOURCE_FILENAME!r} role, or exactly one staged " + f"file -- the IFC publisher does not guess which staged blob is the source" + ) + + +class IfcAssetPublisher: + """Registered for provider id ``ifc`` (``ada.assets.ifc.__init__``, ``ensure_core_publishers`` + -- see ``ada.assets.publishers``). One instance is reused across publishes; it carries no + per-publish state.""" + + id = IFC_PROVIDER_ID + + def derive( + self, + scope: Any, + staged: Mapping[str, str], + *, + storage: Any, + collection: str | None = None, + options: Mapping[str, Any] | None = None, + dry_run: bool = False, + ) -> PublishPlan: + del scope, dry_run # neither changes what WOULD be written -- core decides whether to write it + if not collection: + raise IfcPublishError("derive() needs a 'collection': the IFC provider has no default one to publish into") + opts = dict(options or {}) + staged_key = _resolve_staged_key(staged) + + plan = _derive_ifc_plan( + _ReadOnlyIfcStoreAdapter(storage), + collection=collection, + staged_key=staged_key, + root=opts.get("root"), + leaf=opts.get("leaf"), + source=opts.get("source"), + extracted_at=opts.get("extracted_at"), + enforce_occupancy=False, + ) + return PublishPlan( + collection=plan.collection, + revision=plan.revision, + subjects=plan.subjects, + writes=tuple(PlannedWrite(key=key, data=data) for key, data in plan.writes), + counts=plan.counts, + ) diff --git a/src/ada/assets/ifc/sweep.py b/src/ada/assets/ifc/sweep.py new file mode 100644 index 000000000..e32d4b19c --- /dev/null +++ b/src/ada/assets/ifc/sweep.py @@ -0,0 +1,306 @@ +"""``asset-sweep-ifc`` -- compare a STAGED newer IFC file against the newest published spine of +the same collection, and say what changed. Decision 4's "Change feed -- what is honest for a +file-backed source": + + "An IFC file has no upstream" holds for a PUBLISHED file: nothing moves underneath it. What + CAN move is a newer file that has been imported but not yet published. So the provider + separates sweep from publish. + +**What this does NOT do.** It never writes to the asset store (that's ``publish_ifc``'s job) and +it never opens a database itself (that's the caller's -- the worker's ``source_nodes`` facade, +``ada.comms.rest.worker.source_nodes``, or the REST route, ``routes.source_nodes``). +:func:`sweep_ifc` only DERIVES rows; :func:`run_ifc_sweep` is the thin entry a worker calls to +hand them to whichever recorder it was given. The same "a provider PLANS" discipline +``ada.assets.publish`` applies to writing a revision applies here to writing a change feed: a +sweep that wrote directly would have to re-implement retries, chunking and the REST-vs-pool +choice, and get it right in every provider that ever sweeps a source. There is no REST sweep +route in this phase (the plan's own Phase 4 scope): a worker runs :func:`run_ifc_sweep` as a job. + +**How coverage is decided.** A "root" this sweep can report on is a PUBLISHED SUBJECT -- one with +its own manifest. An intermediate storey that was never independently published is not a root: it +has no row of its own to be ``current``/``behind``, and its content only matters as part of +whichever published root it sits under (a cousin of Decision 4's "no widen-on-failure" for +builds: coverage cannot be wider than what was actually published). With no ``root=``, every +subject the collection index lists as independently published is covered; with ``root=``, +only that one subject is -- and every OTHER published subject in the collection is, correctly, +untouched by this call. Its absence from the returned rows is exactly what makes a reader see +``not-recorded`` rather than ``current`` for it (the ``--root``-scoped acceptance in the plan). + +**Per-product verdict, then roll-up.** Comparing the staged file's own hash +(``ada.assets.ifc.index.build_ifc_index`` -- the SAME function publish uses, so a sweep and a +publish can never disagree about what "unchanged" means) against the published ``ifc.index.json`` +gives added/modified/deleted per product; every ancestor between a touched product and its +covered root -- AND the root itself -- gets its ``last_changed_at`` pulled forward too (the +roll-up IS the writer's job, ``028_source_nodes.sql``), but WITHOUT an ``action`` of its own: only +the touched product carries one (Decision 7's "adopt partially" -- ``action`` is per-node +evidence, not a compound history; ``NOCHANGE``/``MODIFIEDADDED``/``MODIFIEDDELETED`` are not +values this table stores). + +**The "current" row, and why it is not a NOCHANGE value.** A covered root whose subtree the sweep +found untouched still gets exactly ONE row, re-affirming the PUBLISHED ``produced_at`` as its own +``last_changed_at`` -- never advancing it. That is an administrative "I looked, and as of what is +already published, nothing has moved" stamp: one row per covered root, never one row per +unchanged product. It is what lets a reader tell ``current`` (a row exists, and its +``last_changed_at`` does not exceed what is published) from ``not-recorded`` (no row at all) +without this table ever holding a per-node ``NOCHANGE`` value -- Decision 7 rejects exactly that. +""" + +from __future__ import annotations + +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +from ada.assets.ifc.index import IFC_INDEX_FILENAME, build_ifc_index, parse_ifc_index +from ada.assets.ifc.publish import IfcAssetStore, _instant_from_project +from ada.assets.ifc.walk import subtree_nodes, walk_full +from ada.assets.index import fold_listing +from ada.assets.keys import ASSET_PREFIX, asset_key +from ada.assets.manifest import HIERARCHY_FILENAME, MANIFEST_FILENAME, parse_manifest +from ada.assets.projection import parse_hierarchy +from ada.cadit.ifc.store import IfcStore + +__all__ = [ + "IFC_SOURCE_ID", + "IfcSweepError", + "SweepRow", + "SweepResult", + "run_ifc_sweep", + "sweep_ifc", +] + +# `source_nodes.source` for every row this module writes. It EQUALS the provider id, which is the +# convention that lets the browser ask one question per provider: the tab groups its evidence +# fetches by the producing provider of each subject, and a source id that differed from it would +# need a second mapping nobody owns. +IFC_SOURCE_ID = "ifc" + +_ACTIONS = ("added", "modified", "deleted") + + +class IfcSweepError(ValueError): + """A sweep this module refuses: an unknown ``--root``, or a staged file with no instant.""" + + +@dataclass(frozen=True) +class SweepRow: + """One ``source_nodes`` row this sweep would write. ``action`` is ``None`` for a roll-up + (an ancestor of a touched product, or a covered root's own currency row) -- ONLY a product + this sweep itself found added/modified/deleted carries one (see the module docstring).""" + + node_ref: str + parent_ref: str | None + name: str | None + last_changed_at: str # ISO-8601 + action: str | None = None + + def __post_init__(self) -> None: + if self.action is not None and self.action not in _ACTIONS: + raise IfcSweepError(f"action {self.action!r} not in {_ACTIONS}") + + def to_dict(self) -> dict: + """Shaped exactly for ``record_source_nodes`` / the sync facade's ``record()`` -- + migration 030's optional ``action`` column, omitted (not sent as null) when this row is a + roll-up rather than a verdict.""" + out = {"node_ref": self.node_ref, "parent_ref": self.parent_ref, "name": self.name} + out["last_changed_at"] = self.last_changed_at + if self.action is not None: + out["action"] = self.action + return out + + +@dataclass(frozen=True) +class SweepResult: + collection: str + source: str + covered_roots: tuple[str, ...] + rows: tuple[SweepRow, ...] = field(default_factory=tuple) + + def to_records(self) -> list[dict]: + return [r.to_dict() for r in self.rows] + + +def sweep_ifc( + store: IfcAssetStore, + *, + collection: str, + staged_key: str, + root: str | None = None, + extracted_at: str | None = None, +) -> SweepResult: + """Derive the rows a sweep of ``staged_key`` against ``collection``'s published spine(s) + would write. Pure: reads through ``store`` (the same read-only slice ``publish_ifc`` uses), + writes nothing anywhere. + """ + raw = store.get_bytes(staged_key) + tmp = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + try: + tmp.write(raw) + tmp.close() # closed before reopen-by-path: an open handle + reopen is a Windows trap + ifc_store = IfcStore.from_ifc(Path(tmp.name)) + finally: + Path(tmp.name).unlink(missing_ok=True) + + instant = extracted_at or _instant_from_project(ifc_store) + if instant is None: + raise IfcSweepError( + "this file's IfcProject has no OwnerHistory.CreationDate, and no extracted_at was " + "given -- pass extracted_at= (there is no other source of truth for when this sweep " + "saw the file)" + ) + + staged_nodes = walk_full(ifc_store.f) + staged_by_id = {n.id: n for n in staged_nodes} + + idx = fold_listing(store.list_prefix(f"{ASSET_PREFIX}/{collection}/")) + published_roots = [ + s.subject for s in idx.subjects(collection) if s.subject != collection and s.latest_complete is not None + ] + if root is not None: + if root not in published_roots: + raise IfcSweepError( + f"{root!r} has no published manifest in collection {collection!r} -- nothing to " + f"sweep against (published roots: {sorted(published_roots)})" + ) + targets = [root] + else: + targets = published_roots + + rows: list[SweepRow] = [] + for subject in targets: + rows.extend(_sweep_one_subject(store, ifc_store, staged_nodes, staged_by_id, collection, subject, instant, idx)) + + return SweepResult(collection=collection, source=IFC_SOURCE_ID, covered_roots=tuple(targets), rows=tuple(rows)) + + +def _sweep_one_subject( + store: IfcAssetStore, + ifc_store: IfcStore, + staged_nodes: list, + staged_by_id: dict, + collection: str, + subject: str, + instant: str, + idx: Any, +) -> list[SweepRow]: + revision = idx.subject(collection, subject).latest_complete.revision + manifest = parse_manifest(store.get_bytes(asset_key(collection, subject, revision, MANIFEST_FILENAME))) + spine = parse_hierarchy(store.get_bytes(asset_key(collection, subject, revision, HIERARCHY_FILENAME))) + published_hashes = parse_ifc_index(store.get_bytes(asset_key(collection, subject, revision, IFC_INDEX_FILENAME))) + published_records = {r["id"]: r for r in spine.records()} + published_parent = {node_id: r["parent"] for node_id, r in published_records.items()} + published_ids = set(published_hashes) + + subject_in_staged = subject in staged_by_id + staged_subtree = subtree_nodes(staged_nodes, subject) if subject_in_staged else [] + staged_ids = {n.id for n in staged_subtree} + staged_parent = {n.id: n.parent for n in staged_subtree} + + current_hashes = {e.id: e.hash for e in build_ifc_index(ifc_store.f, sorted(staged_ids))} if staged_ids else {} + + touched: dict[str, str] = {} + for node_id, h in current_hashes.items(): + old = published_hashes.get(node_id) + if old is None: + touched[node_id] = "added" + elif old != h: + touched[node_id] = "modified" + for node_id in published_ids: + if node_id not in staged_ids: + touched[node_id] = "deleted" + if not subject_in_staged and subject not in touched: + # the root itself is gone -- nothing more specific to blame, so it is its own deleted + # entry rather than silently vanishing from the swept output. + touched[subject] = "deleted" + + def _parent_of(node_id: str) -> "str | None": + if node_id in staged_parent: + return staged_parent[node_id] + return published_parent.get(node_id) + + def _name_of(node_id: str) -> "str | None": + n = staged_by_id.get(node_id) + if n is not None: + return n.label + rec = published_records.get(node_id) + return rec["label"] if rec else None + + ancestors_to_bump: set[str] = set() + for node_id in touched: + if node_id == subject: + continue + cur = _parent_of(node_id) + guard = 0 + while cur is not None and guard < 10_000: + ancestors_to_bump.add(cur) + if cur == subject: + break + cur = _parent_of(cur) + guard += 1 + + rows: list[SweepRow] = [] + for node_id, action in touched.items(): + rows.append( + SweepRow( + node_ref=node_id, + parent_ref=_parent_of(node_id), + name=_name_of(node_id), + last_changed_at=instant, + action=action, + ) + ) + for anc_id in ancestors_to_bump - set(touched): + rows.append( + SweepRow( + node_ref=anc_id, + parent_ref=_parent_of(anc_id), + name=_name_of(anc_id), + last_changed_at=instant, + action=None, + ) + ) + + if not touched: + # nothing changed anywhere in this root's subtree: one administrative "still current as + # of what is published" row (see the module docstring) -- never advances last_changed_at. + rows.append( + SweepRow( + node_ref=subject, + parent_ref=published_parent.get(subject), + name=_name_of(subject), + last_changed_at=manifest.produced_at, + action=None, + ) + ) + + return rows + + +def run_ifc_sweep( + *, + storage: Any, + record: Callable[[str, list], int], + collection: str, + staged_key: str, + root: str | None = None, + extracted_at: str | None = None, +) -> int: + """The worker-side entry: sweep, then hand the rows to whichever recorder the caller holds. + + ``storage`` is the same synchronous facade a builder/publisher gets (``get_bytes`` / + ``list_keys``); ``record`` is ``_SyncSourceNodesFacade.record`` / ``_RestSourceNodesRecorder. + record`` (``ada.comms.rest.worker.source_nodes``) -- or ``None``'s absence entirely is how a + caller with no database represents ``no-feed``: this function is simply not called, same as a + plugin that has nothing to record today. + """ + from ada.assets.ifc.publisher import _ReadOnlyIfcStoreAdapter + + result = sweep_ifc( + _ReadOnlyIfcStoreAdapter(storage), + collection=collection, + staged_key=staged_key, + root=root, + extracted_at=extracted_at, + ) + return record(result.source, result.to_records()) diff --git a/src/ada/assets/manifest.py b/src/ada/assets/manifest.py index d19ecf5af..5b24e19f9 100644 --- a/src/ada/assets/manifest.py +++ b/src/ada/assets/manifest.py @@ -337,7 +337,7 @@ def manifest_summary(m: AssetManifest) -> dict: Deliberately NOT the whole manifest: ``build.options`` is opaque provider data, and the index is fetched on every refresh. A field lands here only when a badge or a flag reads it. """ - return _drop_none( + out = _drop_none( { "provider": m.provider, "node": m.node, @@ -346,3 +346,21 @@ def manifest_summary(m: AssetManifest) -> dict: "hierarchy_revision": m.hierarchy_revision, } ) + # `change` rides along because the alternative is N browser fetches for a "changed by" + # filter (Decision 6): manifests are immutable at their key barring `replace`, so reading + # them server-side is the cheap side. ABSENT stays absent -- a provider whose source carries + # no authorship is first class, and an empty object here would make the tab offer a filter + # over nothing. + if m.change is not None: + change = _drop_none( + { + "published_by": _actor_to_dict(m.change.published_by), + "published_via": m.change.published_via, + "source_actor": _actor_to_dict(m.change.source_actor), + "action": m.change.action, + "source_instant": m.change.source_instant, + } + ) + if change: + out["change"] = change + return out diff --git a/src/ada/assets/provider.py b/src/ada/assets/provider.py index 2d9bc40bc..e3e24eed8 100644 --- a/src/ada/assets/provider.py +++ b/src/ada/assets/provider.py @@ -86,13 +86,31 @@ def delivery( class AssetPublisher(Protocol): """Optional: providers that accept a publish INTO this scope. - ``derive`` receives the staged blob keys and returns a plan. It must NOT set - ``change.published_by`` -- that is core's to stamp from the authenticated caller, and a - provider that tries is refused by name at the publish job. + ``derive`` PLANS; core writes (``ada.assets.publish``). It receives the staged blob keys, a + read-only view of the scope's storage (the staged bytes are IN the store -- an upload that + survives a reload is the point of staging, so a plan is derived by READING them, not by being + handed a file), the caller's opaque publish options, and returns a ``PublishPlan``: every blob + this publish would write, in order, with each manifest's artefacts and counts filled in. + + ``storage`` is the same synchronous facade a builder gets (``get_bytes`` / ``put_bytes`` / + ``list_keys``). A publisher uses the READ half; writing is core's, and a publisher that wrote + through it would bypass both the owner gate and the manifests-last ordering. + + It must NOT set ``change.published_by`` or ``change.published_via`` -- those record who called + CORE, which no provider can observe, and one that tries is refused by name at the publish job + (Decision 6's owner gate). Relaying what the SOURCE says about authorship is the provider's to + do, in ``change.source_actor`` / ``action`` / ``source_instant``. """ def derive( - self, scope: Any, staged: Mapping[str, str], *, collection: str | None = None, dry_run: bool = False + self, + scope: Any, + staged: Mapping[str, str], + *, + storage: Any, + collection: str | None = None, + options: Mapping[str, Any] | None = None, + dry_run: bool = False, ) -> Any: ... diff --git a/src/ada/assets/publish.py b/src/ada/assets/publish.py new file mode 100644 index 000000000..a681b65db --- /dev/null +++ b/src/ada/assets/publish.py @@ -0,0 +1,229 @@ +"""Publishing into the store: a provider PLANS, core WRITES. + +The split is the whole design of this module, and it is what makes two rules structural instead +of procedural: + +1. **The owner gate (Decision 6).** ``change.published_by`` says who pushed this revision into + the scope. A provider cannot be trusted to state that -- it is core's authenticated caller -- + so a provider that sets it is refused BY NAME here, and core stamps the field itself into + every manifest of the publish. A provider can still RELAY what its source says + (``source_actor``, ``action``, ``source_instant``), which is a different claim and is labelled + as one. +2. **Manifests last (Decision 2a).** A half-written publish must be invisible rather than + discoverable-and-broken. If each provider wrote its own blobs, that ordering would be a rule + every provider had to re-implement; here the plan is an ORDERED list and core does the writing, + so a provider gets the discipline whether or not it thought about it. + +Core edits only the documents it owns the schema of -- ``asset.json`` -- and never looks inside +the provider's own artefacts, which travel as opaque bytes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any, Iterable, Mapping, Sequence + +from ada.assets.keys import ( + ASSET_PREFIX, + STAGING_SEGMENT, + AssetKeyError, + parse_asset_key, +) +from ada.assets.manifest import ( + MANIFEST_FILENAME, + Actor, + AssetManifest, + ChangeRecord, + ManifestError, + parse_manifest, +) + +__all__ = [ + "PlannedWrite", + "PublishError", + "PublishPlan", + "PublishOutcome", + "apply_publish_plan", + "staged_prefix", + "stamp_publish", +] + + +class PublishError(ValueError): + """A plan core refuses to write, naming what is wrong with it.""" + + +@dataclass(frozen=True) +class PlannedWrite: + """One blob a publish would write. ``data`` is opaque to core unless the key names a manifest.""" + + key: str + data: bytes + + +@dataclass(frozen=True) +class PublishPlan: + """What a provider's ``derive()`` returns: everything this publish would write, IN ORDER. + + Order is part of the contract, not a detail: within a subject its artefacts precede its + manifest, and across subjects a collection-level manifest comes last. A provider that emits + them in another order gets that order preserved -- so this is checked, not assumed + (:func:`apply_publish_plan`). + """ + + collection: str + revision: str + subjects: tuple[str, ...] + writes: tuple[PlannedWrite, ...] + counts: Mapping[str, int] = field(default_factory=dict) + #: What the provider RELAYS about the change from its own source. Never `published_by`. + change: ChangeRecord | None = None + + +@dataclass(frozen=True) +class PublishOutcome: + """What actually happened (or, under ``dry_run``, what would have).""" + + collection: str + revision: str + subjects: tuple[str, ...] + written: tuple[str, ...] + dry_run: bool + replaced: tuple[str, ...] = () + counts: Mapping[str, int] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "collection": self.collection, + "revision": self.revision, + "subjects": list(self.subjects), + "written": list(self.written), + # Echoed back deliberately: a caller that asked for a dry run and got a summary + # without the flag cannot tell a plan from a publish, and the difference is every + # byte in the scope. + "dry_run": self.dry_run, + "replaced": list(self.replaced), + "counts": dict(self.counts), + } + + +def staged_prefix(staging_id: str) -> str: + return f"{ASSET_PREFIX}/{STAGING_SEGMENT}/{staging_id}/" + + +def stamp_publish( + manifest: AssetManifest, + *, + published_by: Actor, + published_via: str, +) -> AssetManifest: + """Put core's authorship on a manifest, keeping whatever the provider relayed. + + THE REFUSAL IS THE POINT. A provider that set ``published_by`` is not corrected quietly: a + publish that records the wrong author is worse than one that fails, because the record is + what a later reader trusts. The message names the field so the provider's author can find it. + """ + provider_change = manifest.change + if provider_change is not None and provider_change.published_by is not None: + raise PublishError( + "a provider may not set change.published_by: that field records WHO PUSHED this " + "revision into the scope, which only core's authenticated caller can say. Relay what " + "the source says in change.source_actor instead." + ) + if provider_change is not None and provider_change.published_via is not None: + raise PublishError( + "a provider may not set change.published_via: it names how CORE was called " + "(user or service), not anything the provider can observe." + ) + change = provider_change or ChangeRecord() + return replace( + manifest, + change=replace(change, published_by=published_by, published_via=published_via), + ) + + +def _is_manifest(key: str) -> bool: + return key.rsplit("/", 1)[-1] == MANIFEST_FILENAME + + +def _check_order(writes: Sequence[PlannedWrite]) -> None: + """Every manifest must come after the artefacts of its own subject-revision.""" + seen_manifest: set[tuple[str, str]] = set() + for write in writes: + try: + parsed = parse_asset_key(write.key) + except AssetKeyError as exc: + raise PublishError(f"{write.key}: {exc}") from exc + ident = (parsed.subject, parsed.revision) + if _is_manifest(write.key): + seen_manifest.add(ident) + elif ident in seen_manifest: + raise PublishError( + f"{write.key} is written AFTER the manifest of {parsed.subject}@{parsed.revision}. " + f"Manifests are written last so a half-written publish is invisible rather than " + f"discoverable-and-broken." + ) + + +def apply_publish_plan( + plan: PublishPlan, + *, + published_by: Actor, + published_via: str, + dry_run: bool, + replace_existing: bool, + occupied: "Iterable[str] | None" = None, + write: "Any" = None, +) -> PublishOutcome: + """Validate a plan, stamp its manifests, and (unless ``dry_run``) write it in order. + + ``occupied`` is the set of keys already present under the subject-revisions this plan touches; + ``write(key, data)`` is how a key is stored. Both are injected so this stays a pure decision + function with one side effect the caller supplies -- it is driven in tests against a dict. + """ + if not plan.writes: + raise PublishError("a publish plan with no writes is not a publish") + _check_order(plan.writes) + + occupied_set = set(occupied or ()) + clash = sorted(k for k in occupied_set if any(k == w.key for w in plan.writes)) + # An occupied prefix is refused rather than merged: a revision is immutable by construction + # (Decision 3), so two publishes landing on one revision means one of them is wrong about + # what it published. `replace` is the operator saying which. + if clash and not replace_existing: + raise PublishError( + f"{len(clash)} key(s) already exist at this revision (first: {clash[0]}). " + f"A revision is immutable; re-publish with replace=true to overwrite it deliberately." + ) + + stamped: list[PlannedWrite] = [] + for planned in plan.writes: + if not _is_manifest(planned.key): + stamped.append(planned) # opaque to core -- a provider artefact, passed through + continue + try: + manifest = parse_manifest(planned.data) + except ManifestError as exc: + raise PublishError(f"{planned.key}: {exc}") from exc + stamped.append( + PlannedWrite( + key=planned.key, + data=stamp_publish(manifest, published_by=published_by, published_via=published_via).to_json(), + ) + ) + + if not dry_run: + if write is None: + raise PublishError("a real publish needs a write callable") + for planned in stamped: + write(planned.key, planned.data) + + return PublishOutcome( + collection=plan.collection, + revision=plan.revision, + subjects=plan.subjects, + written=tuple(w.key for w in stamped), + dry_run=dry_run, + replaced=tuple(clash), + counts=plan.counts, + ) diff --git a/src/ada/assets/publishers.py b/src/ada/assets/publishers.py new file mode 100644 index 000000000..8e9994c5e --- /dev/null +++ b/src/ada/assets/publishers.py @@ -0,0 +1,105 @@ +"""Which publisher accepts a publish for a provider id. + +Same shape and the same conflict rule as ``ada.assets.builders`` (Decision 20: origin, not +identity). Separate from the TREE provider registry on purpose: publishing is an optional +capability (Decision 1 -- "presence IS the declaration"), and a provider that only publishes +never needs to answer a hierarchy request, because it rides ``PublishedAssetProvider``. + +Unlike a builder, a publisher is resolved by PROVIDER ID rather than by a capability token: a +publish names the provider whose format the staged bytes are in, and that provider's derivation +is the one thing no other provider can do. +""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from typing import Callable + +from ada.assets.provider import AssetPublisher +from ada.config import logger + +__all__ = [ + "AssetPublisherError", + "asset_publisher", + "clear_asset_publishers", + "ensure_core_publishers", + "register_asset_publisher", + "registered_publisher_ids", +] + + +class AssetPublisherError(LookupError): + """No publisher for a provider id, or two different ones claiming it.""" + + +@dataclass(frozen=True) +class _Entry: + factory: Callable[[], AssetPublisher] + origin: str + label: str | None + + +_PUBLISHERS: dict[str, _Entry] = {} +_CORE_LOADED = False + + +def _origin_of(factory: Callable[[], AssetPublisher]) -> str: + module = getattr(factory, "__module__", "?") + qualname = getattr(factory, "__qualname__", getattr(factory, "__name__", "?")) + return f"{module}:{qualname}" + + +def register_asset_publisher( + provider_id: str, + factory: Callable[[], AssetPublisher], + *, + label: str | None = None, +) -> None: + if not isinstance(provider_id, str) or not provider_id.strip(): + raise AssetPublisherError("a provider id must be a non-empty string") + origin = _origin_of(factory) + existing = _PUBLISHERS.get(provider_id) + if existing is not None and existing.origin != origin: + raise AssetPublisherError( + f"provider {provider_id!r} already has a publisher registered by {existing.origin}; " + f"{origin} may not take it over" + ) + _PUBLISHERS[provider_id] = _Entry(factory=factory, origin=origin, label=label) + + +def asset_publisher(provider_id: str) -> AssetPublisher: + ensure_core_publishers() + entry = _PUBLISHERS.get(provider_id) + if entry is None: + known = ", ".join(sorted(_PUBLISHERS)) or "none" + raise AssetPublisherError( + f"provider {provider_id!r} accepts no publish in this process (known: {known}). " + f"A publish needs the provider that understands the staged format." + ) + return entry.factory() + + +def registered_publisher_ids() -> list[str]: + ensure_core_publishers() + return sorted(_PUBLISHERS) + + +def clear_asset_publishers() -> None: + """Test hook. Core publishers re-register on the next call that needs them.""" + global _CORE_LOADED + _PUBLISHERS.clear() + _CORE_LOADED = False + + +def ensure_core_publishers() -> None: + """Register the publishers core ships. Lazy for the same reason as the builders: the IFC one + pulls ifcopenshell, and a process that never publishes IFC must not pay for it.""" + global _CORE_LOADED + if _CORE_LOADED: + return + _CORE_LOADED = True # set first: a failing import must not retry on every call + try: + importlib.import_module("ada.assets.ifc") + except Exception as exc: # pragma: no cover - a broken core import is a bug, not a config + logger.warning("core asset publishers unavailable: %s", exc) diff --git a/src/ada/assets/unpublish.py b/src/ada/assets/unpublish.py new file mode 100644 index 000000000..b8f812e43 --- /dev/null +++ b/src/ada/assets/unpublish.py @@ -0,0 +1,142 @@ +"""Unpublishing a subject-revision, with the refcount check core owes its publishers. + +THE HAZARD, AND WHY IT IS CORE'S NOW. One publish writes its source blob ONCE and every manifest +of that publish -- and of later leaf publishes derived from it -- references that blob by absolute +key (Decision 3, "leaf without stem"). So deleting a revision can delete the source another +revision still names, leaving a manifest that points at nothing: an asset that lists, resolves and +badges exactly like a working one and fails only at load, minutes later, phrased as a storage +error. The prior art documented this as "the publisher's obligation"; an obligation every +publisher must remember is one that will eventually be forgotten, so the check moves here and the +route refuses rather than the publisher remembering. + +WHAT IS AND IS NOT CASCADED. Derived builds under ``_derived/assets/...`` are NOT deleted: they +are keyed by the source's identity and simply become unreachable, not wrong (Decision 8 of +2026-09-19), and cascading would make an unpublish a recursive delete over a keyspace the caller +never named. An admin sweep collects them by prefix. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable, Mapping + +from ada.assets.keys import ASSET_PREFIX, AssetKeyError, parse_asset_key +from ada.assets.manifest import MANIFEST_FILENAME, ManifestError, parse_manifest + +__all__ = ["UnpublishPlan", "plan_unpublish"] + + +@dataclass(frozen=True) +class UnpublishPlan: + """What an unpublish would do. ``kept`` with a reason is a refusal, not a partial success.""" + + collection: str + subject: str + revision: str + deleted: tuple[str, ...] = () + kept: tuple[str, ...] = () + reason: str | None = None + #: Subject-revisions whose manifests still name a key this unpublish would have deleted. + held_by: tuple[str, ...] = () + unreadable: tuple[str, ...] = field(default_factory=tuple) + + @property + def refused(self) -> bool: + return self.reason is not None + + def to_dict(self) -> dict: + return { + "collection": self.collection, + "subject": self.subject, + "revision": self.revision, + "deleted": list(self.deleted), + "kept": list(self.kept), + "reason": self.reason, + "held_by": list(self.held_by), + "unreadable": list(self.unreadable), + } + + +def plan_unpublish( + *, + collection: str, + subject: str, + revision: str, + collection_keys: Iterable[str], + manifest_bytes: Mapping[str, bytes], +) -> UnpublishPlan: + """Decide what may be deleted, from the collection's own listing and its surviving manifests. + + ``collection_keys`` is every key under ``assets//``; ``manifest_bytes`` maps each + SURVIVING manifest key (i.e. excluding the subject-revision being removed) to its bytes. Pure: + the route does the listing and the reads, this decides. + + A manifest core cannot read is reported in ``unreadable`` and treated as HOLDING everything + it might have named. That is deliberately the cautious direction -- refusing a delete that + would have been fine is recoverable, deleting a blob a manifest still needs is not. + """ + target_prefix = f"{ASSET_PREFIX}/{collection}/{subject}/{revision}/" + target_keys = sorted(k for k in collection_keys if k.startswith(target_prefix)) + if not target_keys: + return UnpublishPlan( + collection=collection, + subject=subject, + revision=revision, + reason=f"nothing published at {collection}/{subject}@{revision}", + ) + + referenced: dict[str, list[str]] = {} + unreadable: list[str] = [] + for key, raw in manifest_bytes.items(): + if key.startswith(target_prefix): + continue # the set being removed cannot hold itself alive + try: + manifest = parse_manifest(raw) + except ManifestError: + unreadable.append(key) + continue + try: + parsed = parse_asset_key(key) + holder = f"{parsed.subject}@{parsed.revision}" + except AssetKeyError: + holder = key + for artefact in manifest.artefacts: + if artefact.key: + referenced.setdefault(artefact.key, []).append(holder) + + held = {k: sorted(set(v)) for k, v in referenced.items() if k in target_keys} + if held or unreadable: + first_key = sorted(held)[0] if held else None + holders = sorted({h for hs in held.values() for h in hs}) + if held: + reason = ( + f"{len(held)} blob(s) at this revision are still named by {len(holders)} other " + f"manifest(s) (e.g. {first_key} held by {holders[0]}). Unpublish those first: a " + f"manifest pointing at a deleted blob fails at load, not here." + ) + else: + reason = ( + f"{len(unreadable)} manifest(s) in this collection could not be read, so whether " + f"they name a blob at this revision is unknown (e.g. {unreadable[0]}). Refusing " + f"rather than guessing." + ) + return UnpublishPlan( + collection=collection, + subject=subject, + revision=revision, + kept=tuple(target_keys), + reason=reason, + held_by=tuple(holders), + unreadable=tuple(sorted(unreadable)), + ) + + # Manifest first in the DELETE order, mirroring manifests-last on the way in: while a partial + # delete is in flight, the set must look unpublished rather than published-and-incomplete. + manifest_key = f"{target_prefix}{MANIFEST_FILENAME}" + ordered = ([manifest_key] if manifest_key in target_keys else []) + [k for k in target_keys if k != manifest_key] + return UnpublishPlan( + collection=collection, + subject=subject, + revision=revision, + deleted=tuple(ordered), + ) diff --git a/src/ada/comms/rest/db/source_nodes.py b/src/ada/comms/rest/db/source_nodes.py index 495c50a61..c5a0276f0 100644 --- a/src/ada/comms/rest/db/source_nodes.py +++ b/src/ada/comms/rest/db/source_nodes.py @@ -23,6 +23,11 @@ class SourceNode: last_changed_at: datetime.datetime last_changed_by: Optional[str] observed_at: datetime.datetime + # What the writer's sweep found for this node -- 'added' | 'modified' | 'deleted', or None for + # a writer that has no opinion (migration 030; every row written before it exists reads back + # with action=None, which is a complete row, not a degraded one -- same discipline as + # ChangeRecord.action on a manifest). + action: Optional[str] = None def _source_node_row(r) -> SourceNode: @@ -33,6 +38,7 @@ def _source_node_row(r) -> SourceNode: last_changed_at=r["last_changed_at"], last_changed_by=r["last_changed_by"], observed_at=r["observed_at"], + action=r["action"], ) @@ -46,7 +52,10 @@ async def record_source_nodes( """Upsert observed nodes. Returns how many rows were written. ``nodes`` is a list of dicts with ``node_ref`` and ``last_changed_at`` - required, and ``parent_ref`` / ``name`` / ``last_changed_by`` optional. + required, and ``parent_ref`` / ``name`` / ``last_changed_by`` / ``action`` + optional. ``action`` is per-node change evidence -- ``'added' | 'modified' | + 'deleted'`` -- borrowed from ``IfcChangeActionEnum`` (migration 030); a + writer with no opinion simply omits it and the column stays NULL. LAST_CHANGED_AT ONLY EVER MOVES FORWARD. A writer re-observing a node it has seen before may hold an older cursor than the row does -- a backfill, a @@ -54,6 +63,9 @@ async def record_source_nodes( that overwrite a newer timestamp would mark current assets stale-free when they are not. `observed_at` is always taken from the new write, because "when was this last confirmed" is precisely the thing that must not be sticky. + ``action`` follows the same rule as ``last_changed_by``: it only moves when + the incoming observation is at least as new as what is already recorded, so + a stale re-run cannot overwrite fresher evidence with older evidence. Written as one executemany inside a transaction: an hourly sweep stamps a changed leaf and every node above it, so the natural batch is thousands of @@ -70,6 +82,7 @@ async def record_source_nodes( n.get("name"), n["last_changed_at"], n.get("last_changed_by"), + n.get("action"), ) for n in nodes ] @@ -78,8 +91,8 @@ async def record_source_nodes( await conn.executemany( """ INSERT INTO source_nodes - (scope, source, node_ref, parent_ref, name, last_changed_at, last_changed_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (scope, source, node_ref, parent_ref, name, last_changed_at, last_changed_by, action) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (scope, source, node_ref) DO UPDATE SET parent_ref = COALESCE(EXCLUDED.parent_ref, source_nodes.parent_ref), name = COALESCE(EXCLUDED.name, source_nodes.name), @@ -87,6 +100,9 @@ async def record_source_nodes( last_changed_by = CASE WHEN EXCLUDED.last_changed_at >= source_nodes.last_changed_at THEN EXCLUDED.last_changed_by ELSE source_nodes.last_changed_by END, + action = CASE + WHEN EXCLUDED.last_changed_at >= source_nodes.last_changed_at + THEN COALESCE(EXCLUDED.action, source_nodes.action) ELSE source_nodes.action END, observed_at = NOW() """, rows, @@ -98,7 +114,7 @@ async def get_source_node(pool: asyncpg.Pool, *, scope: str, source: str, node_r """One node, or None. The staleness check: compare `last_changed_at` against whatever timestamp the caller's copy was made at.""" r = await pool.fetchrow( - "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at " + "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at, action " "FROM source_nodes WHERE scope = $1 AND source = $2 AND node_ref = $3", scope, source, @@ -117,7 +133,7 @@ async def get_source_nodes(pool: asyncpg.Pool, *, scope: str, source: str, node_ if not node_refs: return [] rows = await pool.fetch( - "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at " + "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at, action " "FROM source_nodes WHERE scope = $1 AND source = $2 AND node_ref = ANY($3::text[])", scope, source, @@ -142,7 +158,7 @@ async def list_source_nodes_changed_since( """ if since is None: rows = await pool.fetch( - "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at " + "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at, action " "FROM source_nodes WHERE scope = $1 AND source = $2 " "ORDER BY last_changed_at DESC LIMIT $3", scope, @@ -151,7 +167,7 @@ async def list_source_nodes_changed_since( ) else: rows = await pool.fetch( - "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at " + "SELECT node_ref, parent_ref, name, last_changed_at, last_changed_by, observed_at, action " "FROM source_nodes WHERE scope = $1 AND source = $2 AND last_changed_at > $3 " "ORDER BY last_changed_at DESC LIMIT $4", scope, diff --git a/src/ada/comms/rest/formats/__init__.py b/src/ada/comms/rest/formats/__init__.py index 219a19c3d..3424b153f 100644 --- a/src/ada/comms/rest/formats/__init__.py +++ b/src/ada/comms/rest/formats/__init__.py @@ -8,6 +8,7 @@ from . import ( asset_build, + asset_publish, component, convert, engine_build, @@ -36,6 +37,7 @@ # Synthetic (sourceless) kinds — dispatched before any source download. register(component.ComponentBuildHandler()) register(asset_build.AssetBuildHandler()) +register(asset_publish.AssetPublishHandler()) register(procedural_build.ProceduralBuildHandler()) register(plugin.PluginJobHandler()) register(procedural_detail.ProceduralDetailHandler()) diff --git a/src/ada/comms/rest/formats/asset_publish.py b/src/ada/comms/rest/formats/asset_publish.py new file mode 100644 index 000000000..530288b22 --- /dev/null +++ b/src/ada/comms/rest/formats/asset_publish.py @@ -0,0 +1,180 @@ +"""Publish staged blobs into the asset store (``asset_publish``). + +Synthetic, like ``asset_build``: the staged bytes are already in the scope, and the job carries +the provider id, the staged keys and the caller's opaque options. + +WHY A JOB AND NOT JUST A ROUTE. A derivation opens the staged file with the provider's own reader +-- an IFC file is parsed, walked and projected -- which is seconds to minutes of CPU on a file +that can be hundreds of megabytes. Doing that inside the request would hold a worker thread of +the API for the duration and give the caller a timeout instead of a progress bar. + +THE WRITE IS CORE'S, NOT THE PROVIDER'S. ``derive()`` returns a plan; this handler stamps +authorship into every manifest (the owner gate -- a provider that set ``published_by`` is refused +by name), checks occupancy, and writes in the plan's order so manifests land last. See +``ada.assets.publish``. +""" + +from __future__ import annotations + +import asyncio +import json +import traceback as tb_module + +import asyncpg + +from ada.assets.keys import ASSET_PREFIX +from ada.assets.manifest import Actor +from ada.assets.publish import PublishError, PublishPlan, apply_publish_plan +from ada.assets.publishers import AssetPublisherError, asset_publisher +from ada.config import logger + +from ..queue import JOB_STATUS_DONE, JOB_STATUS_ERROR, Job, JobQueue +from ..storage import Storage +from ..worker.audit import _audit_done +from ..worker.source_nodes import _SyncStorageFacade +from .registry import JobContext, SyntheticFormatHandler + +ASSET_PUBLISH_KIND = "asset_publish" + + +async def _run_asset_publish( + *, + job: Job, + scope, + storage: "Storage", + queue: "JobQueue", + db_pool: "asyncpg.Pool | None", + started_at: float, +) -> None: + job_id = job.job_id + opts = job.conversion_options or {} + provider_id = opts.get("provider") + staged = opts.get("staged") or {} + if not provider_id or not staged: + msg = f"conversion_options needs 'provider' and 'staged' for an {ASSET_PUBLISH_KIND} job" + await queue.update(job_id, status=JOB_STATUS_ERROR, stage="publish", error=msg) + await _audit_done(db_pool, job_id, "error", msg, started_at) + return + + try: + publisher = asset_publisher(provider_id) + except AssetPublisherError as exc: + await queue.update(job_id, status=JOB_STATUS_ERROR, stage="publish", error=str(exc)) + await _audit_done(db_pool, job_id, "error", str(exc), started_at) + return + + dry_run = bool(opts.get("dry_run")) + replace_existing = bool(opts.get("replace")) + collection = opts.get("collection") + published_by = Actor( + id=str(opts.get("published_by_id") or "unknown"), + display=opts.get("published_by_display"), + application=opts.get("published_by_application"), + ) + published_via = str(opts.get("published_via") or "user") + + loop = asyncio.get_running_loop() + sync_storage = _SyncStorageFacade(storage, scope, loop) + + def _derive() -> PublishPlan: + return publisher.derive( + scope, + dict(staged), + storage=sync_storage, + collection=collection, + options=dict(opts.get("options") or {}), + dry_run=dry_run, + ) + + try: + await queue.update(job_id, stage="derive", progress=0.10) + plan = await loop.run_in_executor(None, _derive) + except Exception as exc: + logger.exception("worker: asset_publish derive failed for job %s", job_id) + await queue.update(job_id, status=JOB_STATUS_ERROR, stage="derive", error=str(exc)) + await _audit_done(db_pool, job_id, "error", str(exc), started_at, traceback=tb_module.format_exc()) + return + + # Occupancy is read HERE, not inside the provider: "is this revision already published" is a + # question about the store, which core owns, and answering it per provider would give as many + # answers as there are providers. + try: + touched = {k.rsplit("/", 1)[0] for k in (w.key for w in plan.writes)} + occupied: set[str] = set() + for prefix in sorted(touched): + if not prefix.startswith(f"{ASSET_PREFIX}/"): + continue + entries = await storage.list_prefix(scope, f"{prefix}/") + occupied.update(e.key for e in entries) + except Exception as exc: + logger.exception("worker: asset_publish occupancy check failed for job %s", job_id) + await queue.update(job_id, status=JOB_STATUS_ERROR, stage="publish", error=str(exc)) + await _audit_done(db_pool, job_id, "error", str(exc), started_at) + return + + def _write(key: str, data: bytes) -> None: + sync_storage.put_bytes(key, data) + + try: + await queue.update(job_id, stage="publish", progress=0.60) + outcome = await loop.run_in_executor( + None, + lambda: apply_publish_plan( + plan, + published_by=published_by, + published_via=published_via, + dry_run=dry_run, + replace_existing=replace_existing, + occupied=occupied, + write=_write, + ), + ) + except PublishError as exc: + # The refusals core makes on purpose: the owner gate, an occupied revision, a plan whose + # order would leave a manifest describing blobs that are not there yet. + logger.error("worker: asset_publish %s refused: %s", job_id, exc) + await queue.update(job_id, status=JOB_STATUS_ERROR, stage="publish", error=str(exc)) + await _audit_done(db_pool, job_id, "error", str(exc), started_at) + return + except Exception as exc: + logger.exception("worker: asset_publish write failed for job %s", job_id) + await queue.update(job_id, status=JOB_STATUS_ERROR, stage="publish", error=str(exc)) + await _audit_done(db_pool, job_id, "error", str(exc), started_at, traceback=tb_module.format_exc()) + return + + try: + await queue.update(job_id, stage="upload", progress=0.95) + await storage.put_bytes( + scope, job.derived_key, json.dumps(outcome.to_dict()).encode("utf-8"), content_encoding="gzip" + ) + except Exception as exc: + logger.exception("worker: asset_publish summary upload failed for job %s", job_id) + await queue.update(job_id, status=JOB_STATUS_ERROR, stage="upload", error=str(exc)) + await _audit_done(db_pool, job_id, "error", str(exc), started_at) + return + + await queue.update(job_id, status=JOB_STATUS_DONE, stage="ready", progress=1.0, error=None) + logger.info( + "worker: asset_publish %s -> %s@%s (%d subject(s)%s) job %s", + provider_id, + outcome.collection, + outcome.revision, + len(outcome.subjects), + ", dry run" if outcome.dry_run else "", + job_id, + ) + await _audit_done(db_pool, job_id, "done", None, started_at) + + +class AssetPublishHandler(SyntheticFormatHandler): + kind = ASSET_PUBLISH_KIND + + async def run(self, job: Job, ctx: JobContext) -> None: + await _run_asset_publish( + job=job, + scope=ctx.scope, + storage=ctx.storage, + queue=ctx.queue, + db_pool=ctx.db_pool, + started_at=ctx.started_at, + ) diff --git a/src/ada/comms/rest/job_transport.py b/src/ada/comms/rest/job_transport.py index 6a16477c3..dc9d4ce25 100644 --- a/src/ada/comms/rest/job_transport.py +++ b/src/ada/comms/rest/job_transport.py @@ -60,6 +60,7 @@ #: itself (:mod:`local_jobs`). TransportFeature = Literal[ "asset_build", + "asset_publish", "bake", "bbox_inference", "component_build", @@ -86,6 +87,7 @@ "conversion": "conversion disabled (no NATS configured)", "job_status_report": "no job queue configured", "asset_build": "asset builds disabled (no NATS configured)", + "asset_publish": "asset publishing disabled (no NATS configured)", "plugin_jobs": "plugin jobs disabled (no NATS configured)", "procedural_build": "procedural build disabled (no NATS configured)", "procedural_export": "procedural export disabled (no NATS configured)", @@ -99,7 +101,7 @@ #: What :class:`LocalJobTransport` can run. Everything else it reports as #: unavailable — see the module docstring on why that is a statement rather #: than an omission. -LOCAL_FEATURES: frozenset[str] = frozenset({"asset_build", "plugin_jobs"}) +LOCAL_FEATURES: frozenset[str] = frozenset({"asset_build", "asset_publish", "plugin_jobs"}) @dataclass(frozen=True) @@ -366,6 +368,8 @@ async def submit(self, req: JobRequest, *, before_dispatch: BeforeDispatch | Non self.unavailable(req.feature) if req.feature == "asset_build": return self._submit_asset_build(req) + if req.feature == "asset_publish": + return self._submit_asset_publish(req) if not req.plugin_id: raise HTTPException(status_code=500, detail="a local job needs a plugin_id") try: @@ -436,6 +440,38 @@ def _submit_asset_build(self, req: JobRequest) -> SubmittedJob: payload=job.as_json(), ) + def _submit_asset_publish(self, req: JobRequest) -> SubmittedJob: + """The publish kind's local engine -- same argument as the build's: a single-node viewer + that can stage a file into its own scope must be able to publish it, or `publish` is a + verb that only exists in a cluster.""" + opts = req.conversion_options or {} + try: + job = local_jobs.start_asset_publish( + provider_id=str(opts.get("provider") or ""), + staged=dict(opts.get("staged") or {}), + collection=opts.get("collection"), + options=dict(opts.get("options") or {}), + published_by=str(opts.get("published_by_id") or "unknown"), + published_by_display=opts.get("published_by_display"), + published_via=str(opts.get("published_via") or "user"), + dry_run=bool(opts.get("dry_run")), + replace_existing=bool(opts.get("replace")), + derived_key=req.derived_key or "", + storage=self._storage, + scope=req.scope, + ) + except LookupError as exc: + raise HTTPException(status_code=501, detail=str(exc)) from exc + return SubmittedJob( + job_id=job.job_id, + derived_key=job.derived_key, + status=job.status, + stage=job.stage, + progress=job.progress, + target_capability=None, + payload=job.as_json(), + ) + def inprocess(self, job_id: str) -> JobSnapshot | None: job = local_jobs.registry.get(job_id) if job is None: diff --git a/src/ada/comms/rest/local_jobs.py b/src/ada/comms/rest/local_jobs.py index 0e58a6fe7..7086e21ae 100644 --- a/src/ada/comms/rest/local_jobs.py +++ b/src/ada/comms/rest/local_jobs.py @@ -424,3 +424,94 @@ def _run() -> None: threading.Thread(target=_run, name=f"local-asset-build-{capability}", daemon=True).start() return job + + +def start_asset_publish( + *, + provider_id: str, + staged: dict[str, str], + collection: "str | None", + options: dict[str, Any], + published_by: str, + published_by_display: "str | None", + published_via: str, + dry_run: bool, + replace_existing: bool, + derived_key: str, + storage: Any, + scope: Any, +) -> LocalJob: + """Run an ``asset_publish`` in a thread. The queue-less half of the publish surface. + + The provider PLANS and core writes here too (``apply_publish_plan``), so the owner gate and + the manifests-last ordering hold identically on a laptop and on a cluster -- a publish is the + one operation where "it behaved differently in the small deployment" would mean a store whose + records cannot be trusted. + """ + from ada.assets.manifest import Actor + from ada.assets.publish import apply_publish_plan + from ada.assets.publishers import asset_publisher + + publisher = asset_publisher(provider_id) # LookupError if this process cannot publish it + loop = asyncio.get_running_loop() + + from ada.comms.rest.worker import _SyncStorageFacade + + sync_storage = _SyncStorageFacade(storage, scope, loop) + + job = LocalJob( + job_id=f"local-{uuid.uuid4().hex[:16]}", + plugin_id=provider_id, + scope_kind=getattr(scope, "kind", "shared"), + scope_id=getattr(scope, "id", None), + derived_key=derived_key, + ) + registry.add(job) + + def _run() -> None: + try: + job.stage = "derive" + job.progress = 0.1 + plan = publisher.derive( + scope, + dict(staged), + storage=sync_storage, + collection=collection, + options=dict(options), + dry_run=dry_run, + ) + if job.status != STATUS_RUNNING: + return + occupied: set[str] = set() + for prefix in sorted({w.key.rsplit("/", 1)[0] for w in plan.writes}): + occupied.update(sync_storage.list_keys(f"{prefix}/")) + job.stage = "publish" + job.progress = 0.6 + outcome = apply_publish_plan( + plan, + published_by=Actor(id=published_by, display=published_by_display), + published_via=published_via, + dry_run=dry_run, + replace_existing=replace_existing, + occupied=occupied, + write=lambda key, data: sync_storage.put_bytes(key, data), + ) + payload = outcome.to_dict() + job.stage = "upload" + job.progress = 0.95 + sync_storage.put_bytes(derived_key, json.dumps(payload).encode("utf-8"), content_encoding="gzip") + job.result = payload + job.status = STATUS_DONE + job.stage = "done" + job.progress = 1.0 + except Exception as exc: # noqa: BLE001 — the publish's failure is data, not ours + if job.status != STATUS_RUNNING: + return + logger.exception("local asset publish %s (%s) failed", job.job_id, provider_id) + job.status = STATUS_ERROR + job.error = f"{type(exc).__name__}: {exc}" + job.stage = "error" + logger.debug("local asset publish traceback:\n%s", traceback.format_exc()) + + threading.Thread(target=_run, name=f"local-asset-publish-{provider_id}", daemon=True).start() + return job diff --git a/src/ada/comms/rest/migrations/030_source_nodes_action.sql b/src/ada/comms/rest/migrations/030_source_nodes_action.sql new file mode 100644 index 000000000..e0efaa4dd --- /dev/null +++ b/src/ada/comms/rest/migrations/030_source_nodes_action.sql @@ -0,0 +1,32 @@ +-- 030_source_nodes_action.sql — per-node change evidence, borrowed from +-- IfcChangeActionEnum, adopted PARTIALLY (see plan/v4's Decision 7). +-- +-- WHAT THIS IS NOT. `source_nodes` already answers four ROOT-level questions +-- ("behind" / "current" / "not-recorded" / "no-feed") from whether a row exists +-- at all and how its `last_changed_at` compares to what was published — that +-- logic reads the table, not this column, and is unchanged by this migration. +-- +-- WHAT THIS IS. A writer that swept a source and found a product changed, +-- added or removed now has somewhere to say WHICH of the three it saw, per +-- node — evidence a "changed by" / "what happened" row detail can show without +-- the reader re-deriving it from two timestamps. `IfcChangeActionEnum`'s +-- NOCHANGE / MODIFIEDADDED / MODIFIEDDELETED are deliberately not represented: +-- a row's absence from a sweep already means "no change" (writing NOCHANGE rows +-- would turn this table into a full mirror of the source), and the compound +-- values encode a HISTORY this table does not keep — two revisions of a +-- manifest already say "added, then modified" without a fourth enum value. +-- +-- ADDITIVE AND NULLABLE, on purpose. Every existing writer (an out-of-tree +-- provider's feed, any sweep written before this column existed) keeps inserting rows with no +-- opinion about `action`, and every existing reader keeps working without +-- asking for it — a writer that cannot say what happened is not thereby wrong, +-- only silent on this one axis. + +-- No IF NOT EXISTS needed on either statement: the runner (`db/migrations.py`) +-- records each filename in `schema_version` and never re-applies one, so this +-- body runs exactly once per database, same as every other migration here. +ALTER TABLE source_nodes ADD COLUMN action TEXT; + +ALTER TABLE source_nodes + ADD CONSTRAINT source_nodes_action_check + CHECK (action IS NULL OR action IN ('added', 'modified', 'deleted')); diff --git a/src/ada/comms/rest/routes/assets.py b/src/ada/comms/rest/routes/assets.py index 8b37c9c5f..43d46c793 100644 --- a/src/ada/comms/rest/routes/assets.py +++ b/src/ada/comms/rest/routes/assets.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio +import uuid from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse @@ -30,7 +31,7 @@ derived_asset_prefix, ) from ada.assets.index import fold_listing -from ada.assets.keys import ASSET_PREFIX, AssetKeyError, asset_key +from ada.assets.keys import ASSET_PREFIX, STAGING_SEGMENT, AssetKeyError, asset_key from ada.assets.manifest import ( HIERARCHY_FILENAME, MANIFEST_FILENAME, @@ -40,12 +41,14 @@ ) from ada.assets.projection import HierarchyError, parse_hierarchy from ada.assets.provider import BuildDelivery, MeshDelivery +from ada.assets.publish import staged_prefix from ada.assets.registry import ( AssetProviderError, asset_provider, asset_providers, registered_provider_ids, ) +from ada.assets.unpublish import plan_unpublish from .. import auth as auth_module from ..auth import User @@ -372,6 +375,195 @@ async def api_asset_build( return JSONResponse({**answer, "job_id": submitted.job_id, "cached": False}) +@router.post("/scopes/{scope}/assets/publish") +async def api_asset_publish( + body: dict, + request: Request, + scope_obj: Scope = Depends(scope_from_path), + ctx: RestContext = Depends(rest_context), + user: User = Depends(auth_module.current_user), +) -> JSONResponse: + """Publish staged blobs. Body: ``{provider, staging_id | staged, collection?, options?, + dry_run?, replace?}``. + + The provider DERIVES and core WRITES (``ada.assets.publish``), which is what makes the owner + gate and the manifests-last ordering properties of the store rather than habits of each + provider. Authorship is stamped HERE, from the same authenticated caller the audit row uses: + a provider that returned ``change.published_by`` is refused by name. + + ``staging_id`` is the ordinary case -- everything under ``assets/_staging//`` is handed + to the provider by role (its filename), which is why staging survives a reload: the keys are + in the store, not in a browser's memory (see ``GET /assets/staging``). + """ + provider = str(body.get("provider") or "") + if not provider: + raise HTTPException( + status_code=400, detail="'provider' is required: it names whose format the staged bytes are in" + ) + ctx.jobs.require("asset_publish") + + staged: dict[str, str] = {} + staging_id = body.get("staging_id") + if staging_id: + prefix = staged_prefix(str(staging_id)) + entries = await ctx.storage.list_prefix(scope_obj, prefix) + for entry in entries: + staged[entry.key[len(prefix) :]] = entry.key + if not staged: + raise HTTPException(status_code=404, detail=f"nothing staged under {prefix}") + else: + raw = body.get("staged") or {} + if not isinstance(raw, dict) or not raw: + raise HTTPException(status_code=400, detail="one of 'staging_id' or a non-empty 'staged' map is required") + for role, key in raw.items(): + key = str(key) + # A staged key must BE staged: publishing "from" an arbitrary key in the scope would + # let a caller re-derive over blobs it never uploaded, and the grammar reserves + # `_staging/` for exactly this handover. + if not key.startswith(f"{ASSET_PREFIX}/{STAGING_SEGMENT}/"): + raise HTTPException( + status_code=400, + detail=f"staged key {key!r} is outside {ASSET_PREFIX}/{STAGING_SEGMENT}/", + ) + staged[str(role)] = key + + dry_run = bool(body.get("dry_run")) + derived_key = f"_derived/assets/_publish/{provider}/{uuid.uuid4().hex[:16]}/summary.json" + submitted = await ctx.jobs.submit( + JobRequest( + source_key=f"_synthetic/asset_publish/{provider}/{sorted(staged.values())[0]}", + target_format="asset_publish", + scope=scope_obj, + feature="asset_publish", + derived_key=derived_key, + conversion_options={ + "provider": provider, + "staged": staged, + "collection": body.get("collection"), + "options": body.get("options") or {}, + "dry_run": dry_run, + "replace": bool(body.get("replace")), + # Decision 6's three trust levels: this is the CORE-STAMPED one, taken from the + # authenticated caller and never from the request body. + "published_by_id": getattr(user, "sub", None) or getattr(user, "id", None) or "unknown", + "published_by_display": getattr(user, "display_name", None) or getattr(user, "email", None), + "published_via": _published_via(user), + }, + ), + before_dispatch=lambda submitted: ctx.audit( + request, + user, + scope_obj, + "asset_publish", + key=derived_key, + target_format="asset_publish", + status="queued", + job_id=submitted.job_id, + ), + ) + return JSONResponse({"job_id": submitted.job_id, "derived_key": derived_key, "dry_run": dry_run}) + + +@router.get("/scopes/{scope}/assets/staging") +async def api_asset_staging( + scope_obj: Scope = Depends(scope_from_path), + ctx: RestContext = Depends(rest_context), +) -> JSONResponse: + """What is staged and not yet published, grouped by staging id. + + STAGING RECOVERY IS A LISTING, not a session. An upload that finished and a publish that was + never started leave bytes in the scope with nobody's browser remembering them; without this + the only trace is a prefix nothing lists, and the user re-uploads a file that is already + there. The store is the memory. + """ + prefix = f"{ASSET_PREFIX}/{STAGING_SEGMENT}/" + entries = await ctx.storage.list_prefix(scope_obj, prefix) + staged: dict[str, dict] = {} + for entry in entries: + rest = entry.key[len(prefix) :] + staging_id, _, filename = rest.partition("/") + if not staging_id or not filename: + continue + group = staged.setdefault(staging_id, {"staging_id": staging_id, "files": [], "size": 0}) + group["files"].append({"file": filename, "key": entry.key, "size": getattr(entry, "size", None)}) + group["size"] += getattr(entry, "size", 0) or 0 + return JSONResponse({"staged": [staged[k] for k in sorted(staged)]}) + + +@router.delete("/scopes/{scope}/assets/{collection}/{subject}/{revision}") +async def api_asset_unpublish( + collection: str, + subject: str, + revision: str, + request: Request, + scope_obj: Scope = Depends(scope_from_path), + ctx: RestContext = Depends(rest_context), + user: User = Depends(auth_module.current_user), +) -> JSONResponse: + """Unpublish one subject-revision, refusing while another manifest still names its blobs. + + The refcount check the prior art left as "the publisher's obligation" (Decision 3), moved to + the route: an obligation every publisher must remember is one that will eventually be + forgotten, and what it costs is a manifest pointing at a deleted blob -- an asset that lists, + resolves and badges like a working one and fails only at load. + + Answers ``{deleted, kept, reason}``: a refusal is a 409 whose reason names who is holding it. + """ + try: + keys = await _list_asset_keys(ctx, scope_obj, f"{ASSET_PREFIX}/{collection}/") + except (FileNotFoundError, KeyError): + keys = [] + manifests: dict[str, bytes] = {} + for key in keys: + if key.rsplit("/", 1)[-1] != MANIFEST_FILENAME: + continue + try: + manifests[key] = await ctx.storage.get_bytes(scope_obj, key) + except (FileNotFoundError, KeyError): + continue + + plan = plan_unpublish( + collection=collection, + subject=subject, + revision=revision, + collection_keys=keys, + manifest_bytes=manifests, + ) + if plan.refused: + status = 404 if not plan.kept and not plan.held_by else 409 + return JSONResponse({**plan.to_dict(), "ok": False}, status_code=status) + + deleted: list[str] = [] + errors: dict[str, str] = {} + for key in plan.deleted: + try: + await ctx.storage.delete(scope_obj, key) + deleted.append(key) + except Exception as exc: # noqa: BLE001 - one key's failure is not the others' + errors[key] = str(exc) + await ctx.audit( + request, + user, + scope_obj, + "asset_unpublish", + key=f"{ASSET_PREFIX}/{collection}/{subject}/{revision}/", + status="error" if errors else "done", + ) + return JSONResponse({**plan.to_dict(), "deleted": deleted, "errors": errors, "ok": not errors}) + + +def _published_via(user: object) -> str: + """``user`` or ``service`` -- Decision 6's first two trust levels, told apart by WHO CALLED. + + A scheduled firing, a mirror and a worker with no human in the path all arrive as the + deployment's own identity (``SystemUser``, ``sub == "system"``), and recording those as a + user publish would put a person's name on a revision nobody pushed. The distinction is read + from the authenticated principal rather than from anything in the request body, for the same + reason ``published_by`` is: a caller that could state it could also misstate it. + """ + return "service" if getattr(user, "sub", None) == "system" else "user" + + # --- helpers ------------------------------------------------------------------------------------- # Bounded fan-out for the manifest fold: enough to hide per-object latency, few enough that one diff --git a/src/ada/comms/rest/routes/source_nodes.py b/src/ada/comms/rest/routes/source_nodes.py index bdd5c51a0..d545b5785 100644 --- a/src/ada/comms/rest/routes/source_nodes.py +++ b/src/ada/comms/rest/routes/source_nodes.py @@ -52,6 +52,9 @@ def _source_nodes_pool(request: Request): return pool +_ACTIONS = ("added", "modified", "deleted") + + def _source_node_json(n) -> dict: return { "node_ref": n.node_ref, @@ -60,6 +63,9 @@ def _source_node_json(n) -> dict: "last_changed_at": n.last_changed_at.isoformat(), "last_changed_by": n.last_changed_by, "observed_at": n.observed_at.isoformat(), + # None for every row written before migration 030, and for any writer + # that has no opinion -- absent is a normal, complete answer, not a gap. + "action": getattr(n, "action", None), } @@ -198,12 +204,20 @@ def _opt(field: str): val = str(val).strip() return val or None + action = _opt("action") + if action is not None and action not in _ACTIONS: + raise HTTPException( + status_code=400, + detail=f"nodes[{index}].action {action!r} not in {_ACTIONS} (omit it if the writer has no opinion)", + ) + return { "node_ref": node_ref, "parent_ref": _opt("parent_ref"), "name": _opt("name"), "last_changed_at": changed, "last_changed_by": _opt("last_changed_by"), + "action": action, } diff --git a/src/ada/comms/rest/worker/source_nodes.py b/src/ada/comms/rest/worker/source_nodes.py index 43e379a97..6f4660e4f 100644 --- a/src/ada/comms/rest/worker/source_nodes.py +++ b/src/ada/comms/rest/worker/source_nodes.py @@ -54,10 +54,13 @@ def record(self, source: str, nodes: list) -> int: """Upsert observed nodes for one source. Returns rows written. Each node is a dict: ``node_ref`` and ``last_changed_at`` required, - ``parent_ref`` / ``name`` / ``last_changed_by`` optional. A writer that - observes a leaf change is expected to stamp every node ABOVE it too -- - the roll-up is the writer's job, so that a consumer asking about a branch - reads one row rather than walking a hierarchy this table does not model. + ``parent_ref`` / ``name`` / ``last_changed_by`` / ``action`` optional. + ``action`` is per-node change evidence (``'added' | 'modified' | + 'deleted'``, migration 030) -- a writer with no opinion simply omits + it. A writer that observes a leaf change is expected to stamp every + node ABOVE it too -- the roll-up is the writer's job, so that a + consumer asking about a branch reads one row rather than walking a + hierarchy this table does not model. """ from .. import db as db_module @@ -211,7 +214,7 @@ def _node_json(node: dict) -> dict: ) changed = changed.isoformat() out = {"node_ref": node.get("node_ref"), "last_changed_at": changed} - for field in ("parent_ref", "name", "last_changed_by"): + for field in ("parent_ref", "name", "last_changed_by", "action"): if node.get(field) is not None: out[field] = node[field] return out @@ -301,6 +304,7 @@ def _dt(value): last_changed_at=_dt(raw.get("last_changed_at")), last_changed_by=raw.get("last_changed_by"), observed_at=_dt(raw.get("observed_at")), + action=raw.get("action"), ) diff --git a/src/frontend/src/__tests__/assets/assetIndex.test.ts b/src/frontend/src/__tests__/assets/assetIndex.test.ts index ec3d6b917..5f989bb1a 100644 --- a/src/frontend/src/__tests__/assets/assetIndex.test.ts +++ b/src/frontend/src/__tests__/assets/assetIndex.test.ts @@ -83,7 +83,7 @@ function equivalentKeysAndManifests(): { keys: string[]; manifests: Map([ [ `${COLL}/area-1/${R_A}`, - { provider: "fixture-lines", node: "area-1", delivery: "mesh", producedAt: R_A, hierarchyRevision: R_A }, + { provider: "fixture-lines", node: "area-1", delivery: "mesh", producedAt: R_A, hierarchyRevision: R_A, change: null }, ], ]); return { keys, manifests }; @@ -158,6 +158,7 @@ test("the wire's snake_case manifest fields map to camelCase", () => { delivery: "mesh", producedAt: R_A, hierarchyRevision: R_A, + change: null, }); }); diff --git a/src/frontend/src/__tests__/assets/assetView.test.ts b/src/frontend/src/__tests__/assets/assetView.test.ts index 56d9eb777..98d8bd0fe 100644 --- a/src/frontend/src/__tests__/assets/assetView.test.ts +++ b/src/frontend/src/__tests__/assets/assetView.test.ts @@ -58,8 +58,8 @@ function orphanFixture(): { forest: Forest; index: ReturnType([ - [`${COLL}/member-9/${R1}`, { provider: "p", node: "member-9", delivery: "mesh", producedAt: R1, hierarchyRevision: null }], - [`${COLL}/member-20/${R3}`, { provider: "p", node: "member-20", delivery: "mesh", producedAt: R3, hierarchyRevision: null }], + [`${COLL}/member-9/${R1}`, { provider: "p", node: "member-9", delivery: "mesh", producedAt: R1, hierarchyRevision: null, change: null }], + [`${COLL}/member-20/${R3}`, { provider: "p", node: "member-20", delivery: "mesh", producedAt: R3, hierarchyRevision: null, change: null }], ]); const index = foldListing( [ @@ -116,9 +116,9 @@ function driftFixture(): { forest: Forest; index: ReturnType forest = mergeSpine(forest, [an("member-4", "area-1", "member", true)], { subject: "member-4", revision: R1, root: null }); const manifests = new Map([ - [`${COLL}/member-3/${R1}`, { provider: "p", node: "member-3", delivery: "mesh", producedAt: R1, hierarchyRevision: R1 }], + [`${COLL}/member-3/${R1}`, { provider: "p", node: "member-3", delivery: "mesh", producedAt: R1, hierarchyRevision: R1, change: null }], // member-4 recorded the CURRENT collection revision — no drift. - [`${COLL}/member-4/${R1}`, { provider: "p", node: "member-4", delivery: "mesh", producedAt: R1, hierarchyRevision: R2 }], + [`${COLL}/member-4/${R1}`, { provider: "p", node: "member-4", delivery: "mesh", producedAt: R1, hierarchyRevision: R2, change: null }], ]); const index = foldListing( [ @@ -211,7 +211,7 @@ test("a row drawn from a subtree spine is stale once its subject resolves to a n root: "level-2", }); const manifests = new Map([ - [`${COLL}/level-2/${R2}`, { provider: "p", node: "level-2", delivery: "none", producedAt: R2, hierarchyRevision: null }], + [`${COLL}/level-2/${R2}`, { provider: "p", node: "level-2", delivery: "none", producedAt: R2, hierarchyRevision: null, change: null }], ]); const index = foldListing( [ diff --git a/src/frontend/src/__tests__/assets/assetViewChanges.test.ts b/src/frontend/src/__tests__/assets/assetViewChanges.test.ts new file mode 100644 index 000000000..17ce83a70 --- /dev/null +++ b/src/frontend/src/__tests__/assets/assetViewChanges.test.ts @@ -0,0 +1,193 @@ +// `buildAssetView`'s integration of the change feed (§Decision 4, Phase 4) +// with the rest of the view: root-level `changeState` and per-node +// `evidenceMark` surfacing through `rowFacts`, the "changed by" gate +// (§Decision 6), and -- the thing this file exists to pin -- that STALE +// (`./freshness`) and BEHIND (`./changes`) are independent facts: a row can +// be either, both, or neither, and neither is derived from the other. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_FILENAME, MANIFEST_FILENAME, foldListing } from "../../assets/assetIndex"; +import { buildAssetView } from "../../assets/assetView"; +import type { SourceNodeRow, SourceNodesAnswer } from "../../assets/changes"; +import { EMPTY_FOREST, mergeSpine, type Forest } from "../../assets/merge"; +import { changeOwners, rowFacts, subjectsByOwner } from "../../assets/rowFacts"; +import type { AssetIndex, AssetNode, ManifestSummary } from "../../assets/types"; + +const R1 = "20260825T000000Z"; // 2026-08-25T00:00:00Z +const R2 = "20260827T000000Z"; // 2026-08-27T00:00:00Z +const COLL = "plant-a"; +const K = (s: string, r: string, f: string): string => `assets/${COLL}/${s}/${r}/${f}`; + +const an = (id: string, parent: string | null, kind: string, leaf: boolean): AssetNode => ({ + id, + parent, + label: id, + kind, + leaf, + delivery: "none", + provider: "fixture-lines", +}); + +const PROVIDER = "fixture-lines"; + +function sourceRow(nodeRef: string, lastChangedAt: string, action: SourceNodeRow["action"] = null): SourceNodeRow { + return { nodeRef, parentRef: null, name: null, lastChangedAt, lastChangedBy: "the-source", observedAt: lastChangedAt, action }; +} + +function answer(rows: readonly SourceNodeRow[]): SourceNodesAnswer { + return { source: PROVIDER, rows: new Map(rows.map((r) => [r.nodeRef, r])), unknown: new Set() }; +} + +/** site-a is republished at R2 without its spine being re-fetched (still + * merged at R1) -- the standard staleness shape from `./freshness`. site-b + * is published once, at R1, and never re-touched -- never stale. Neither + * subject's own manifest carries a `change` record unless a test adds one. */ +function fixture(): { forest: Forest; index: AssetIndex } { + let forest = mergeSpine(EMPTY_FOREST, [an("site-a", null, "site", false), an("leaf-a1", "site-a", "member", true)], { + subject: "site-a", + revision: R1, + root: "site-a", + }); + forest = mergeSpine(forest, [an("site-b", null, "site", false)], { subject: "site-b", revision: R1, root: "site-b" }); + + const manifests = new Map([ + [`${COLL}/site-a/${R1}`, { provider: PROVIDER, node: "site-a", delivery: "mesh", producedAt: R1, hierarchyRevision: null, change: null }], + [`${COLL}/site-a/${R2}`, { provider: PROVIDER, node: "site-a", delivery: "mesh", producedAt: R2, hierarchyRevision: null, change: null }], + [`${COLL}/site-b/${R1}`, { provider: PROVIDER, node: "site-b", delivery: "mesh", producedAt: R1, hierarchyRevision: null, change: null }], + ]); + const index = foldListing( + [ + K("site-a", R1, MANIFEST_FILENAME), + K("site-a", R1, HIERARCHY_FILENAME), + K("site-a", R2, MANIFEST_FILENAME), + K("site-b", R1, MANIFEST_FILENAME), + K("site-b", R1, HIERARCHY_FILENAME), + ], + manifests, + ); + return { forest, index }; +} + +test("a root that is BOTH stale and behind shows both facts, independently and with different vocabulary", () => { + const { forest, index } = fixture(); + // site-a resolves to R2 (its newest complete revision) but its spine was + // merged at R1 -- stale. Judged against R2 (its CURRENT resolved revision, + // not the stale R1), the feed says the source moved even later -- behind. + const view = buildAssetView({ + forest, + index, + collection: COLL, + mode: { kind: "latest" }, + evidenceAsked: new Set(["site-a", "site-b"]), + sourceAnswer: new Map([[PROVIDER, answer([sourceRow("site-a", "2026-08-28T00:00:00Z")])]]), + }); + const facts = rowFacts(view, "site-a")!; + assert.equal(facts.freshness?.stale, true, "drawn from R1 while the resolution now names R2"); + assert.equal(facts.changeState, "behind", "the source moved after R2, which is what this row is judged against"); + assert.notEqual(String(facts.changeState), "stale", "the two facts must never share a word"); +}); + +test("a root that is stale but NOT behind: independence in the other direction", () => { + const { forest, index } = fixture(); + const view = buildAssetView({ + forest, + index, + collection: COLL, + mode: { kind: "latest" }, + evidenceAsked: new Set(["site-a"]), + // Judged against R2 (site-a's current resolution): nothing newer recorded. + sourceAnswer: new Map([[PROVIDER, answer([sourceRow("site-a", "2026-08-20T00:00:00Z")])]]), + }); + const facts = rowFacts(view, "site-a")!; + assert.equal(facts.freshness?.stale, true); + assert.equal(facts.changeState, "current"); +}); + +test("a root that is behind but NOT stale: never re-published, so freshness has nothing to say", () => { + const { forest, index } = fixture(); + const view = buildAssetView({ + forest, + index, + collection: COLL, + mode: { kind: "latest" }, + evidenceAsked: new Set(["site-b"]), + sourceAnswer: new Map([[PROVIDER, answer([sourceRow("site-b", "2026-08-26T00:00:00Z")])]]), + }); + const facts = rowFacts(view, "site-b")!; + assert.equal(facts.freshness?.stale, false); + assert.equal(facts.changeState, "behind"); +}); + +test("a root never asked about has changeState null, not a guessed `not-recorded`", () => { + const { forest, index } = fixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" } }); + assert.equal(rowFacts(view, "site-a")!.changeState, null); + assert.equal(view.changes.byRoot.size, 0); +}); + +test("changeState is null for a row that is not itself a published subject", () => { + const { forest, index } = fixture(); + const view = buildAssetView({ + forest, + index, + collection: COLL, + mode: { kind: "latest" }, + evidenceAsked: new Set(["site-a", "leaf-a1"]), + sourceAnswer: new Map([[PROVIDER, answer([sourceRow("site-a", "2026-08-28T00:00:00Z")])]]), + }); + assert.equal(rowFacts(view, "leaf-a1")!.changeState, null, "leaf-a1 is a node, never a resolved subject of its own"); +}); + +test("evidence marks surface per-node through rowFacts, independent of the root's own changeState", () => { + const { forest, index } = fixture(); + const changedRows = new Map([["leaf-a1", sourceRow("leaf-a1", "2026-08-27T12:00:00Z", "modified")]]); + const view = buildAssetView({ + forest, + index, + collection: COLL, + mode: { kind: "latest" }, + evidenceAsked: new Set(["site-a"]), + sourceAnswer: new Map([[PROVIDER, answer([sourceRow("site-a", "2026-08-20T00:00:00Z")])]]), + changedRows, + }); + assert.equal(rowFacts(view, "leaf-a1")!.evidenceMark, "modified"); + assert.equal(rowFacts(view, "site-a")!.evidenceMark, null, "the sweep marked the leaf, not the root itself"); +}); + +test("no manifest carries a `change` -> hasChangeOwners is false and the owner list is empty", () => { + const { forest, index } = fixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" } }); + assert.equal(view.hasChangeOwners, false); + assert.deepEqual(changeOwners(view), []); + assert.deepEqual(subjectsByOwner(view, "alice"), []); +}); + +test("a manifest with a `change.publishedBy` makes hasChangeOwners true and the actor findable", () => { + const { forest, index } = fixture(); + const owned = new Map(index.collections.get(COLL)!); + const siteA = owned.get("site-a")!; + const revisions = siteA.revisions.map((r) => + r.revision === R2 && r.manifest + ? { + ...r, + manifest: { + ...r.manifest, + change: { publishedBy: { id: "alice", display: "Alice", application: null }, publishedVia: "user" as const, sourceActor: null, action: null, sourceInstant: null }, + }, + } + : r, + ); + owned.set("site-a", { ...siteA, revisions }); + const withOwner = { collections: new Map(index.collections).set(COLL, owned), malformed: index.malformed }; + + const view = buildAssetView({ forest, index: withOwner, collection: COLL, mode: { kind: "latest" } }); + assert.equal(view.hasChangeOwners, true); + const owners = changeOwners(view); + assert.equal(owners.length, 1); + assert.equal(owners[0].id, "alice"); + assert.deepEqual(subjectsByOwner(view, "alice"), ["site-a"]); + assert.equal(rowFacts(view, "site-a")!.changeRecord?.publishedBy?.display, "Alice"); + assert.equal(rowFacts(view, "site-b")!.changeRecord, null); +}); diff --git a/src/frontend/src/__tests__/assets/assetViewPerf.test.ts b/src/frontend/src/__tests__/assets/assetViewPerf.test.ts index 8f144612e..847687294 100644 --- a/src/frontend/src/__tests__/assets/assetViewPerf.test.ts +++ b/src/frontend/src/__tests__/assets/assetViewPerf.test.ts @@ -27,8 +27,8 @@ test("a ~41k-row spine parses, merges, builds and flattens within a generous bou const synthetic = buildSyntheticSpine(); const manifests = new Map([ - [`${COLL}/${COLL}/${REVISION}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: REVISION, hierarchyRevision: null }], - [`${COLL}/${SYNTHETIC_ROOT}/${REVISION}`, { provider: "fixture-lines", node: SYNTHETIC_ROOT, delivery: "none", producedAt: REVISION, hierarchyRevision: null }], + [`${COLL}/${COLL}/${REVISION}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: REVISION, hierarchyRevision: null, change: null }], + [`${COLL}/${SYNTHETIC_ROOT}/${REVISION}`, { provider: "fixture-lines", node: SYNTHETIC_ROOT, delivery: "none", producedAt: REVISION, hierarchyRevision: null, change: null }], ]); const keys = [ K(COLL, REVISION, MANIFEST_FILENAME), @@ -44,6 +44,7 @@ test("a ~41k-row spine parses, merges, builds and flattens within a generous bou delivery: "mesh", producedAt: REVISION, hierarchyRevision: null, + change: null, }); } const index = foldListing(keys, manifests); diff --git a/src/frontend/src/__tests__/assets/changes.test.ts b/src/frontend/src/__tests__/assets/changes.test.ts new file mode 100644 index 000000000..af8752f14 --- /dev/null +++ b/src/frontend/src/__tests__/assets/changes.test.ts @@ -0,0 +1,222 @@ +// The change feed's four states (`behind | current | not-recorded | no-feed`) +// and the per-node evidence marks -- Phase 4's frontend half of §Decision 4. +// +// Pinned here, and why each pin exists: +// - the pair that must never collapse: `current` and `not-recorded` read +// identically to a careless renderer, and are opposite claims. +// - a 503 (no-feed) answers `no-feed` for every ref asked about it, never a +// silent `current`. +// - evidence marks are a positive claim only -- absence from the feed's +// rows never becomes a mark, whatever the reason for the absence. +// - "not asked" is a THIRD thing again, apart from all four states: a root +// the browser never queried gets no entry at all, not a guess. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + classifyChanges, + evidenceMarks, + sourceNodesAnswerFromWire, + type ExportRoot, + type RootLookup, + type SourceNodeRow, + type SourceNodesAnswer, +} from "../../assets/changes"; +import type { WireSourceNodesRefsResponse } from "../../assets/types"; + +const R1 = "20260825T000000Z"; // 2026-08-25T00:00:00Z +const R2 = "20260827T000000Z"; // 2026-08-27T00:00:00Z + +function row(nodeRef: string, lastChangedAt: string, opts: Partial = {}): SourceNodeRow { + return { + nodeRef, + parentRef: opts.parentRef ?? null, + name: opts.name ?? null, + lastChangedAt, + lastChangedBy: opts.lastChangedBy ?? null, + observedAt: opts.observedAt ?? lastChangedAt, + action: opts.action ?? null, + }; +} + +function answer(source: string, rows: readonly SourceNodeRow[], unknown: readonly string[] = []): SourceNodesAnswer { + return { source, rows: new Map(rows.map((r) => [r.nodeRef, r])), unknown: new Set(unknown) }; +} + +/** A lookup where every listed subject was asked, against one fixed answer + * (or `null` for no-feed) -- the common case in these tests. */ +function askedLookup(a: SourceNodesAnswer | null, asked: readonly string[] = []): RootLookup { + const askedSet = new Set(asked.length ? asked : a ? [...a.rows.keys()] : []); + return (subject) => (askedSet.has(subject) ? { asked: true, answer: a } : { asked: false, answer: null }); +} + +// --------------------------------------------------------------------------- +// the four states +// --------------------------------------------------------------------------- + +test("a root the feed recorded a LATER change for than its own revision is `behind`", () => { + const roots: ExportRoot[] = [{ subject: "site-a", revision: R1 }]; + const a = answer("provider-x", [row("site-a", "2026-08-27T00:00:00Z")]); + const report = classifyChanges(roots, askedLookup(a, ["site-a"])); + assert.equal(report.byRoot.get("site-a")?.state, "behind"); + assert.equal(report.behind, 1); + assert.equal(report.current, 0); +}); + +test("a root the feed recorded a change for AT OR BEFORE its own revision is `current`", () => { + const roots: ExportRoot[] = [{ subject: "site-a", revision: R2 }]; + const a = answer("provider-x", [row("site-a", "2026-08-25T00:00:00Z")]); + const report = classifyChanges(roots, askedLookup(a, ["site-a"])); + assert.equal(report.byRoot.get("site-a")?.state, "current"); + assert.equal(report.current, 1); +}); + +test("a root recorded at EXACTLY its own revision instant is current, not behind (strictly greater only)", () => { + const roots: ExportRoot[] = [{ subject: "site-a", revision: R1 }]; + const a = answer("provider-x", [row("site-a", "2026-08-25T00:00:00Z")]); + const report = classifyChanges(roots, askedLookup(a, ["site-a"])); + assert.equal(report.byRoot.get("site-a")?.state, "current"); +}); + +test("`current` and `not-recorded` must never collapse: absence from a GIVEN answer is not-recorded, not current", () => { + const roots: ExportRoot[] = [ + { subject: "site-a", revision: R1 }, // has a row + { subject: "site-b", revision: R1 }, // the feed never mentions it + ]; + const a = answer("provider-x", [row("site-a", "2026-08-24T00:00:00Z")]); + const report = classifyChanges(roots, askedLookup(a, ["site-a", "site-b"])); + assert.equal(report.byRoot.get("site-a")?.state, "current"); + assert.equal(report.byRoot.get("site-b")?.state, "not-recorded", "absence from a real answer means nobody looked"); + assert.notEqual( + report.byRoot.get("site-a")?.state, + report.byRoot.get("site-b")?.state, + "the two must read as different states even though neither is bad news", + ); + assert.equal(report.notRecorded, 1); +}); + +test("a root never ASKED about gets no entry at all -- not a guessed `not-recorded`", () => { + const roots: ExportRoot[] = [{ subject: "site-a", revision: R1 }]; + const report = classifyChanges(roots, () => ({ asked: false, answer: null })); + assert.equal(report.byRoot.has("site-a"), false); + assert.equal(report.notRecorded, 0, "an unasked root must not inflate the not-recorded count either"); +}); + +test("a 503 answer (no-feed) yields `no-feed` for every asked ref, never `current`", () => { + const roots: ExportRoot[] = [ + { subject: "site-a", revision: R1 }, + { subject: "site-b", revision: R2 }, + ]; + const report = classifyChanges(roots, askedLookup(null, ["site-a", "site-b"])); + assert.equal(report.byRoot.get("site-a")?.state, "no-feed"); + assert.equal(report.byRoot.get("site-b")?.state, "no-feed"); + assert.equal(report.behind, 0); + assert.equal(report.current, 0); + assert.equal(report.notRecorded, 0, "no-feed is its own bucket, not folded into not-recorded's count"); +}); + +test("no-feed is per-provider: one provider's roots read no-feed while another's are classified normally", () => { + const roots: ExportRoot[] = [ + { subject: "site-a", revision: R1 }, + { subject: "site-b", revision: R1 }, + ]; + const providerOf: Record = { "site-a": "provider-down", "site-b": "provider-up" }; + const upAnswer = answer("provider-up", [row("site-b", "2026-08-24T00:00:00Z")]); + const lookup: RootLookup = (subject) => { + if (providerOf[subject] === "provider-down") return { asked: true, answer: null }; + return { asked: true, answer: upAnswer }; + }; + const report = classifyChanges(roots, lookup); + assert.equal(report.byRoot.get("site-a")?.state, "no-feed"); + assert.equal(report.byRoot.get("site-b")?.state, "current"); +}); + +test("a malformed last_changed_at is read as current rather than thrown", () => { + const roots: ExportRoot[] = [{ subject: "site-a", revision: R1 }]; + const a = answer("provider-x", [row("site-a", "not-a-timestamp")]); + assert.doesNotThrow(() => classifyChanges(roots, askedLookup(a, ["site-a"]))); + assert.equal(classifyChanges(roots, askedLookup(a, ["site-a"])).byRoot.get("site-a")?.state, "current"); +}); + +test("classifyChanges over no roots is the empty report, not an error", () => { + const report = classifyChanges([], () => ({ asked: false, answer: null })); + assert.equal(report.byRoot.size, 0); + assert.equal(report.behind, 0); +}); + +// --------------------------------------------------------------------------- +// per-node evidence marks +// --------------------------------------------------------------------------- + +test("evidence marks appear only for refs the feed actually returned with an action", () => { + const changedRows = new Map([ + ["member-3", row("member-3", "2026-08-27T00:00:00Z", { action: "modified" })], + ["member-7", row("member-7", "2026-08-27T00:00:00Z", { action: "added" })], + // A roll-up ancestor row with no action is not evidence at THIS node -- + // callers keep it out of `changedRows` (the store filters on `action` + // before this ever runs), but this module must not invent a mark for it + // even if one slipped through. + ["level-2", row("level-2", "2026-08-27T00:00:00Z", { action: null })], + ]); + const marks = evidenceMarks(changedRows); + assert.equal(marks.get("member-3"), "modified"); + assert.equal(marks.get("member-7"), "added"); + assert.equal(marks.has("level-2"), false, "no action recorded here -- absence, not a guessed mark"); + assert.equal(marks.has("area-1"), false, "never asked about -- nothing to mark"); +}); + +test("evidence marks over an empty map is the empty map", () => { + assert.equal(evidenceMarks(new Map()).size, 0); +}); + +// --------------------------------------------------------------------------- +// wire parsing +// --------------------------------------------------------------------------- + +test("sourceNodesAnswerFromWire parses rows by node_ref and keeps `unknown` apart", () => { + const wire: WireSourceNodesRefsResponse = { + scope: "user:me", + source: "provider-x", + nodes: [ + { + node_ref: "site-a", + parent_ref: null, + name: "Site A", + last_changed_at: "2026-08-27T00:00:00Z", + last_changed_by: "alice", + observed_at: "2026-08-27T00:05:00Z", + action: "modified", + }, + ], + unknown: ["site-b"], + }; + const a = sourceNodesAnswerFromWire(wire); + assert.equal(a.source, "provider-x"); + assert.equal(a.rows.size, 1); + const r = a.rows.get("site-a")!; + assert.equal(r.lastChangedBy, "alice"); + assert.equal(r.action, "modified"); + assert.ok(a.unknown.has("site-b")); +}); + +test("sourceNodesAnswerFromWire defaults an absent action to null, never a guessed value", () => { + const wire: WireSourceNodesRefsResponse = { + scope: "user:me", + source: "provider-x", + nodes: [ + { + node_ref: "site-a", + parent_ref: null, + name: null, + last_changed_at: "2026-08-27T00:00:00Z", + last_changed_by: null, + observed_at: "2026-08-27T00:00:00Z", + // no `action` at all -- a deployment that has not migrated the column in + }, + ], + unknown: [], + }; + const a = sourceNodesAnswerFromWire(wire); + assert.equal(a.rows.get("site-a")?.action, null); +}); diff --git a/src/frontend/src/__tests__/assets/rowFacts.test.ts b/src/frontend/src/__tests__/assets/rowFacts.test.ts index c5c47a15b..a2ec8f835 100644 --- a/src/frontend/src/__tests__/assets/rowFacts.test.ts +++ b/src/frontend/src/__tests__/assets/rowFacts.test.ts @@ -34,7 +34,7 @@ function fixture() { root: null, }); const manifests = new Map([ - [`${COLL}/member-3/${R1}`, { provider: "fixture-lines", node: "member-3", delivery: "mesh", producedAt: R1, hierarchyRevision: null }], + [`${COLL}/member-3/${R1}`, { provider: "fixture-lines", node: "member-3", delivery: "mesh", producedAt: R1, hierarchyRevision: null, change: null }], ]); const index = foldListing( [K(COLL, R1, MANIFEST_FILENAME), K(COLL, R1, HIERARCHY_FILENAME), K("member-3", R1, MANIFEST_FILENAME)], @@ -77,7 +77,7 @@ test("dimmed is suppressed for an unexplored branch — absence there is not yet // omits it, so `unexplored` covers level-2 and its ancestors. const forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false)], { subject: COLL, revision: R1, root: null }); const manifests = new Map([ - [`${COLL}/area-1/${R1}`, { provider: "fixture-lines", node: "area-1", delivery: "none", producedAt: R1, hierarchyRevision: null }], + [`${COLL}/area-1/${R1}`, { provider: "fixture-lines", node: "area-1", delivery: "none", producedAt: R1, hierarchyRevision: null, change: null }], ]); const index = foldListing( [K(COLL, R1, MANIFEST_FILENAME), K(COLL, R1, HIERARCHY_FILENAME), K("area-1", R1, MANIFEST_FILENAME), K("area-1", R1, HIERARCHY_FILENAME)], diff --git a/src/frontend/src/__tests__/assets/sweepShadowing.test.ts b/src/frontend/src/__tests__/assets/sweepShadowing.test.ts index afbc3f495..3de462c11 100644 --- a/src/frontend/src/__tests__/assets/sweepShadowing.test.ts +++ b/src/frontend/src/__tests__/assets/sweepShadowing.test.ts @@ -40,10 +40,10 @@ const INDEX = foldListing( K("area-1", SWEEP, HIERARCHY_FILENAME), ], new Map([ - [`${COLL}/${COLL}/${CONTENT_RUN}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: CONTENT_RUN, hierarchyRevision: null }], - [`${COLL}/area-1/${CONTENT_RUN}`, { provider: "fixture-lines", node: "area-1", delivery: "mesh", producedAt: CONTENT_RUN, hierarchyRevision: null }], - [`${COLL}/${COLL}/${SWEEP}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: SWEEP, hierarchyRevision: null }], - [`${COLL}/area-1/${SWEEP}`, { provider: "fixture-lines", node: "area-1", delivery: "none", producedAt: SWEEP, hierarchyRevision: null }], + [`${COLL}/${COLL}/${CONTENT_RUN}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: CONTENT_RUN, hierarchyRevision: null, change: null }], + [`${COLL}/area-1/${CONTENT_RUN}`, { provider: "fixture-lines", node: "area-1", delivery: "mesh", producedAt: CONTENT_RUN, hierarchyRevision: null, change: null }], + [`${COLL}/${COLL}/${SWEEP}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: SWEEP, hierarchyRevision: null, change: null }], + [`${COLL}/area-1/${SWEEP}`, { provider: "fixture-lines", node: "area-1", delivery: "none", producedAt: SWEEP, hierarchyRevision: null, change: null }], ]), ); diff --git a/src/frontend/src/__tests__/state/assetBrowserLoader.test.ts b/src/frontend/src/__tests__/state/assetBrowserLoader.test.ts index 35ce2be79..453c65286 100644 --- a/src/frontend/src/__tests__/state/assetBrowserLoader.test.ts +++ b/src/frontend/src/__tests__/state/assetBrowserLoader.test.ts @@ -11,9 +11,10 @@ import assert from "node:assert/strict"; import { beforeEach, test } from "node:test"; +import type { SourceNodesAnswer } from "../../assets/changes"; import type { WireAssetIndex, WireHierarchySlice } from "../../assets/types"; import { useAssetBrowserStore } from "../../state/assetBrowserStore"; -import { createAssetBrowserLoader, type AssetsApiLike } from "../../state/assetBrowserLoader"; +import { createAssetBrowserLoader, type AssetsApiLike, type SourceNodesApiLike } from "../../state/assetBrowserLoader"; const SCOPE = "user:me"; const R1 = "20260901T100000Z"; @@ -174,6 +175,99 @@ test("a failed spine is recorded against its root and retried explicitly", async assert.ok(!s.spineLoading.has("area-2")); }); +// --------------------------------------------------------------------------- +// the change feed's fetch side (§Decision 4, Phase 4): eager per published +// root, lazy per spine, best-effort, and optional (a caller with no +// `sourceNodesApi` -- every test above this point -- gets a pure no-op). +// --------------------------------------------------------------------------- + +function makeSourceNodesApi(answerFor: (source: string, refs: readonly string[]) => SourceNodesAnswer | null) { + const calls: { source: string; refs: readonly string[] }[] = []; + const api: SourceNodesApiLike = { + async getSourceNodes(_scope, source, refs) { + calls.push({ source, refs: [...refs] }); + return answerFor(source, refs); + }, + }; + return { api, calls }; +} + +test("root evidence is fetched eagerly for every published subject (not the collection itself), grouped by provider", async () => { + const { api } = makeApi(); + const { api: sourceApi, calls } = makeSourceNodesApi((source, refs) => ({ + source, + rows: new Map(refs.map((r) => [r, { nodeRef: r, parentRef: null, name: null, lastChangedAt: "2026-08-01T00:00:00Z", lastChangedBy: null, observedAt: "2026-08-01T00:00:00Z", action: null }])), + unknown: new Set(), + })); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api, sourceApi); + await loader.loadCollections(SCOPE); + // area-1 is the only published subject other than the collection index + // itself ("plant-a"); the collection subject is never asked about as an + // export root. + assert.equal(calls.length, 1); + assert.equal(calls[0].source, "fixture-lines"); + assert.deepEqual(calls[0].refs, ["area-1"]); + const s = useAssetBrowserStore.getState(); + assert.ok(s.evidenceAsked.has("area-1")); + assert.ok(!s.evidenceAsked.has("plant-a"), "the collection index is not an export root"); +}); + +test("per-spine evidence asks only the refs THIS spine brought in, deduped against what root evidence already asked", async () => { + const { api } = makeApi(); + const { api: sourceApi, calls } = makeSourceNodesApi((source, refs) => ({ + source, + rows: new Map(), + unknown: new Set(refs), + })); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api, sourceApi); + await loader.loadCollections(SCOPE); // asks about area-1 eagerly (root evidence) + calls.length = 0; + await loader.loadSpine(SCOPE, { subject: "area-1", revision: R1, root: "area-1" }); + assert.equal(calls.length, 1); + // area-1 itself was already asked by root evidence -- the spine call's own + // refs (its root plus every node id the slice brought in) are deduped + // against the GLOBAL `evidenceAsked` set, not re-requested. + assert.deepEqual([...calls[0].refs].sort(), ["level-1", "member-1"]); + const s = useAssetBrowserStore.getState(); + assert.ok(s.evidenceAsked.has("level-1")); + assert.ok(s.evidenceAsked.has("member-1")); +}); + +test("a second load of the same spine does not re-ask evidence for refs it already has", async () => { + const { api } = makeApi(); + const { api: sourceApi, calls } = makeSourceNodesApi((source, refs) => ({ source, rows: new Map(), unknown: new Set(refs) })); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api, sourceApi); + await loader.loadCollections(SCOPE); + const source = { subject: "area-1", revision: R1, root: "area-1" }; + await loader.loadSpine(SCOPE, source); + const before = calls.length; + await loader.loadSpine(SCOPE, source); // idempotent: `spineLoaded` already matches + assert.equal(calls.length, before, "the spine itself is not re-fetched, so evidence is not re-asked either"); +}); + +test("the feed answering no-feed (null) is recorded as such, not silently dropped", async () => { + const { api } = makeApi(); + const { api: sourceApi } = makeSourceNodesApi(() => null); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api, sourceApi); + await loader.loadCollections(SCOPE); + const s = useAssetBrowserStore.getState(); + assert.equal(s.sourceAnswer.get("fixture-lines"), null); + assert.ok(s.evidenceAsked.has("area-1"), "asked and told no-feed is still having asked"); +}); + +test("a caller that supplies no sourceNodesApi gets a pure no-op -- evidence fetching is optional plumbing", async () => { + const { api } = makeApi(); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api); // two-arg call, exactly like every earlier test in this file + await loader.loadCollections(SCOPE); + const source = { subject: "area-1", revision: R1, root: "area-1" }; + await loader.loadSpine(SCOPE, source); + const s = useAssetBrowserStore.getState(); + assert.equal(s.evidenceAsked.size, 0); + assert.equal(s.sourceAnswer.size, 0); + // The hierarchy work itself is unaffected either way. + assert.equal(s.forest.nodes.get("member-1")?.parent, "level-1"); +}); + test("a response for a collection the user has left is dropped", async () => { const { api, gates } = makeApi(); const loader = createAssetBrowserLoader(useAssetBrowserStore, api); diff --git a/src/frontend/src/__tests__/state/assetBrowserStore.test.ts b/src/frontend/src/__tests__/state/assetBrowserStore.test.ts index 9aaf921d3..500a7436c 100644 --- a/src/frontend/src/__tests__/state/assetBrowserStore.test.ts +++ b/src/frontend/src/__tests__/state/assetBrowserStore.test.ts @@ -1,11 +1,15 @@ -// The Assets tab's own store: this file covers only the Phase-3 load-tracking -// slice added alongside `./delivery` (busy/error per row, the `loaded` mirror, -// and reconciliation against the scene's live source-name set). The rest of -// the store is exercised through `assetBrowserLoader.test.ts`. +// The Assets tab's own store: the Phase-3 load-tracking slice added alongside +// `./delivery` (busy/error per row, the `loaded` mirror, and reconciliation +// against the scene's live source-name set), and the Phase-4 change-feed +// slice (`sourceAnswer`/`changedRows`/`evidenceAsked`, folded by +// `mergeSourceAnswer`). The rest of the store -- and the LAZY FETCHING that +// drives `mergeSourceAnswer` -- is exercised through `assetBrowserLoader.test.ts`; +// this file pins the fold itself, called directly, network-free. import assert from "node:assert/strict"; import { beforeEach, test } from "node:test"; +import type { SourceNodeRow, SourceNodesAnswer } from "../../assets/changes"; import type { LoadedAsset } from "../../assets/delivery"; import { useAssetBrowserStore } from "../../state/assetBrowserStore"; @@ -79,3 +83,61 @@ test("reconcileLoaded is a no-op (same reference) when nothing changed", () => { s.reconcileLoaded(new Set([kept.sourceName])); assert.equal(useAssetBrowserStore.getState().loaded, before); }); + +// --------------------------------------------------------------------------- +// mergeSourceAnswer -- the change-feed slice +// --------------------------------------------------------------------------- + +function row(nodeRef: string, action: SourceNodeRow["action"] = null): SourceNodeRow { + return { nodeRef, parentRef: null, name: null, lastChangedAt: "2026-08-27T00:00:00Z", lastChangedBy: null, observedAt: "2026-08-27T00:00:00Z", action }; +} + +function answer(source: string, rows: readonly SourceNodeRow[]): SourceNodesAnswer { + return { source, rows: new Map(rows.map((r) => [r.nodeRef, r])), unknown: new Set() }; +} + +beforeEach(() => { + useAssetBrowserStore.setState({ + sourceAnswer: new Map(), + changedRows: new Map(), + evidenceAsked: new Set(), + }); +}); + +test("mergeSourceAnswer records every asked ref, whether or not the feed had a row for it", () => { + const s = useAssetBrowserStore.getState(); + s.mergeSourceAnswer("provider-x", answer("provider-x", [row("site-a")]), ["site-a", "site-b"]); + const after = useAssetBrowserStore.getState(); + assert.ok(after.evidenceAsked.has("site-a")); + assert.ok(after.evidenceAsked.has("site-b"), "asked and got nothing back is still having asked"); +}); + +test("mergeSourceAnswer flattens rows with a non-null action into changedRows, across providers", () => { + const s = useAssetBrowserStore.getState(); + s.mergeSourceAnswer("provider-x", answer("provider-x", [row("member-3", "modified"), row("site-a", null)]), ["member-3", "site-a"]); + s.mergeSourceAnswer("provider-y", answer("provider-y", [row("member-9", "added")]), ["member-9"]); + const after = useAssetBrowserStore.getState(); + assert.equal(after.changedRows.get("member-3")?.action, "modified"); + assert.equal(after.changedRows.has("site-a"), false, "action is null -- a roll-up row, not evidence"); + assert.equal(after.changedRows.get("member-9")?.action, "added"); +}); + +test("mergeSourceAnswer with a null answer (no-feed) replaces that provider's rows outright, not merges", () => { + const s = useAssetBrowserStore.getState(); + s.mergeSourceAnswer("provider-x", answer("provider-x", [row("member-3", "modified")]), ["member-3"]); + assert.equal(useAssetBrowserStore.getState().changedRows.has("member-3"), true); + s.mergeSourceAnswer("provider-x", null, ["member-3"]); + const after = useAssetBrowserStore.getState(); + assert.equal(after.sourceAnswer.get("provider-x"), null); + assert.equal(after.changedRows.has("member-3"), false, "a provider that just said no-feed cannot still justify a stale evidence row"); + assert.ok(after.evidenceAsked.has("member-3"), "still asked -- the feed answered no-feed, it did not go unasked"); +}); + +test("mergeSourceAnswer for one provider does not disturb another provider's rows", () => { + const s = useAssetBrowserStore.getState(); + s.mergeSourceAnswer("provider-x", answer("provider-x", [row("member-3", "modified")]), ["member-3"]); + s.mergeSourceAnswer("provider-y", null, ["member-9"]); + const after = useAssetBrowserStore.getState(); + assert.equal(after.changedRows.get("member-3")?.action, "modified", "provider-y going no-feed must not wipe provider-x's rows"); + assert.equal(after.sourceAnswer.get("provider-y"), null); +}); diff --git a/src/frontend/src/assets/assetIndex.ts b/src/frontend/src/assets/assetIndex.ts index dc8c74d9a..c4789517f 100644 --- a/src/frontend/src/assets/assetIndex.ts +++ b/src/frontend/src/assets/assetIndex.ts @@ -15,12 +15,16 @@ import { ASSET_PREFIX, STAGING_SEGMENT, parseAssetKey } from "./keys"; import type { + Actor, AssetIndex, AssetRevision, AssetSubject, + ChangeRecord, ManifestSummary, ResolutionMode, + WireActor, WireAssetIndex, + WireChangeRecord, WireManifestSummary, } from "./types"; @@ -31,6 +35,26 @@ export function compareRevisions(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } +function actorFromWire(a: WireActor | null | undefined): Actor | null { + if (!a) return null; + return { id: a.id, display: a.display ?? null, application: a.application ?? null }; +} + +/** §Decision 6: absent is normal, not degraded -- a provider whose source + * carries no authorship omits the field entirely, and this returns `null` + * rather than an object of nulls, so a row detail can tell "no change record" + * apart from "a change record that happens to say nothing" with one check. */ +function changeFromWire(c: WireChangeRecord | null | undefined): ChangeRecord | null { + if (!c) return null; + return { + publishedBy: actorFromWire(c.published_by), + publishedVia: c.published_via ?? null, + sourceActor: actorFromWire(c.source_actor), + action: c.action ?? null, + sourceInstant: c.source_instant ?? null, + }; +} + function summaryFromWire(m: WireManifestSummary | undefined): ManifestSummary | null { if (!m) return null; return { @@ -39,6 +63,7 @@ function summaryFromWire(m: WireManifestSummary | undefined): ManifestSummary | delivery: m.delivery, producedAt: m.produced_at, hierarchyRevision: m.hierarchy_revision ?? null, + change: changeFromWire(m.change), }; } diff --git a/src/frontend/src/assets/assetView.ts b/src/frontend/src/assets/assetView.ts index c2b7a4637..e09d7f03f 100644 --- a/src/frontend/src/assets/assetView.ts +++ b/src/frontend/src/assets/assetView.ts @@ -22,6 +22,15 @@ // spine to the O(n) passes below with no re-indexing. import { HIERARCHY_FILENAME, isComplete } from "./assetIndex"; +import { + classifyChanges, + evidenceMarks, + type ChangeAction, + type ChangeReport, + type ExportRoot, + type SourceNodeRow, + type SourceNodesAnswer, +} from "./changes"; import { projectCoverage, type CoverageResult } from "./coverage"; import { hierarchyDrift, nodeFreshness, staleCount, type HierarchyDrift, type NodeFreshness } from "./freshness"; import { ancestorsOf, buildHierarchyFrom, type Hierarchy } from "./hierarchy"; @@ -71,6 +80,25 @@ export interface AssetViewInput { * "nothing to deliver here" -- that would be a guess about rows nobody has * read. Omitted, every branch counts as explored. */ readonly spineLoaded?: ReadonlyMap; + /** PROVIDER id -> the change feed's last answer for that provider, or + * `null` for that provider's own no-feed. Per provider, not one flat + * answer, because a mixed collection can straddle providers with + * different feed availability (`./changes`'s `RootLookup`). Omitted, no + * root is ever classified -- see `evidenceAsked`. */ + readonly sourceAnswer?: ReadonlyMap; + /** Every node ref (a root's own subject id, or any descendant) the browser + * has asked the feed about, across every provider. A root not in this set + * is not "not-recorded" -- that would claim a fact about the FEED's + * coverage the browser has not actually asked it for -- it is simply not + * yet classified, the same honesty `unexplored` keeps for spines. */ + readonly evidenceAsked?: ReadonlySet; + /** Every row the feed has told us carries a non-null `action`, flattened + * across providers -- the per-node evidence marks. Kept as its OWN input, + * not derived from `sourceAnswer` here, because the store already + * maintains this flattening incrementally (`assetBrowserStore.changedRows`) + * as evidence trickles in per spine, and re-flattening on every view build + * would repeat that work for nothing a view needs to decide itself. */ + readonly changedRows?: ReadonlyMap; } export interface AssetView { @@ -106,6 +134,19 @@ export interface AssetView { /** subject -> why its resolved manifest could not be read. */ readonly manifestErrors: ReadonlyMap; readonly malformedKeys: readonly string[]; + /** Behind-upstream, per export root -- the change feed's answer, a + * DIFFERENT fact from `freshness` above and never folded into it (see the + * module comment in `./changes`). */ + readonly changes: ChangeReport; + /** node id -> what the sweep found there (`added`/`modified`/`deleted`). + * Per-node evidence, kept apart from `changes.byRoot`'s per-root states -- + * §Decision 7's "adopted as per-node evidence vocabulary only". */ + readonly evidenceMarks: ReadonlyMap; + /** True once at least one resolved manifest in this view carries a + * `change` with an actor (`publishedBy` or `sourceActor`). The "changed + * by" filter is offered only then (§Decision 6: absent is normal, and a + * filter over nothing is worse than no filter). */ + readonly hasChangeOwners: boolean; } export function buildAssetHierarchy(forest: Forest): Hierarchy { @@ -211,6 +252,39 @@ export function buildAssetView(input: AssetViewInput): AssetView { const providers = new Set(); for (const n of forest.nodes.values()) providers.add(n.provider); + // Export roots: every published subject this resolution names, other than + // the collection index itself (which is not a publish anyone re-exports). + // A tree-only publish is still a legal root (§Decision 3) -- carrying + // `content` is not the bar, being a resolved SUBJECT is. + const roots: ExportRoot[] = publishedSubjects.map((subject) => ({ + subject, + revision: resolution.subjects.get(subject)!.revision.revision, + })); + const evidenceAsked = input.evidenceAsked; + const sourceAnswer = input.sourceAnswer; + const changes = classifyChanges(roots, (subject) => { + if (!evidenceAsked?.has(subject)) return { asked: false, answer: null }; + const provider = hierarchy.byId.get(subject)?.data.provider; + // Defensive fallback, not an expected path: `evidenceAsked` and + // `sourceAnswer` are updated together by the same store action per + // provider group, so a subject marked asked always has its provider's + // entry too. Reading a mismatch as no-feed -- rather than `current` -- + // is the same "an unanswerable question must not look clean" rule the + // real no-feed case follows. + const answer = provider !== undefined ? (sourceAnswer?.get(provider) ?? null) : null; + return { asked: true, answer }; + }); + const evidence = evidenceMarks(input.changedRows ?? new Map()); + + let hasChangeOwners = false; + for (const resolved of resolution.subjects.values()) { + const c = resolved.revision.manifest?.change; + if (c && (c.publishedBy || c.sourceActor)) { + hasChangeOwners = true; + break; + } + } + return { collection, hierarchy, @@ -231,5 +305,8 @@ export function buildAssetView(input: AssetViewInput): AssetView { providers: [...providers].sort(), manifestErrors, malformedKeys: index.malformed, + changes, + evidenceMarks: evidence, + hasChangeOwners, }; } diff --git a/src/frontend/src/assets/changes.ts b/src/frontend/src/assets/changes.ts new file mode 100644 index 000000000..51299d397 --- /dev/null +++ b/src/frontend/src/assets/changes.ts @@ -0,0 +1,215 @@ +// Has the SOURCE moved since we published it? A different question from +// `./freshness`, answered from a different input, and drawn with a different +// mark -- see the module comment there for the boundary. This module reads +// `GET /scopes/{scope}/source-nodes`, a change feed a provider's sweep writes +// (never this module, never the browser: writing is the sweep's job, through +// the worker facade or the POST route the GET sits beside). +// +// TWO SUBJECTS, NOT ONE. An export root is the UNIT OF WORK -- it is what a +// re-export re-does -- and a changed node is the UNIT OF EVIDENCE. A reader +// told "this root is behind" still needs to know WHAT moved under it, so both +// are modelled: `classifyChanges` answers the first, `evidenceMarks` the +// second, and neither is derived from the other. +// +// FOUR STATES, AND THE PAIR THAT MUST NEVER COLLAPSE. `current` and +// `not-recorded` look identical to a careless renderer -- both mean "no bad +// news" -- and are opposite in what they claim. A ref ABSENT from an answer +// the feed actually gave means NOBODY LOOKED: the sweep never covered that +// root, and rendering it as `current` turns "we have no idea" into "you are +// fine", the one mistake a change feed exists to prevent. `no-feed` is a +// third, separate "cannot say": the deployment has no database at all +// (`_source_nodes_pool`, 503) and answered NOTHING, so nothing this module +// classifies from `no-feed` may be `current` either -- see `classifyChanges`. +// +// WHY THIS MODULE DOES NOT COMPARE TIMESTAMPS BLINDLY. A row present in the +// answer means the feed recorded something for that ref -- an actual change, +// or the roll-up mark an ancestor gets when something changed beneath it +// (§Decision 4). Its `last_changed_at` is compared against the export root's +// own resolved REVISION, in the compact-UTC revision domain (`./keys`) rather +// than as two independently-parsed Date objects: the revision token's whole +// contract is that lexical order is chronological order, and converting the +// feed's ISO instant into that same domain once, here, keeps every comparison +// in this module reading off the one ordering the rest of the asset store +// already trusts, instead of re-deriving it per call site. + +import { compareRevisions } from "./assetIndex"; +import { revisionFromInstant } from "./keys"; +import type { WireSourceNodeRow, WireSourceNodesRefsResponse } from "./types"; + +/** A node the sweep found added, modified or deleted. NOCHANGE is never a + * value the feed stores (§Decision 7): a row's absence already means "no + * change was recorded here", and writing a `nochange` row for every + * untouched node would turn the feed into a full mirror of the source. */ +export type ChangeAction = "added" | "modified" | "deleted"; + +export interface SourceNodeRow { + readonly nodeRef: string; + readonly parentRef: string | null; + readonly name: string | null; + readonly lastChangedAt: string; + readonly lastChangedBy: string | null; + readonly observedAt: string; + readonly action: ChangeAction | null; +} + +/** One `GET .../source-nodes?source=&refs=...` reply, parsed. `source` is the + * provider id the refs were asked under -- a mixed collection asks more than + * one, and each source's rows (and each source's own no-feed-ness) are kept + * apart by the caller (`assetBrowserStore.sourceAnswer` is keyed by it). */ +export interface SourceNodesAnswer { + readonly source: string; + readonly rows: ReadonlyMap; + readonly unknown: ReadonlySet; +} + +export function sourceNodesAnswerFromWire(wire: WireSourceNodesRefsResponse): SourceNodesAnswer { + const rows = new Map(); + for (const n of wire.nodes) rows.set(n.node_ref, sourceNodeRowFromWire(n)); + return { source: wire.source, rows, unknown: new Set(wire.unknown) }; +} + +function sourceNodeRowFromWire(n: WireSourceNodeRow): SourceNodeRow { + return { + nodeRef: n.node_ref, + parentRef: n.parent_ref ?? null, + name: n.name ?? null, + lastChangedAt: n.last_changed_at, + lastChangedBy: n.last_changed_by ?? null, + observedAt: n.observed_at, + action: n.action ?? null, + }; +} + +// --------------------------------------------------------------------------- +// root state: behind | current | not-recorded | no-feed +// --------------------------------------------------------------------------- + +export type ChangeState = "behind" | "current" | "not-recorded" | "no-feed"; + +/** One export root: a published subject and the revision it is resolved to + * right now (`resolution.subjects`, not the newest revision overall -- the + * question is "is THIS publish behind", not "is the newest one"). */ +export interface ExportRoot { + readonly subject: string; + readonly revision: string; +} + +export interface RootChange { + readonly subject: string; + readonly state: ChangeState; + /** The revision this state was judged against. */ + readonly revision: string; + readonly lastChangedAt: string | null; + readonly lastChangedBy: string | null; + /** What the feed's own row said happened, when it has an opinion (a pure + * roll-up ancestor row carries none, only the changed node itself does). */ + readonly action: ChangeAction | null; +} + +export interface ChangeReport { + /** subject -> its state. A subject ABSENT here was never asked about -- + * see `classifyChanges`'s `asked` parameter -- which is a third thing + * again, distinct from all four states: "not asked" is not "not-recorded", + * because "not-recorded" is a claim about the FEED's coverage and "not + * asked" is a claim about the BROWSER's, and only the browser's own + * `evidenceAsked` set may tell that one apart from real ignorance. */ + readonly byRoot: ReadonlyMap; + readonly behind: number; + readonly current: number; + readonly notRecorded: number; +} + +const EMPTY_REPORT: ChangeReport = Object.freeze({ + byRoot: new Map(), + behind: 0, + current: 0, + notRecorded: 0, +}); + +/** What a caller (`./assetView`) knows about one root: whether the browser + * ever asked the feed about it, and if so, the answer it got back for the + * PROVIDER that root belongs to. `answer: null` is that provider's own + * no-feed (its source has no database, or no source at all in this scope); + * it is looked up per call rather than passed as one flat answer because a + * mixed collection can straddle providers with different feed availability, + * and this module has no notion of "provider" of its own to key by. */ +export type RootLookup = (subject: string) => { readonly asked: boolean; readonly answer: SourceNodesAnswer | null }; + +function noFeedEntry(root: ExportRoot): RootChange { + return { subject: root.subject, state: "no-feed", revision: root.revision, lastChangedAt: null, lastChangedBy: null, action: null }; +} + +function notRecordedEntry(root: ExportRoot): RootChange { + return { subject: root.subject, state: "not-recorded", revision: root.revision, lastChangedAt: null, lastChangedBy: null, action: null }; +} + +/** + * Classify every export root against the change feed. + * + * A root not yet ASKED about (`lookup(subject).asked === false`) gets no + * entry at all -- not `not-recorded`, which would claim a fact about the feed + * the browser has not actually asked it for. Once asked, `answer === null` + * is that root's provider answering no-feed, and EVERY root on that provider + * is `no-feed`, never `current`: an unanswerable question must not render as + * a clean bill (the same rule `no-feed`'s own definition states). + */ +export function classifyChanges(roots: readonly ExportRoot[], lookup: RootLookup): ChangeReport { + if (!roots.length) return EMPTY_REPORT; + const byRoot = new Map(); + let behind = 0; + let current = 0; + let notRecorded = 0; + for (const root of roots) { + const { asked, answer } = lookup(root.subject); + if (!asked) continue; + if (answer === null) { + byRoot.set(root.subject, noFeedEntry(root)); + continue; + } + const row = answer.rows.get(root.subject); + if (!row) { + notRecorded++; + byRoot.set(root.subject, notRecordedEntry(root)); + continue; + } + // Strictly greater: a root published in the same instant as the change it + // already includes is not behind it -- `>=` would mark every export + // behind its own recorded change. + let isBehind: boolean; + try { + isBehind = compareRevisions(revisionFromInstant(row.lastChangedAt), root.revision) > 0; + } catch { + // A malformed instant is the feed's fault, not grounds to alarm the + // reader over a row they cannot act on -- read as `current` rather + // than let one bad timestamp throw the whole classification. + isBehind = false; + } + if (isBehind) behind++; + else current++; + byRoot.set(root.subject, { + subject: root.subject, + state: isBehind ? "behind" : "current", + revision: root.revision, + lastChangedAt: row.lastChangedAt, + lastChangedBy: row.lastChangedBy, + action: row.action, + }); + } + return { byRoot, behind, current, notRecorded }; +} + +// --------------------------------------------------------------------------- +// per-node evidence: which nodes the sweep actually touched +// --------------------------------------------------------------------------- + +/** node ref -> what the sweep recorded for it. Built straight from whatever + * rows have been merged into the store so far (`assetBrowserStore.changedRows`) + * -- a ref simply absent here was either never asked or asked and found to + * carry no `action` (a roll-up ancestor, or a node the feed never mentions), + * and both render as nothing: an evidence mark is only ever a positive + * claim, never inferred from silence. */ +export function evidenceMarks(changedRows: ReadonlyMap): ReadonlyMap { + const marks = new Map(); + for (const [ref, row] of changedRows) if (row.action) marks.set(ref, row.action); + return marks; +} diff --git a/src/frontend/src/assets/rowFacts.ts b/src/frontend/src/assets/rowFacts.ts index 47f0f9123..771d964c2 100644 --- a/src/frontend/src/assets/rowFacts.ts +++ b/src/frontend/src/assets/rowFacts.ts @@ -8,9 +8,10 @@ import type { AssetView } from "./assetView"; import { ROLE_CONTENT } from "./assetView"; +import type { ChangeAction, ChangeState } from "./changes"; import type { HierarchyDrift, NodeFreshness } from "./freshness"; import { ancestorsOf, type Hierarchy } from "./hierarchy"; -import type { AssetNode, DeliveryKind } from "./types"; +import type { AssetNode, ChangeRecord, DeliveryKind } from "./types"; export type BadgeWeight = "solid" | "ghost" | "below"; @@ -42,6 +43,19 @@ export interface RowFacts { readonly drift: HierarchyDrift | null; /** The revision this row's own subject resolved to, if it is one. */ readonly resolvedRevision: string | null; + /** Behind-upstream, ONLY when this row is itself an export root the feed + * has been asked about (`view.changes.byRoot`). `null` for every other + * row -- including an unasked root -- never a guessed `not-recorded`. + * A DIFFERENT fact from `freshness` above; see `./changes`'s module + * comment for why the two must never share a mark. */ + readonly changeState: ChangeState | null; + /** What the sweep found AT this node (`added`/`modified`/`deleted`), + * independent of `changeState` -- a node deep under a `behind` root that + * the sweep did not itself touch has no mark here, only its root does. */ + readonly evidenceMark: ChangeAction | null; + /** This row's own subject's `change` record (§Decision 6), when its + * resolved manifest carries one. `null` is the normal case. */ + readonly changeRecord: ChangeRecord | null; } function deliveryOf(view: AssetView, subject: string): { delivery: DeliveryKind; revision: string } | null { @@ -79,9 +93,48 @@ export function rowFacts(view: AssetView, id: string): RowFacts | null { freshness: view.freshness.get(id) ?? null, drift: view.drift.get(id) ?? null, resolvedRevision: view.resolution.subjects.get(id)?.revision.revision ?? null, + changeState: view.changes.byRoot.get(id)?.state ?? null, + evidenceMark: view.evidenceMarks.get(id) ?? null, + changeRecord: view.resolution.subjects.get(id)?.revision.manifest?.change ?? null, }; } +export interface ChangeOwner { + readonly id: string; + readonly display: string | null; +} + +/** Distinct actors across every resolved subject's `change` record in this + * view -- both `publishedBy` (core-verified) and `sourceActor` (merely + * relayed) -- deduped by id. What the "changed by" filter offers; empty + * exactly when `view.hasChangeOwners` is false, which is the gate that + * decides whether the filter is shown at all: §Decision 6 says a manifest + * with no actor is the NORMAL case, and a filter over zero owners is worse + * than no filter -- it invites a click that can only ever find nothing. */ +export function changeOwners(view: AssetView): readonly ChangeOwner[] { + const seen = new Map(); + for (const resolved of view.resolution.subjects.values()) { + const c = resolved.revision.manifest?.change; + if (!c) continue; + for (const a of [c.publishedBy, c.sourceActor]) { + if (a && !seen.has(a.id)) seen.set(a.id, { id: a.id, display: a.display }); + } + } + return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + +/** Every subject whose OWN `change` record names `actorId`, as either the + * verified publisher or the relayed source actor -- what the filter narrows + * the tree to once an owner is chosen. */ +export function subjectsByOwner(view: AssetView, actorId: string): readonly string[] { + const out: string[] = []; + for (const [subject, resolved] of view.resolution.subjects) { + const c = resolved.revision.manifest?.change; + if (c && (c.publishedBy?.id === actorId || c.sourceActor?.id === actorId)) out.push(subject); + } + return out; +} + /** The rows a search keeps: every match and every ancestor of one, so the set * is ancestor-closed as `flattenVisible`'s `include` requires. Also returns the * ancestors, which the tab treats as expanded while the search is active -- diff --git a/src/frontend/src/assets/types.ts b/src/frontend/src/assets/types.ts index 27cea623d..6d13478ce 100644 --- a/src/frontend/src/assets/types.ts +++ b/src/frontend/src/assets/types.ts @@ -28,6 +28,31 @@ export interface WireHierarchySlice { readonly rows: readonly (readonly unknown[])[]; } +/** Whoever made a change, on the wire -- adopted under core's own names + * (§Decision 7); nothing here is format-specific. */ +export interface WireActor { + readonly id: string; + readonly display?: string | null; + readonly application?: string | null; +} + +/** Authorship recorded on ONE publish (§Decision 6). Every field optional, and + * an absent `WireChangeRecord` -- the field missing from `WireManifestSummary` + * entirely -- is a complete manifest, not a degraded one: a provider whose + * source carries no authorship leaves it out and the tab shows nothing, never + * a gap or an "unknown". `published_by`/`published_via` are core-stamped and + * trustworthy; `source_actor` is merely RELAYED by the provider from its own + * source and rendered "source says", never presented as the publisher -- + * that split is the whole point of the field, see `./changes` and + * `AssetsTab`'s `Detail`. */ +export interface WireChangeRecord { + readonly published_by?: WireActor | null; + readonly published_via?: "user" | "service" | null; + readonly source_actor?: WireActor | null; + readonly action?: "added" | "modified" | "deleted" | null; + readonly source_instant?: string | null; +} + /** The per-revision manifest fields the index route folds in with `manifests=true`. */ export interface WireManifestSummary { readonly provider: string; @@ -35,6 +60,7 @@ export interface WireManifestSummary { readonly delivery: DeliveryKind; readonly produced_at: string; readonly hierarchy_revision?: string | null; + readonly change?: WireChangeRecord | null; } export interface WireRevision { @@ -88,12 +114,29 @@ export interface HierarchySlice { readonly nodes: readonly AssetNode[]; } +export interface Actor { + readonly id: string; + readonly display: string | null; + readonly application: string | null; +} + +export interface ChangeRecord { + readonly publishedBy: Actor | null; + readonly publishedVia: "user" | "service" | null; + readonly sourceActor: Actor | null; + readonly action: "added" | "modified" | "deleted" | null; + readonly sourceInstant: string | null; +} + export interface ManifestSummary { readonly provider: string; readonly node: string | null; readonly delivery: DeliveryKind; readonly producedAt: string; readonly hierarchyRevision: string | null; + /** Present only when the manifest carries one -- §Decision 6, absent is + * normal and must render as nothing (no badge, no dimming, no "unknown"). */ + readonly change: ChangeRecord | null; } export interface AssetRevision { @@ -187,3 +230,35 @@ export interface WireBuildAssetResponse { readonly job_id: string | null; readonly cached: boolean; } + +// --- the change feed (`GET /scopes/{scope}/source-nodes`) ---------------------- +// +// A different backend subsystem from the asset store above (`routes/source_nodes.py`, +// not `routes/assets.py`) -- kept in this file anyway so `./changes` and +// `services/api/sourceNodes` have exactly one place to import wire shapes from, +// the same discipline every other module in this directory already keeps. + +export interface WireSourceNodeRow { + readonly node_ref: string; + readonly parent_ref: string | null; + readonly name: string | null; + readonly last_changed_at: string; + readonly last_changed_by: string | null; + readonly observed_at: string; + /** Nullable, and absent entirely on a deployment that has not migrated the + * column in yet (additive, migration 030): only a node the sweep found + * added/modified/deleted carries one -- a pure roll-up ancestor row does + * not, and NOCHANGE is never a value this column holds (§Decision 7). */ + readonly action?: "added" | "modified" | "deleted" | null; +} + +/** `GET .../source-nodes?source=&refs=a,b,c`'s reply. `unknown` names refs the + * feed has never recorded a row for, so a caller does not have to infer that + * from absence in `nodes` -- though `./changes` treats the two identically, + * since both mean "nobody looked". */ +export interface WireSourceNodesRefsResponse { + readonly scope: string; + readonly source: string; + readonly nodes: readonly WireSourceNodeRow[]; + readonly unknown: readonly string[]; +} diff --git a/src/frontend/src/components/asset_browser/AssetTree.tsx b/src/frontend/src/components/asset_browser/AssetTree.tsx index eb2b25ddd..5567d5cdd 100644 --- a/src/frontend/src/components/asset_browser/AssetTree.tsx +++ b/src/frontend/src/components/asset_browser/AssetTree.tsx @@ -8,18 +8,25 @@ // A row receives the view and its id and nothing else (`rowFacts`), so every // mark on it is one function of one derived object. // -// FOUR WAYS A ROW CAN READ AS LESS THAN ORDINARY, kept visually apart: -// dimmed nothing at or below to deliver -- reduced opacity, and it SAYS so -// in its title. Rendered, never hidden: "there is nothing here" is -// an answer, a missing row is not. -// gap something below, no publish covers it -- an amber `gap` tag. -// stale drawn from a spine the resolution moved past -- a `stale` tag. -// drift published against an older tree -- an amber `older tree` tag. +// SIX WAYS A ROW CAN READ AS LESS THAN ORDINARY, kept visually apart: +// dimmed nothing at or below to deliver -- reduced opacity, and it SAYS so +// in its title. Rendered, never hidden: "there is nothing here" is +// an answer, a missing row is not. +// gap something below, no publish covers it -- an amber `gap` tag. +// stale drawn from a spine the resolution moved past -- a gray `stale` +// tag. Fixed by Refresh. +// drift published against an older tree -- an amber `older tree` tag. +// behind (change feed) the SOURCE moved after this root was published -- +// a RED chip, never the same mark as `stale`: fixed only by a new +// export, and Refresh does nothing for it. +// evidence (change feed) the sweep found THIS node added/modified/deleted -- +// a purple per-node letter, independent of the root's own chip. import React, { useMemo, useRef } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { AssetView } from "@/assets/assetView"; +import type { ChangeAction, ChangeState } from "@/assets/changes"; import { flattenVisible } from "@/assets/hierarchy"; import { rowFacts, searchRows, type RowBadge } from "@/assets/rowFacts"; import { canFetchSpine, rowSpineState, type SpineSource } from "@/assets/spines"; @@ -65,6 +72,57 @@ const Tag: React.FC<{ tone: "amber" | "gray"; title: string; children: React.Rea ); +// BEHIND-UPSTREAM is a change-feed fact, never the same mark as `stale` +// (freshness, gray) or `drift` (hierarchy, amber) above: a red family, its +// own word per state, so a row that is stale, drifted AND behind at once +// shows three visibly different tags rather than one overloaded amber dot. +const CHANGE_CHIP: Record = { + behind: { + cls: "bg-red-800/70 text-red-100", + label: "behind", + title: "The source moved after this root was published. Re-export to catch up -- Refresh will not fix this.", + }, + current: { + cls: "bg-emerald-800/60 text-emerald-100", + label: "current", + title: "The change feed covered this root and found nothing newer at the source.", + }, + "not-recorded": { + cls: "bg-gray-600 text-gray-300", + label: "not recorded", + title: "The change feed has never covered this root -- nobody has looked, which is not the same as unchanged.", + }, + "no-feed": { + cls: "bg-gray-700 text-gray-400 italic", + label: "no feed", + title: "This deployment has no change-feed database. Whether the source moved cannot be said.", + }, +}; + +const ChangeChip: React.FC<{ state: ChangeState }> = ({ state }) => { + const c = CHANGE_CHIP[state]; + return ( + + {c.label} + + ); +}; + +const EVIDENCE_LETTER: Record = { added: "+", modified: "~", deleted: "−" }; + +// Per-NODE evidence -- what the sweep found AT this row -- is a purple +// family, deliberately apart from the root-level red `ChangeChip`: a leaf the +// sweep flagged `modified` inside a root already marked `behind` would +// otherwise repaint the same fact twice in the same colour. +const EvidenceMark: React.FC<{ action: ChangeAction }> = ({ action }) => ( + + {EVIDENCE_LETTER[action]} + +); + const AssetRow: React.FC<{ view: AssetView; id: string; @@ -114,6 +172,8 @@ const AssetRow: React.FC<{ {facts.payload} )} {facts.badge && } + {facts.changeState && } + {facts.evidenceMark && } {facts.gap && gap} {spine.deadEnd && ( no subtree diff --git a/src/frontend/src/components/asset_browser/AssetsTab.tsx b/src/frontend/src/components/asset_browser/AssetsTab.tsx index c77610d21..3cd89da3b 100644 --- a/src/frontend/src/components/asset_browser/AssetsTab.tsx +++ b/src/frontend/src/components/asset_browser/AssetsTab.tsx @@ -19,6 +19,7 @@ import React, { useEffect, useMemo } from "react"; import { revisionsOf } from "@/assets/assetIndex"; import { buildAssetHierarchy, buildAssetView, type AssetView } from "@/assets/assetView"; +import type { ChangeState } from "@/assets/changes"; import { assetSourceName, loadNode, @@ -27,7 +28,7 @@ import { type NodeRef, } from "@/assets/delivery"; import { orphanHeading, orphanSentence, type OrphanEntry } from "@/assets/orphans"; -import { rowFacts, type RowBadge } from "@/assets/rowFacts"; +import { changeOwners, rowFacts, subjectsByOwner, type RowBadge } from "@/assets/rowFacts"; import { canFetchSpine } from "@/assets/spines"; import type { ResolutionMode } from "@/assets/types"; import type { TreeNodeData } from "@/components/tree_view/CustomNode"; @@ -35,6 +36,7 @@ import { makePluginContextStandalone } from "@/plugins"; import { assetsApi } from "@/services/api/assets"; import { conversionApi } from "@/services/api/conversion"; import { filesApi } from "@/services/api/files"; +import { sourceNodesApi } from "@/services/api/sourceNodes"; import { useViewerStores } from "@/state/AdaViewerContext"; import { loaderFor } from "@/state/assetBrowserLoader"; import { useModelSessionStore } from "@/state/modelSession"; @@ -125,6 +127,16 @@ function loadedTreeRoot(treeData: TreeNodeData | null, sourceName: string): Tree return candidates.find((c) => c.model_key === modelKey) ?? null; } +// Row-detail wording for the three non-`behind` states (`behind` gets its own +// sentence above, with the change itself). Kept as data so the four states' +// words are declared once rather than re-typed at each render. +const CHANGE_STATE_LABEL: Record = { + behind: "behind — see the line above", + current: "up to date — the change feed covered this root and found nothing newer", + "not-recorded": "not recorded — the change feed has never covered this root", + "no-feed": "unknown — this deployment has no change-feed database", +}; + const Banner: React.FC<{ tone: "info" | "warn" | "error"; children: React.ReactNode; title?: string }> = ({ tone, children, @@ -378,6 +390,31 @@ const Detail: React.FC<{ view: AssetView; id: string; scope: string }> = ({ view if (facts?.drift) { lines.push(["Tree", `published against ${formatRevision(facts.drift.publishedAgainst)}; shown from ${formatRevision(facts.drift.shownFrom)}`]); } + // Behind-upstream is its OWN line, never merged into "Drawn from" above: + // that line is about OUR spine lagging OUR resolution (fixed by Refresh), + // this one is about the SOURCE moving past what was published (fixed only + // by a new export). Same sentence for both would say the wrong fix. + const change = view.changes.byRoot.get(id); + if (change && change.state === "behind") { + lines.push([ + "Behind source", + `changed ${change.lastChangedAt ?? "—"}${change.lastChangedBy ? ` by ${change.lastChangedBy}` : ""} — after this was published; re-export to catch up`, + ]); + } else if (change) { + lines.push(["Source", CHANGE_STATE_LABEL[change.state]]); + } + // §Decision 6: two separately-labelled facts, never merged into one + // "author" -- `publishedBy` is core-stamped and trustworthy, `sourceActor` + // is merely relayed by the provider from its own source. Absent is the + // normal case and renders nothing at all, not a placeholder. + if (facts?.changeRecord?.publishedBy) { + const a = facts.changeRecord.publishedBy; + lines.push(["Published by", a.display ? `${a.display} (${a.id})` : a.id]); + } + if (facts?.changeRecord?.sourceActor) { + const a = facts.changeRecord.sourceActor; + lines.push(["Source says", a.display ? `${a.display} (${a.id})` : a.id]); + } if (orphan) lines.push(["Not in tree", orphanSentence(orphan, formatRevision)]); const err = view.manifestErrors.get(id); if (err) lines.push(["Manifest", err]); @@ -397,10 +434,61 @@ const Detail: React.FC<{ view: AssetView; id: string; scope: string }> = ({ view ); }; +/** §Decision 6's "changed by" filter -- offered ONLY when `view.hasChangeOwners` + * (at least one loaded manifest carries a `publishedBy` or `sourceActor`); + * otherwise this renders nothing, not a disabled control, because a filter + * over zero owners is a dead end dressed up as an affordance. Narrows to a + * flat clickable list rather than pruning the tree itself -- the same choice + * `Orphans` below makes for the same reason: an owner is a property of a + * SUBJECT, not a shape the hierarchy needs to know about, and jumping + * `select()` to a match is enough to act on it. */ +const ChangedByFilter: React.FC<{ view: AssetView; selected: string | null; onSelect: (id: string) => void }> = ({ + view, + selected, + onSelect, +}) => { + const [owner, setOwner] = React.useState(""); + if (!view.hasChangeOwners) return null; + const owners = changeOwners(view); + const matches = owner ? subjectsByOwner(view, owner) : []; + return ( +
+ + {owner && ( + + {matches.length} subject(s) + {matches.map((id) => ( + + ))} + + )} +
+ ); +}; + const AssetsTab: React.FC = () => { const { useAssetBrowserStore, useScopeStore } = useViewerStores(); const scope = scopeUrlPart(useScopeStore((s) => s.current)); - const loader = loaderFor(useAssetBrowserStore, assetsApi); + const loader = loaderFor(useAssetBrowserStore, assetsApi, sourceNodesApi); const storeScope = useAssetBrowserStore((s) => s.scope); const collections = useAssetBrowserStore((s) => s.collections); @@ -416,6 +504,9 @@ const AssetsTab: React.FC = () => { const spineLoaded = useAssetBrowserStore((s) => s.spineLoaded); const spineLoading = useAssetBrowserStore((s) => s.spineLoading); const spineErrors = useAssetBrowserStore((s) => s.spineErrors); + const sourceAnswer = useAssetBrowserStore((s) => s.sourceAnswer); + const changedRows = useAssetBrowserStore((s) => s.changedRows); + const evidenceAsked = useAssetBrowserStore((s) => s.evidenceAsked); const selected = useAssetBrowserStore((s) => s.selected); const searchTerm = useAssetBrowserStore((s) => s.searchTerm); const { setMode, select, setSearchTerm } = useAssetBrowserStore.getState(); @@ -441,11 +532,22 @@ const AssetsTab: React.FC = () => { const view = useMemo( () => index && collection - ? buildAssetView({ forest, index, collection, mode, indexRevisions: merged, hierarchy, spineLoaded }) + ? buildAssetView({ + forest, + index, + collection, + mode, + indexRevisions: merged, + hierarchy, + spineLoaded, + sourceAnswer, + changedRows, + evidenceAsked, + }) : null, // `forest` changes exactly when `hierarchy` does. // eslint-disable-next-line react-hooks/exhaustive-deps - [hierarchy, index, collection, mode, merged, spineLoaded], + [hierarchy, index, collection, mode, merged, spineLoaded, sourceAnswer, changedRows, evidenceAsked], ); // Lazy spines: an expanded row whose covering spine is not in (at the @@ -512,6 +614,7 @@ const AssetsTab: React.FC = () => { onChange={(e) => setSearchTerm(e.target.value)} /> + {view && }
{indexError && {indexError}} @@ -533,6 +636,17 @@ const AssetsTab: React.FC = () => { {view.staleCount} row(s) are drawn from a hierarchy the resolution has moved past. Refresh to rebuild. )} + {/* BEHIND is not STALE: stale says our own tree lags the resolution + (fixed by Refresh); behind says the SOURCE moved after a root was + published (fixed only by a new export). Different tone ("error", not + "warn"), different verb, so the two are never mistaken for one banner + said twice -- see `@/assets/changes`'s module comment. */} + {view && view.changes.behind > 0 && ( + + {view.changes.behind} published root(s) are behind their source — it changed after this was + published. Re-export to catch up; Refresh will not fix this. + + )} {view && view.drift.size > 0 && ( {view.drift.size} subject(s) were published against an older tree than the one shown. )} diff --git a/src/frontend/src/services/api/sourceNodes.ts b/src/frontend/src/services/api/sourceNodes.ts new file mode 100644 index 000000000..528d3e3d8 --- /dev/null +++ b/src/frontend/src/services/api/sourceNodes.ts @@ -0,0 +1,70 @@ +// The change feed's fetch side: `GET /api/scopes/{scope}/source-nodes` +// (`ada/comms/rest/routes/source_nodes.py`). Read-only: the browser only ever +// ASKS this feed. Writing belongs to a provider's own sweep, through the +// worker's `source_nodes` facade or the POST route the GET sits beside in +// that same file -- never a path this module exposes. +// +// Returns the WIRE shape parsed into `@/assets/changes`'s model; nothing else +// in the frontend reads `WireSourceNodesRefsResponse` directly. + +import { runtime } from "@/runtime/config"; + +import { sourceNodesAnswerFromWire, type SourceNodeRow, type SourceNodesAnswer } from "@/assets/changes"; +import type { WireSourceNodesRefsResponse } from "@/assets/types"; + +import { authedFetch, jsonOrThrow, type ScopeUrl } from "./client"; + +function base(scope: ScopeUrl): string { + return `${runtime.apiBase()}/scopes/${encodeURIComponent(scope)}/source-nodes`; +} + +// The route defaults its own `limit` to 1000 and 400s a `refs=` list longer +// than that; this keeps every request comfortably under it (headroom for the +// `source`/URL overhead) rather than trusting every caller to know the +// server's cap. Callers still fetch lazily per spine (§Decision 4's frontend +// half) -- chunking here is a safety net for one large spine, not licence to +// ask for a whole 41k-row tree in one go. +const REFS_PER_REQUEST = 900; + +function chunk(items: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +/** + * Ask the change feed about exactly these refs (an export root's subject id, + * or any node ref beneath it -- the route makes no distinction). + * + * Returns `null` on a 503, mapping the deployment's "this runs without a + * database" answer (`_source_nodes_pool`) onto `no-feed`, one of the change + * feed's four legitimate states (`@/assets/changes`) -- NOT a transport + * fault. This is deliberately not error-swallowing: `no-feed` is a + * first-class value `classifyChanges` renders as its own state (nothing + * marked `current`, which a caught-and-ignored exception would risk if a + * caller's `catch` silently kept stale data instead); every other status + * code still throws through `jsonOrThrow` exactly as any other route in + * `services/api/` does, so a real fault (500, a network error, an + * authorization failure) is still a fault and still surfaces as one. + */ +export async function getSourceNodes( + scope: ScopeUrl, + source: string, + refs: readonly string[], +): Promise { + if (refs.length === 0) return { source, rows: new Map(), unknown: new Set() }; + const answerRows = new Map(); + const unknown = new Set(); + for (const part of chunk(refs, REFS_PER_REQUEST)) { + const q = new URLSearchParams({ source, refs: part.join(",") }); + const r = await authedFetch(`${base(scope)}?${q}`); + if (r.status === 503) return null; + const wire = await jsonOrThrow(r, `getSourceNodes(${source})`); + const answer = sourceNodesAnswerFromWire(wire); + for (const [ref, row] of answer.rows) answerRows.set(ref, row); + for (const u of answer.unknown) unknown.add(u); + } + return { source, rows: answerRows, unknown }; +} + +export const sourceNodesApi = { getSourceNodes }; diff --git a/src/frontend/src/state/assetBrowserLoader.ts b/src/frontend/src/state/assetBrowserLoader.ts index 1577edf43..51b26c4b3 100644 --- a/src/frontend/src/state/assetBrowserLoader.ts +++ b/src/frontend/src/state/assetBrowserLoader.ts @@ -10,7 +10,8 @@ // shared node wins. When a mode change admits an index older than one already // merged, the newer ones are re-merged after it from the cache. -import { collectionIndexRevisions, compareRevisions, defaultCollection, indexFromWire } from "@/assets/assetIndex"; +import { collectionIndexRevisions, compareRevisions, defaultCollection, indexFromWire, subjectsOf } from "@/assets/assetIndex"; +import type { SourceNodesAnswer } from "@/assets/changes"; import { parseHierarchySlice } from "@/assets/projection"; import type { SpineSource } from "@/assets/spines"; import type { AssetNode, WireAssetIndex, WireHierarchySlice } from "@/assets/types"; @@ -27,6 +28,15 @@ export interface AssetsApiLike { ): Promise; } +/** The change feed's fetch side (`services/api/sourceNodes`), injected the + * same way `AssetsApiLike` is -- so this loader stays drivable under + * `node --test` against canned answers, and so a caller that does not care + * about the change feed (most of today's tests) need not supply one at all: + * evidence fetching is then simply a no-op, never a crash. */ +export interface SourceNodesApiLike { + getSourceNodes(scope: string, source: string, refs: readonly string[]): Promise; +} + export interface StoreLike { getState(): AssetBrowserState; } @@ -39,7 +49,7 @@ function message(e: unknown): string { return e instanceof Error ? e.message : String(e); } -export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike) { +export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike, sourceNodesApi?: SourceNodesApiLike) { const indexSlices = new Map(); // `${collection}@${revision}` let generation = 0; // bumped on scope/collection change; stale responses are dropped @@ -48,6 +58,57 @@ export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike) { return gen === generation && s.scope === scope && (collection === null || s.collection === collection); }; + /** Ask the change feed about `refs` under `source` (a provider id), and + * fold the answer into the store. Best-effort: a hiccup here must not read + * as a hierarchy-fetch failure -- the spine or index fetch it rides along + * with has ALREADY SUCCEEDED by the time this runs, and the change feed is + * supplementary. Refs already in `evidenceAsked` are dropped before the + * request, the dedup `loadSpine`'s own re-fetch guard already relies on + * for hierarchy slices, applied here to the refs granularity instead of + * the spine-root granularity. On failure nothing is marked asked, so the + * NEXT spine load or root sync retries rather than black-holing a + * transient error into a permanent "no-feed". */ + async function loadEvidence(scope: string, source: string, refs: readonly string[]): Promise { + if (!sourceNodesApi || !refs.length) return; + const gen = generation; + const s = store.getState(); + const collection = s.collection; + const missing = refs.filter((r) => !s.evidenceAsked.has(r)); + if (!missing.length) return; + try { + const answer = await sourceNodesApi.getSourceNodes(scope, source, missing); + if (!alive(gen, scope, collection)) return; + store.getState().mergeSourceAnswer(source, answer, missing); + } catch { + // Best-effort; see the function comment. Nothing is marked asked. + } + } + + /** Every published root (a resolved subject other than the collection + * itself) gets asked about EAGERLY, independent of whether its row has + * ever been expanded -- the root-state chip must not wait on the user + * opening a branch. Bounded by the number of published subjects (typically + * tens), never by the size of any subject's own subtree, which is what + * keeps this "cheap and eager" rather than "ask for 41k refs". Grouped by + * each subject's OWN manifest provider, because `source` names an external + * system and core has no truer identifier for "whose feed is this" than + * the provider that published the subject. */ + async function loadRootEvidence(scope: string, collection: string): Promise { + if (!sourceNodesApi) return; + const s = store.getState(); + if (!s.index) return; + const byProvider = new Map(); + for (const [subject, entry] of subjectsOf(s.index, collection)) { + if (subject === collection) continue; // the collection's own index entry, not an export root + const newest = [...entry.revisions].reverse().find((r) => r.manifest); + if (!newest?.manifest) continue; // nothing complete published here yet -- nothing to ask about + const refs = byProvider.get(newest.manifest.provider) ?? []; + refs.push(subject); + byProvider.set(newest.manifest.provider, refs); + } + for (const [provider, refs] of byProvider) await loadEvidence(scope, provider, refs); + } + async function loadCollections(scope: string): Promise { const gen = ++generation; const s = store.getState(); @@ -94,6 +155,13 @@ export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike) { // Nothing to fetch; only the admitted set changed (e.g. `run` narrowing). s.setMergedIndexRevisions(wanted); } + // Root evidence is asked unconditionally on every sync, including this + // no-new-index-to-fetch path (a `run` narrowing, or simply the first + // sync after `openCollection` set the index): `loadRootEvidence` is + // idempotent per ref (`evidenceAsked`), so the extra call costs nothing + // once the refs are already known, and is the only way a fresh + // collection's roots get asked at all. + await loadRootEvidence(scope, collection); return; } const fetched = await Promise.all( @@ -120,6 +188,7 @@ export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike) { }); } cur.setMergedIndexRevisions(wanted); + await loadRootEvidence(scope, collection); } /** Fetch and merge one subtree spine. Idempotent per (root, revision). */ @@ -140,6 +209,10 @@ export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike) { const cur = store.getState(); cur.mergeSlice(slice.nodes, { subject: source.subject, revision: source.revision, root: source.root }); cur.endSpine(source.root, source.revision); + // Per-node evidence, lazily: exactly this spine's own refs (its root + // plus whatever it just brought in), never the whole tree. `wire.provider` + // is who produced this slice -- and so who would know whether it moved. + await loadEvidence(scope, wire.provider, [source.root, ...slice.nodes.map((n) => n.id)]); } catch (e) { if (alive(gen, scope, collection)) store.getState().failSpine(source.root, message(e)); } @@ -172,7 +245,17 @@ export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike) { await openCollection(scope, s.collection); } - return { loadCollections, openCollection, syncCollectionIndexes, loadSpine, loadSpines, chooseCollection, refresh }; + return { + loadCollections, + openCollection, + syncCollectionIndexes, + loadSpine, + loadSpines, + chooseCollection, + refresh, + loadEvidence, + loadRootEvidence, + }; } export type AssetBrowserLoader = ReturnType; @@ -180,11 +263,13 @@ export type AssetBrowserLoader = ReturnType; const LOADERS = new WeakMap(); /** One loader per store instance, so its slice cache and generation counter - * survive the tab being unmounted and remounted. */ -export function loaderFor(store: StoreLike, api: AssetsApiLike): AssetBrowserLoader { + * survive the tab being unmounted and remounted. `sourceNodesApi` is read + * only on the FIRST call for a given store -- the same one-loader-per-store + * rule the cache and generation counter already follow. */ +export function loaderFor(store: StoreLike, api: AssetsApiLike, sourceNodesApi?: SourceNodesApiLike): AssetBrowserLoader { let loader = LOADERS.get(store); if (!loader) { - loader = createAssetBrowserLoader(store, api); + loader = createAssetBrowserLoader(store, api, sourceNodesApi); LOADERS.set(store, loader); } return loader; diff --git a/src/frontend/src/state/assetBrowserStore.ts b/src/frontend/src/state/assetBrowserStore.ts index 8f8549f50..e7ae2d5b2 100644 --- a/src/frontend/src/state/assetBrowserStore.ts +++ b/src/frontend/src/state/assetBrowserStore.ts @@ -9,6 +9,7 @@ import { create } from "zustand"; +import type { SourceNodeRow, SourceNodesAnswer } from "@/assets/changes"; import type { LoadedAsset } from "@/assets/delivery"; import { EMPTY_FOREST, mergeSpine, type Forest, type SpineMerge } from "@/assets/merge"; import type { AssetIndex, AssetNode, ResolutionMode } from "@/assets/types"; @@ -19,6 +20,8 @@ const EMPTY_SET: ReadonlySet = Object.freeze(new Set()); const EMPTY_LOADED: ReadonlyMap = Object.freeze(new Map()); const EMPTY_ERRORS: ReadonlyMap = Object.freeze(new Map()); const EMPTY_LOADED_ASSETS: readonly LoadedAsset[] = Object.freeze([]); +const EMPTY_SOURCE_ANSWERS: ReadonlyMap = Object.freeze(new Map()); +const EMPTY_CHANGED_ROWS: ReadonlyMap = Object.freeze(new Map()); export interface AssetBrowserState { tab: AssetBrowserTab; @@ -48,6 +51,26 @@ export interface AssetBrowserState { spineLoading: ReadonlySet; spineErrors: ReadonlyMap; + /** PROVIDER id -> the change feed's last answer for that provider, or + * `null` for that provider's own no-feed (§Decision 4's four states, + * `@/assets/changes`). Keyed by provider, not flattened, because a mixed + * collection can straddle providers with different feed availability -- + * see `mergeSourceAnswer`. */ + sourceAnswer: ReadonlyMap; + /** Every row across every provider whose `action` is non-null, flattened -- + * the per-node evidence marks a row paints, kept pre-flattened so a render + * never re-scans every provider's answer. Recomputed by `mergeSourceAnswer` + * whenever a new answer comes in, from `sourceAnswer` in full (cheap: a + * sweep's rows are the changed nodes plus their ancestors, never the whole + * tree -- §Decision 4 -- so this map stays small regardless of forest size). */ + changedRows: ReadonlyMap; + /** Every node ref (a root's own subject id, or any descendant) the tab has + * asked the feed about, across every provider -- the global dedup gate + * `assetBrowserLoader`'s evidence fetch reads before firing a request, and + * what tells `buildAssetView` "not yet asked" apart from "asked and got + * nothing back" (`not-recorded`). */ + evidenceAsked: ReadonlySet; + expanded: ReadonlySet; /** The focused row. One field on purpose: the tree is virtualised over ~41k * rows, so selection is never per-row state. */ @@ -83,6 +106,18 @@ export interface AssetBrowserState { beginSpine: (root: string) => void; endSpine: (root: string, revision: string) => void; failSpine: (root: string, error: string) => void; + /** Fold one provider's answer to a batch of refs into the running picture. + * `askedRefs` is recorded in `evidenceAsked` REGARDLESS of whether `answer` + * is a real answer or `null` -- asking and being told no-feed is still + * having asked, and is exactly what must stop `not-recorded` (a claim + * about the feed) from being confused with "nobody has asked yet" (a fact + * about this browser tab). `answer: null` replaces this provider's slot in + * `sourceAnswer` with `null` outright rather than merging into whatever + * rows it may have held before: a provider that has just told us it has no + * database cannot simultaneously be trusted for rows fetched a moment + * earlier, and keeping them would let a `current` badge outlive the + * answer that justified it. */ + mergeSourceAnswer: (source: string, answer: SourceNodesAnswer | null, askedRefs: readonly string[]) => void; toggleExpanded: (id: string) => void; setExpanded: (id: string, on: boolean) => void; select: (id: string | null) => void; @@ -115,6 +150,12 @@ const FOREST_RESET = { spineLoaded: EMPTY_LOADED, spineLoading: EMPTY_SET, spineErrors: EMPTY_ERRORS, + // The change feed is asked about refs from THIS forest; a different + // collection or a rebuilt forest has different refs to ask about, so + // nothing here would still mean anything -- reset with the rest. + sourceAnswer: EMPTY_SOURCE_ANSWERS, + changedRows: EMPTY_CHANGED_ROWS, + evidenceAsked: EMPTY_SET, expanded: EMPTY_SET, selected: null, }; @@ -134,6 +175,9 @@ export const useAssetBrowserStore = create((set) => ({ spineLoaded: EMPTY_LOADED, spineLoading: EMPTY_SET, spineErrors: EMPTY_ERRORS, + sourceAnswer: EMPTY_SOURCE_ANSWERS, + changedRows: EMPTY_CHANGED_ROWS, + evidenceAsked: EMPTY_SET, expanded: EMPTY_SET, selected: null, searchTerm: "", @@ -187,6 +231,26 @@ export const useAssetBrowserStore = create((set) => ({ return { spineLoading: loading, spineErrors: errors }; }), + mergeSourceAnswer: (source, answer, askedRefs) => + set((s) => { + const asked = new Set(s.evidenceAsked); + for (const ref of askedRefs) asked.add(ref); + const bySource = new Map(s.sourceAnswer); + bySource.set(source, answer); + // Re-flattened from every provider's answer rather than patched + // incrementally: a provider going from a real answer to `null` (its + // own no-feed) must make ITS rows disappear from `changedRows` too, and + // patching would have to special-case that removal every call for a + // map that stays small regardless (§Decision 4's roll-up keeps the + // feed to changed nodes and their ancestors, never the whole tree). + const changedRows = new Map(); + for (const a of bySource.values()) { + if (!a) continue; + for (const [ref, row] of a.rows) if (row.action) changedRows.set(ref, row); + } + return { evidenceAsked: asked, sourceAnswer: bySource, changedRows }; + }), + toggleExpanded: (id) => set((s) => { const next = new Set(s.expanded); diff --git a/tests/comms/rest/test_asset_publish_routes.py b/tests/comms/rest/test_asset_publish_routes.py new file mode 100644 index 000000000..c949c25d5 --- /dev/null +++ b/tests/comms/rest/test_asset_publish_routes.py @@ -0,0 +1,379 @@ +"""``POST /assets/publish``, ``GET /assets/staging`` and ``DELETE /assets/{collection}/{subject}/ +{revision}``, driven end to end by the private-format fixture provider and publisher. + +Modelled on ``test_asset_build_routes.py``'s harness: local-storage sandbox, auth disabled. The +routes themselves, the job transport (``LocalJobTransport``) and ``ada.assets.publish``/ +``ada.assets.unpublish`` are all owned by other files in this change (see the task's "do not touch" +list) -- this exercises the CONTRACT they already implement: staging recovers from a plain listing, +a publish is core-stamped and manifests-last, and an unpublish is refused while a holder survives. + +The fixture PUBLISHER (``tests/core/assets/fixture_provider/publisher.py``) is registered for the +whole module -- the in-process job transport (no NATS in this harness) needs something to resolve +``provider="fixture-lines"`` to, or every publish 501s before it can even derive a plan. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import time + +os.environ.setdefault("ADA_VIEWER_STORAGE_KIND", "local") +os.environ.setdefault("ADA_VIEWER_LOCAL_PATH", tempfile.mkdtemp(prefix="ada-test-asset-publish-")) + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from tests.core.assets.fixture_provider.provider import ( # noqa: E402 + FIXTURE_PROVIDER_ID, +) +from tests.core.assets.fixture_provider.publisher import ( # noqa: E402 + FixtureLinesPublisher, + fixture_source_bytes, + register_fixture_publisher, +) + +from ada.assets.keys import asset_key, staging_prefix # noqa: E402 +from ada.assets.manifest import ( # noqa: E402 + MANIFEST_FILENAME, + ArtefactEntry, + AssetManifest, +) +from ada.assets.publishers import asset_publisher, clear_asset_publishers # noqa: E402 +from ada.comms.rest import local_jobs # noqa: E402 +from ada.comms.rest.app import create_app # noqa: E402 +from ada.comms.rest.config import ( # noqa: E402 + AuthConfig, + LocalConfig, + QueueConfig, + Settings, +) + +COLLECTION = "fixture-pub" +LOCAL_DEV_SUB = "local-dev" # ada.comms.rest.auth.User.local_dev() -- the synthetic caller when auth is disabled + + +def _settings(tmp_path) -> Settings: + return Settings( + storage_kind="local", + s3=None, + local=LocalConfig(path=str(tmp_path), prefix=""), + host="127.0.0.1", + port=0, + static_path="", + queue=QueueConfig( + url=None, + stream="ada", + subject="ada.viewer.jobs.convert", + kv_bucket="ada-viewer-jobs", + durable="ada-viewer-worker", + ), + auth=AuthConfig(enabled=False, issuer="", client_id="", audience="", admin_group="", cli_token_secret=""), + database_url="", + ) + + +@pytest.fixture(autouse=True) +def _fixture_publisher_registered(): + """Idempotent by origin (``ada.assets.publishers``), same discipline as the builder's own + fixture in ``test_asset_build_routes.py`` -- cleared afterwards so the process-global registry + does not leak into other test modules.""" + clear_asset_publishers() + register_fixture_publisher() + yield + clear_asset_publishers() + + +@pytest.fixture +def client(tmp_path): + app = create_app(_settings(tmp_path)) + with TestClient(app) as c: + yield c, tmp_path + + +def _scope_root(tmp_path): + return tmp_path / "users" / LOCAL_DEV_SUB + + +def _write(tmp_path, key: str, data: bytes) -> None: + dest = _scope_root(tmp_path) / key + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + + +def _read(tmp_path, key: str) -> bytes: + return (_scope_root(tmp_path) / key).read_bytes() + + +def _exists(tmp_path, key: str) -> bool: + return (_scope_root(tmp_path) / key).exists() + + +def _stage(tmp_path, staging_id: str, filename: str = "source.jsonl") -> str: + key = f"{staging_prefix(staging_id)}{filename}" + _write(tmp_path, key, fixture_source_bytes()) + return key + + +def _publish_url() -> str: + return "/api/scopes/user:me/assets/publish" + + +def _poll_done(client, job_id: str, *, timeout_s: float = 10.0) -> dict: + deadline = time.monotonic() + timeout_s + status = None + while time.monotonic() < deadline: + status = client.get(f"/api/convert/{job_id}").json() + if status.get("status") != local_jobs.STATUS_RUNNING: + return status + time.sleep(0.02) + raise AssertionError(f"job {job_id} did not finish in {timeout_s}s: {status}") + + +def _blob_json(client, key: str) -> dict: + r = client.get(f"/api/scopes/user:me/blobs/{key}") + assert r.status_code == 200, r.text + body = r.content + if body[:2] == b"\x1f\x8b": # tolerate gzip either way, as test_local_plugin_jobs.py does + import gzip + + body = gzip.decompress(body) + return json.loads(body) + + +# -------------------------------------------------------------------------------------------- +# POST /assets/publish -- the ordinary staging_id path, run to completion. +# -------------------------------------------------------------------------------------------- + + +def test_publish_with_staging_id_collects_everything_under_it_and_returns_the_job_shape(client): + c, tmp_path = client + _stage(tmp_path, "up1") + + r = c.post(_publish_url(), json={"provider": FIXTURE_PROVIDER_ID, "staging_id": "up1", "collection": COLLECTION}) + assert r.status_code == 200, r.text + body = r.json() + assert set(body) == {"job_id", "derived_key", "dry_run"} + assert body["dry_run"] is False + assert body["job_id"] + assert body["derived_key"] + + status = _poll_done(c, body["job_id"]) + assert status["status"] == local_jobs.STATUS_DONE, status + + +def test_a_real_publish_ends_with_manifests_present_written_last_and_change_published_by_stamped(client): + c, tmp_path = client + _stage(tmp_path, "up1") + + r = c.post(_publish_url(), json={"provider": FIXTURE_PROVIDER_ID, "staging_id": "up1", "collection": COLLECTION}) + assert r.status_code == 200, r.text + body = r.json() + status = _poll_done(c, body["job_id"]) + assert status["status"] == local_jobs.STATUS_DONE, status + + outcome = _blob_json(c, body["derived_key"]) + assert outcome["dry_run"] is False + assert len(outcome["subjects"]) == 6 # site, unit-1, unit-2, pump-a, pump-b, tank-c + revision = outcome["revision"] + + # Manifests present, on disk, for every subject this publish declared. + for subject in outcome["subjects"]: + manifest_key = asset_key(COLLECTION, subject, revision, MANIFEST_FILENAME) + assert _exists(tmp_path, manifest_key), manifest_key + + # Written LAST: the collection-level manifest is the final key in the outcome's own order. + collection_manifest_key = asset_key(COLLECTION, COLLECTION, revision, MANIFEST_FILENAME) + assert outcome["written"][-1] == collection_manifest_key + + # change.published_by is CORE's stamp, from the authenticated (local-dev) caller, published_via + # "user" -- exactly what `POST /assets/publish` hard-codes for a caller-initiated publish. + # EVERY manifest of the publish carries it, collection-level included -- `apply_publish_plan` + # stamps any write whose filename is `asset.json`, not just the node-level ones. + manifest = json.loads(_read(tmp_path, collection_manifest_key)) + assert manifest["change"]["published_by"]["id"] == LOCAL_DEV_SUB + assert manifest["change"]["published_via"] == "user" + assert set(manifest["change"]) == {"published_by", "published_via"} # nothing the fixture relays + + node_manifest_key = asset_key(COLLECTION, "pump-a", revision, MANIFEST_FILENAME) + node_manifest = json.loads(_read(tmp_path, node_manifest_key)) + assert node_manifest["change"]["published_by"]["id"] == LOCAL_DEV_SUB + assert node_manifest["change"]["published_via"] == "user" + + +def test_dry_run_publish_writes_nothing_to_disk_but_the_summary_says_so(client): + c, tmp_path = client + _stage(tmp_path, "up1") + + r = c.post( + _publish_url(), + json={"provider": FIXTURE_PROVIDER_ID, "staging_id": "up1", "collection": COLLECTION, "dry_run": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["dry_run"] is True + status = _poll_done(c, body["job_id"]) + assert status["status"] == local_jobs.STATUS_DONE, status + + outcome = _blob_json(c, body["derived_key"]) + assert outcome["dry_run"] is True + assert len(outcome["written"]) > 0 # it still REPORTS what it would have written ... + for key in outcome["written"]: + assert not _exists(tmp_path, key) # ... none of which actually landed on disk + + +# -------------------------------------------------------------------------------------------- +# POST /assets/publish -- the request-shape refusals. +# -------------------------------------------------------------------------------------------- + + +def test_a_staged_map_pointing_outside_staging_is_400(client): + c, _tmp_path = client + outside_key = asset_key(COLLECTION, COLLECTION, "20260101T000000Z", "source.jsonl") + r = c.post(_publish_url(), json={"provider": FIXTURE_PROVIDER_ID, "staged": {"source.jsonl": outside_key}}) + assert r.status_code == 400, r.text + assert "outside" in r.json()["detail"] + + +def test_missing_staging_id_and_no_staged_map_is_400(client): + c, _tmp_path = client + r = c.post(_publish_url(), json={"provider": FIXTURE_PROVIDER_ID}) + assert r.status_code == 400, r.text + + +def test_a_staging_id_with_nothing_staged_under_it_is_404(client): + c, _tmp_path = client + r = c.post(_publish_url(), json={"provider": FIXTURE_PROVIDER_ID, "staging_id": "never-uploaded"}) + assert r.status_code == 404, r.text + + +# -------------------------------------------------------------------------------------------- +# GET /assets/staging -- a listing, not a session. +# -------------------------------------------------------------------------------------------- + + +def test_get_staging_groups_by_id_and_survives_a_reload(tmp_path): + """Blobs are written directly to disk, never through the app -- the route must answer from + the store alone, which is what "survives a reload" (the module docstring in routes/assets.py) + actually means: no browser, no session, nothing remembered by this process.""" + _write(tmp_path, f"{staging_prefix('up1')}source.jsonl", b"one upload's bytes") + _write(tmp_path, f"{staging_prefix('up2')}source.jsonl", b"a different upload's bytes, longer") + + app = create_app(_settings(tmp_path)) + with TestClient(app) as c: + r = c.get("/api/scopes/user:me/assets/staging") + assert r.status_code == 200, r.text + staged = {g["staging_id"]: g for g in r.json()["staged"]} + + assert set(staged) == {"up1", "up2"} + assert staged["up1"]["files"][0]["file"] == "source.jsonl" + assert staged["up1"]["size"] == len(b"one upload's bytes") + assert staged["up2"]["size"] == len(b"a different upload's bytes, longer") + + +# -------------------------------------------------------------------------------------------- +# DELETE /assets/{collection}/{subject}/{revision} -- the refcount-checked unpublish. +# -------------------------------------------------------------------------------------------- + + +REVISION = "20260921T143001Z" + + +def _seed_collection_and_holder(tmp_path) -> str: + """A collection-level revision holding the real source blob, and ONE node manifest at the + same revision that references it by absolute ``key`` -- written straight to disk so this test + exercises only the DELETE route, not a publish.""" + source_key = asset_key(COLLECTION, COLLECTION, REVISION, "source.jsonl") + _write(tmp_path, source_key, b"the shared source bytes") + collection_manifest = AssetManifest( + provider=FIXTURE_PROVIDER_ID, + collection=COLLECTION, + subject=COLLECTION, + revision=REVISION, + node=None, + produced_at="2026-09-21T14:29:00Z", + published_at="2026-09-21T14:30:01Z", + delivery="none", + artefacts=(ArtefactEntry(role="source", file="source.jsonl", sha256="a" * 64, size=24),), + ) + _write(tmp_path, asset_key(COLLECTION, COLLECTION, REVISION, MANIFEST_FILENAME), collection_manifest.to_json()) + + holder_manifest = AssetManifest( + provider=FIXTURE_PROVIDER_ID, + collection=COLLECTION, + subject="pump-a", + revision=REVISION, + node="pump-a", + produced_at="2026-09-21T14:29:00Z", + published_at="2026-09-21T14:30:01Z", + delivery="none", + artefacts=(ArtefactEntry(role="source", key=source_key, sha256="a" * 64, size=24),), + ) + _write(tmp_path, asset_key(COLLECTION, "pump-a", REVISION, MANIFEST_FILENAME), holder_manifest.to_json()) + return source_key + + +def _delete_url(subject: str, revision: str = REVISION) -> str: + return f"/api/scopes/user:me/assets/{COLLECTION}/{subject}/{revision}" + + +def test_delete_refuses_with_409_and_a_reason_while_a_holder_exists(client): + c, tmp_path = client + source_key = _seed_collection_and_holder(tmp_path) + + r = c.delete(_delete_url(COLLECTION)) + assert r.status_code == 409, r.text + body = r.json() + assert body["ok"] is False + assert body["reason"] + assert "pump-a" in body["reason"] or any("pump-a" in h for h in body["held_by"]) + assert _exists(tmp_path, source_key) # refused means untouched, not partially deleted + + +def test_delete_succeeds_once_the_holder_is_gone(client): + c, tmp_path = client + source_key = _seed_collection_and_holder(tmp_path) + + r = c.delete(_delete_url("pump-a")) + assert r.status_code == 200, r.text + assert r.json()["ok"] is True + assert r.json()["deleted"] + + r = c.delete(_delete_url(COLLECTION)) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["deleted"] + assert body["deleted"][0] == asset_key(COLLECTION, COLLECTION, REVISION, MANIFEST_FILENAME) # manifest first + assert not _exists(tmp_path, source_key) + + +def test_delete_of_an_absent_subject_revision_is_404(client): + c, _tmp_path = client + r = c.delete(_delete_url("no-such-subject")) + assert r.status_code == 404, r.text + assert r.json()["ok"] is False + + +def test_register_fixture_publisher_resolves_by_provider_id(): + # The autouse fixture already registered it once; a second registration (discovery plus an + # explicit preload, say) is a no-op by origin, not a conflict -- the same idempotency + # `ada.assets.registry` promises for tree providers. + register_fixture_publisher() + publisher = asset_publisher(FIXTURE_PROVIDER_ID) + assert isinstance(publisher, FixtureLinesPublisher) + + +def test_published_via_is_read_from_the_caller_not_the_body(): + """Decision 6's first two trust levels. A scheduled firing arrives as the deployment's own + identity, and recording that as a `user` publish would put a person's name on a revision + nobody pushed -- so the field is derived from the authenticated principal, and a request body + cannot influence it.""" + from ada.comms.rest.routes.assets import _published_via + from ada.comms.rest.routes.deps import SystemUser + + class _Person: + sub = "alice@example.invalid" + + assert _published_via(_Person()) == "user" + assert _published_via(SystemUser()) == "service" diff --git a/tests/comms/rest/test_format_registry.py b/tests/comms/rest/test_format_registry.py index deb2cd8c2..df962cb0b 100644 --- a/tests/comms/rest/test_format_registry.py +++ b/tests/comms/rest/test_format_registry.py @@ -28,6 +28,7 @@ CHAIN_KINDS = [ ("component_build", True), ("asset_build", True), + ("asset_publish", True), ("procedural_build", True), ("plugin_job", True), ("procedural_detail", True), diff --git a/tests/comms/rest/test_job_transport.py b/tests/comms/rest/test_job_transport.py index 1a79f0b72..18f0a13bd 100644 --- a/tests/comms/rest/test_job_transport.py +++ b/tests/comms/rest/test_job_transport.py @@ -130,12 +130,13 @@ def test_every_feature_has_a_message_and_only_plugin_jobs_runs_locally(): assert set(get_args(TransportFeature)) == set(FEATURE_UNAVAILABLE_DETAIL) assert all(FEATURE_UNAVAILABLE_DETAIL.values()) - # Two kinds run without a queue, for the same reason: a single-node viewer + # Three kinds run without a queue, for the same reason: a single-node viewer # with the model in its own scope must be able to run the thing the UI # offers. `asset_build` joined `plugin_jobs` with Decision 1's build # delivery -- a 503 there would make `build` a delivery kind that only - # exists in a cluster. - assert LOCAL_FEATURES == frozenset({"asset_build", "plugin_jobs"}) + # exists in a cluster -- and `asset_publish` with Phase 4's publish + # surface, on the same argument about `publish`. + assert LOCAL_FEATURES == frozenset({"asset_build", "asset_publish", "plugin_jobs"}) # -------------------------------------------------------------------------- diff --git a/tests/comms/rest/test_source_nodes.py b/tests/comms/rest/test_source_nodes.py index 5f62adb2d..2a9093f76 100644 --- a/tests/comms/rest/test_source_nodes.py +++ b/tests/comms/rest/test_source_nodes.py @@ -739,3 +739,31 @@ def test_a_user_scope_refuses_to_invent_a_wire_form(worker_mod): rec = worker_mod._RestSourceNodesRecorder("https://viewer.example", "tok", Scope.user("someone")) with pytest.raises(ValueError, match="user:me"): rec._url() + + +def test_no_database_is_no_feed_never_current(): + """`_source_nodes_pool` is the read side's only source of truth for whether a feed exists at + all: no `db_pool` on `app.state` is refused with 503, not answered with an empty-but-200 + "nothing changed". A sweep's rows are meaningless without somewhere to record them -- this is + the third "cannot say" state Decision 4 lists alongside `behind` / `current` / + `not-recorded`, and it must never collapse into `current`. + + Lives with the REST tests rather than beside the IFC sweep that motivated it: it asserts a + ROUTE helper, and the core asset suite runs in an environment with no REST stack at all. + """ + from fastapi import HTTPException + + from ada.comms.rest.routes.source_nodes import _source_nodes_pool + + class _State: + db_pool = None + + class _App: + state = _State() + + class _Request: + app = _App() + + with pytest.raises(HTTPException) as exc_info: + _source_nodes_pool(_Request()) + assert exc_info.value.status_code == 503 diff --git a/tests/core/assets/corpus/make_plant_a.py b/tests/core/assets/corpus/make_plant_a.py index 37a83bfa8..d8c9d9016 100644 --- a/tests/core/assets/corpus/make_plant_a.py +++ b/tests/core/assets/corpus/make_plant_a.py @@ -1,19 +1,27 @@ -"""Generates ``plant-a_v1.ifc`` with adapy's OWN writer. The output is committed (small; run this -file to regenerate it, which nothing in CI does automatically -- the committed file IS the fixture). - -Shape (Decision 4's corpus spec): 2 ``IfcSite``, each with 2 ``IfcBuildingStorey``, one storey -holding an ``IfcElementAssembly``, ~40 beams and plates total, depth >= 4. - -**Why the two sites are nested (SiteB under SiteA) rather than siblings under IfcProject.** -``ada.Assembly`` is itself always written as exactly one ``IfcSite`` (``SpatialWriter. -create_ifc_site()`` in ``write_spatial_elements.py`` hard-codes the entity type, ignoring -``ifc_class`` -- there is no adapy Part-tree shape that puts two independent ``IfcSite``s directly -under ``IfcProject``). Rather than fight that by surgically rewiring ``IfcRelAggregates`` after the -fact, the assembly's own always-present site IS SiteA, and SiteB is an ordinary child ``Part`` of -``ifc_class=IfcSite`` beneath it. The result is still exactly 2 reachable ``IfcSite`` entities -- -which is what "declared roots default to every IfcSite" and the whole-file publish acceptance ("2 -site manifests") actually count -- just nested one level rather than sibling. Depth is unaffected -(``StoreyA2 -> AssemblyAA -> beam`` alone already clears depth >= 4 from ``IfcProject``). +"""Generates the ``plant-a`` IFC corpus with adapy's OWN writer. Outputs are committed (small; run +this file to regenerate them, which nothing in CI does automatically -- the committed files ARE +the fixtures): + +* ``plant-a_v1.ifc`` -- Phase 3's corpus, UNCHANGED in shape (Decision 4's spec): 2 ``IfcSite``, + each with 2 ``IfcBuildingStorey``, one storey holding an ``IfcElementAssembly``, ~40 beams and + plates total, depth >= 4. +* ``plant-a_v2.ifc`` -- the SAME model at a later instant with exactly ONE member modified, ONE + removed, ONE added, one storey untouched (``StoreyA1``), one site untouched (``SiteB``). +* ``plant-a_v2-leaf.ifc`` -- a single-product file: one already-published member (``a1-bm0``, + UNCHANGED between v1 and v2) republished standalone, for the leaf-without-stem publish + (``--leaf --source``, Phase 4). + +**Why every named entity gets a DETERMINISTIC, name-keyed guid.** ``ada``'s writer mints a random +``GlobalId`` per object by default (``ada.core.guid.create_guid()``), which is fine for a single +export but wrong for a CORPUS whose whole point is that v1 and v2 are two INDEPENDENT exports of +the same underlying plant: the Phase-4 sweep and the leaf-without-stem publish both work by GUID +IDENTITY (``ada.assets.ifc.walk``/``sweep``: "by GUID presence, and by a hash ... recorded in +``ifc.index.json``"), so a member that persists across the two files must keep the SAME id, and +only a genuinely new member should mint a new one. ``create_guid(name=...)`` (``ada/core/guid.py``) +is already exactly this: an md5-derived, deterministic, valid 22-character GlobalId keyed by a +name string -- calling it with the same name in both generators reproduces the same id, and a name +neither file shares (``aa-bm6``, v2-only) reproduces a fresh one, all without hand-maintaining a +guid table. """ from __future__ import annotations @@ -22,41 +30,81 @@ import ada from ada.base.ifc_types import SpatialTypes +from ada.core.guid import create_guid HERE = pathlib.Path(__file__).parent -OUTPUT = HERE / "plant-a_v1.ifc" +OUTPUT_V1 = HERE / "plant-a_v1.ifc" +OUTPUT_V2 = HERE / "plant-a_v2.ifc" +OUTPUT_V2_LEAF = HERE / "plant-a_v2-leaf.ifc" + + +def _guid(name: str) -> str: + """The one guid a given NAME ever gets, in any generator in this module.""" + return create_guid(name=name) + + +def _beam(name: str, z: float, x: float, *, length: float = 5.0, sec: str = "IPE200") -> ada.Beam: + return ada.Beam(name, (x, 0.0, z), (x, length, z), sec, guid=_guid(name)) + + +def _plate(name: str, x: float, z: float) -> ada.Plate: + return ada.Plate( + name, [(x, 0.0), (x + 1.5, 0.0), (x + 1.5, 1.5), (x, 1.5)], 0.01, origin=(0, 0, z), guid=_guid(name) + ) def _beams(prefix: str, n: int, z: float, x0: float) -> list[ada.Beam]: - return [ada.Beam(f"{prefix}-bm{i}", (x0 + i * 2.0, 0.0, z), (x0 + i * 2.0, 5.0, z), "IPE200") for i in range(n)] + return [_beam(f"{prefix}-bm{i}", z, x0 + i * 2.0) for i in range(n)] def _plates(prefix: str, n: int, z: float, x0: float) -> list[ada.Plate]: - out = [] - for i in range(n): - x = x0 + i * 2.0 - out.append( - ada.Plate(f"{prefix}-pl{i}", [(x, 0.0), (x + 1.5, 0.0), (x + 1.5, 1.5), (x, 1.5)], 0.01, origin=(0, 0, z)) - ) - return out + return [_plate(f"{prefix}-pl{i}", x0 + i * 2.0, z) for i in range(n)] + +def _part(name: str, ifc_class: SpatialTypes) -> ada.Part: + return ada.Part(name, ifc_class=ifc_class, guid=_guid(name)) -def build_plant_a() -> ada.Assembly: - # The assembly root doubles as SiteA -- see the module docstring. + +def build_plant_a(*, revise: bool = False) -> ada.Assembly: + """The whole ``plant-a`` model. ``revise=False`` builds v1; ``revise=True`` builds v2, which + changes ONLY ``AssemblyAA`` under ``StoreyA2`` -- ``StoreyA1`` and the whole of ``SiteB`` are + built by the exact same calls as v1, so their subtrees are byte-for-byte identical (same + names, same guids, same geometry) and the sweep must find nothing to say about them. + + The assembly root doubles as SiteA -- see the module's original docstring reasoning (adapy's + ``Assembly`` is always written as exactly one ``IfcSite``, so two real sibling sites are not + reachable through the public API; SiteB nests one level under SiteA instead of beside it, + which does not change what "declared roots default to every IfcSite" counts). + """ a = ada.Assembly("SiteA", project="plant-a") + a.guid = _guid("SiteA") # set post-construction: Assembly.__init__ takes no guid= of its own - storey_a1 = ada.Part("StoreyA1", ifc_class=SpatialTypes.IfcBuildingStorey) + storey_a1 = _part("StoreyA1", SpatialTypes.IfcBuildingStorey) storey_a1 / (_beams("a1", 6, 0.0, 0.0) + _plates("a1", 4, 0.0, 0.0)) - storey_a2 = ada.Part("StoreyA2", ifc_class=SpatialTypes.IfcBuildingStorey) - assembly_aa = ada.Part("AssemblyAA", ifc_class=SpatialTypes.IfcElementAssembly) - assembly_aa / (_beams("aa", 6, 3.0, 0.0) + _plates("aa", 4, 3.0, 0.0)) + storey_a2 = _part("StoreyA2", SpatialTypes.IfcBuildingStorey) + assembly_aa = _part("AssemblyAA", SpatialTypes.IfcElementAssembly) + aa_members: list = [] + for i in range(6): + name = f"aa-bm{i}" + if revise and i == 1: + continue # REMOVED in v2 + if revise and i == 0: + # MODIFIED in v2: same guid (same name), moved and re-sectioned -- both the placement + # and the representation change, so this is not a hash coincidence either input covers. + aa_members.append(_beam(name, 3.0, 0.0 + 0.5, length=5.5, sec="IPE300")) + else: + aa_members.append(_beam(name, 3.0, 0.0 + i * 2.0)) + aa_members += _plates("aa", 4, 3.0, 0.0) + if revise: + aa_members.append(_beam("aa-bm6", 3.0, 12.0)) # ADDED in v2 -- a name v1 never had + assembly_aa / aa_members storey_a2 / [assembly_aa] - site_b = ada.Part("SiteB", ifc_class=SpatialTypes.IfcSite) - storey_b1 = ada.Part("StoreyB1", ifc_class=SpatialTypes.IfcBuildingStorey) + site_b = _part("SiteB", SpatialTypes.IfcSite) + storey_b1 = _part("StoreyB1", SpatialTypes.IfcBuildingStorey) storey_b1 / (_beams("b1", 6, 0.0, 20.0) + _plates("b1", 4, 0.0, 20.0)) - storey_b2 = ada.Part("StoreyB2", ifc_class=SpatialTypes.IfcBuildingStorey) + storey_b2 = _part("StoreyB2", SpatialTypes.IfcBuildingStorey) storey_b2 / (_beams("b2", 6, 3.0, 20.0) + _plates("b2", 4, 3.0, 20.0)) site_b / [storey_b1, storey_b2] @@ -64,10 +112,34 @@ def build_plant_a() -> ada.Assembly: return a +def build_plant_a_v2_leaf() -> ada.Assembly: + """A single-product export of ``a1-bm0`` -- UNCHANGED between v1 and v2 -- for the + leaf-without-stem publish (``--leaf --source``): a caller that has only this one member's + file, not the whole plant, republishing it against the plant's already-uploaded source blob. + + ``SiteA``'s guid matches the full model's SO ``_hierarchy_revision_for`` (``ada.assets.ifc. + publish``), which walks THIS file's own ancestor chain and checks it against what is already + published, finds SiteA published and records a ``hierarchy_revision`` -- the whole point of + the test this fixture drives. ``StoreyA1Mini`` is a stand-in container (a real IFC beam still + needs a spatial parent) and is deliberately NOT name-matched to ``StoreyA1``: leaf-without-stem + must resolve to the nearest PUBLISHED ancestor (SiteA, which has its own manifest from the + whole-file publish) even when the leaf's own local container is not itself a published node. + """ + a = ada.Assembly("SiteA", project="plant-a") + a.guid = _guid("SiteA") + storey = _part("StoreyA1Mini", SpatialTypes.IfcBuildingStorey) + storey / [_beam("a1-bm0", 0.0, 0.0)] + a / [storey] + return a + + def main() -> None: - a = build_plant_a() - a.to_ifc(destination=OUTPUT, file_obj_only=False, validate=True) - print(f"wrote {OUTPUT}") + build_plant_a(revise=False).to_ifc(destination=OUTPUT_V1, file_obj_only=False, validate=True) + print(f"wrote {OUTPUT_V1}") + build_plant_a(revise=True).to_ifc(destination=OUTPUT_V2, file_obj_only=False, validate=True) + print(f"wrote {OUTPUT_V2}") + build_plant_a_v2_leaf().to_ifc(destination=OUTPUT_V2_LEAF, file_obj_only=False, validate=True) + print(f"wrote {OUTPUT_V2_LEAF}") if __name__ == "__main__": diff --git a/tests/core/assets/corpus/plant-a_v1.ifc b/tests/core/assets/corpus/plant-a_v1.ifc index 4c4ea6588..3b62f721e 100644 --- a/tests/core/assets/corpus/plant-a_v1.ifc +++ b/tests/core/assets/corpus/plant-a_v1.ifc @@ -1,16 +1,16 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); -FILE_NAME('/dev/null','2026-09-22T20:24:55+02:00',('AdaUser'),(''),'IfcOpenShell 0.8.5','IfcOpenShell 0.8.5','Nobody'); +FILE_NAME('/dev/null','2026-09-22T21:27:53+02:00',('AdaUser'),(''),'IfcOpenShell 0.8.5','IfcOpenShell 0.8.5','Nobody'); FILE_SCHEMA(('IFC4X3_ADD2')); ENDSEC; DATA; -#1=IFCPROJECT('0wIJW3aA12PxJhKDKBePBL',$,'plant-a',$,$,$,$,(#11),#6); +#1=IFCPROJECT('3J5YqjooHARheku$HlV0KI',$,'plant-a',$,$,$,$,(#11),#6); #2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); #3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); #4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); #5=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); -#6=IFCUNITASSIGNMENT((#4,#5,#2,#3)); +#6=IFCUNITASSIGNMENT((#3,#4,#2,#5)); #7=IFCCARTESIANPOINT((0.,0.,0.)); #8=IFCDIRECTION((0.,0.,1.)); #9=IFCDIRECTION((1.,0.,0.)); @@ -24,57 +24,57 @@ DATA; #17=IFCORGANIZATION('ADA','Assembly For Design and Analysis',$,$,$); #18=IFCPERSONANDORGANIZATION(#16,#17,$); #19=IFCAPPLICATION(#17,'XXX','ADA','ADA'); -#20=IFCOWNERHISTORY(#18,#19,.READWRITE.,$,$,#18,#19,1790101495); +#20=IFCOWNERHISTORY(#18,#19,.READWRITE.,$,$,#18,#19,1790105273); #21=IFCDIRECTION((0.,0.,1.)); #22=IFCDIRECTION((1.,0.,0.)); #23=IFCCARTESIANPOINT((0.,0.,0.)); #24=IFCAXIS2PLACEMENT3D(#23,#21,#22); #25=IFCLOCALPLACEMENT($,#24); -#26=IFCSITE('2DHI$PTXb0UxJQwO21IHsP',#20,'SiteA',$,$,#25,$,$,.ELEMENT.,$,$,$,$,$); -#27=IFCRELAGGREGATES('1zBBLPaoPDn82Trt1T5HAc',#20,'Project Container',$,#1,(#26)); +#26=IFCSITE('3WJPj2eAGiVce6lnlPqOcA',#20,'SiteA',$,$,#25,$,$,.ELEMENT.,$,$,$,$,$); +#27=IFCRELAGGREGATES('30xPKUaqHAVOqv0mEaJCYq',#20,'Project Container',$,#1,(#26)); #28=IFCPROPERTYSINGLEVALUE('project',$,IFCTEXT('plant-a'),$); #29=IFCPROPERTYSINGLEVALUE('schema',$,IFCTEXT('IFC4X3_add2'),$); -#30=IFCPROPERTYSET('10PZJpkgb1XfJOg_Yx6OA7',#20,'Properties',$,(#28,#29)); -#31=IFCRELDEFINESBYPROPERTIES('39tNWODejCbhmJFnMMFIUi',#20,'Properties',$,(#26),#30); +#30=IFCPROPERTYSET('2_xkNBoRT4zAPJmDHyxCRq',#20,'Properties',$,(#28,#29)); +#31=IFCRELDEFINESBYPROPERTIES('067WE$beX2vQYAPoqtUaP8',#20,'Properties',$,(#26),#30); #32=IFCDIRECTION((0.,0.,1.)); #33=IFCDIRECTION((1.,0.,0.)); #34=IFCCARTESIANPOINT((0.,0.,0.)); #35=IFCAXIS2PLACEMENT3D(#34,#32,#33); #36=IFCLOCALPLACEMENT(#25,#35); -#37=IFCBUILDINGSTOREY('2XXwVzTwzBxQwaB5GY$jr8',#20,'StoreyA1',$,$,#36,$,$,.ELEMENT.,0.); -#38=IFCRELAGGREGATES('16zgML_D9AoPvaNbQATmDG',#20,'Site Container',$,#26,(#37,#44,#57)); +#37=IFCBUILDINGSTOREY('19QMj6Mx9ReIRYpLTAiA9Q',#20,'StoreyA1',$,$,#36,$,$,.ELEMENT.,0.); +#38=IFCRELAGGREGATES('2v0_48djD8mANtbcLJIHNm',#20,'Site Container',$,#26,(#37,#44,#57)); #39=IFCDIRECTION((0.,0.,1.)); #40=IFCDIRECTION((1.,0.,0.)); #41=IFCCARTESIANPOINT((0.,0.,0.)); #42=IFCAXIS2PLACEMENT3D(#41,#39,#40); #43=IFCLOCALPLACEMENT(#25,#42); -#44=IFCBUILDINGSTOREY('1nElJYrrfAhhMwRC2X7NzU',#20,'StoreyA2',$,$,#43,$,$,.ELEMENT.,0.); +#44=IFCBUILDINGSTOREY('0CRDvtMxpOLYR108n5OI5p',#20,'StoreyA2',$,$,#43,$,$,.ELEMENT.,0.); #45=IFCDIRECTION((0.,0.,1.)); #46=IFCDIRECTION((1.,0.,0.)); #47=IFCCARTESIANPOINT((0.,0.,0.)); #48=IFCAXIS2PLACEMENT3D(#47,#45,#46); #49=IFCLOCALPLACEMENT(#43,#48); -#50=IFCELEMENTASSEMBLY('1jGA7NGI13Lvel6DFmAxxq',#20,'AssemblyAA',$,$,#49,$,$,$,$); -#51=IFCRELAGGREGATES('23W2Wj33v7zQv2AEgAsUFs',#20,'Site Container',$,#44,(#50)); +#50=IFCELEMENTASSEMBLY('2X7izBbpgQdGz5JuZgCj2U',#20,'AssemblyAA',$,$,#49,$,$,$,$); +#51=IFCRELAGGREGATES('0rSOLZvVX09BgzUk$v2LMU',#20,'Site Container',$,#44,(#50)); #52=IFCDIRECTION((0.,0.,1.)); #53=IFCDIRECTION((1.,0.,0.)); #54=IFCCARTESIANPOINT((0.,0.,0.)); #55=IFCAXIS2PLACEMENT3D(#54,#52,#53); #56=IFCLOCALPLACEMENT(#25,#55); -#57=IFCSITE('2Cfk9GZsH9wumLDdCyDcfd',#20,'SiteB',$,$,#56,$,$,.ELEMENT.,$,$,$,$,$); +#57=IFCSITE('1TvWWM_HihayqyFE0Yo_T0',#20,'SiteB',$,$,#56,$,$,.ELEMENT.,$,$,$,$,$); #58=IFCDIRECTION((0.,0.,1.)); #59=IFCDIRECTION((1.,0.,0.)); #60=IFCCARTESIANPOINT((0.,0.,0.)); #61=IFCAXIS2PLACEMENT3D(#60,#58,#59); #62=IFCLOCALPLACEMENT(#56,#61); -#63=IFCBUILDINGSTOREY('2qOccTs1f8buj2xOgvyzqf',#20,'StoreyB1',$,$,#62,$,$,.ELEMENT.,0.); -#64=IFCRELAGGREGATES('0813aUJkLEkPqXb8t4QgGU',#20,'Site Container',$,#57,(#63,#70)); +#63=IFCBUILDINGSTOREY('0JOGaKUe2AkPAEc7XHFNAL',#20,'StoreyB1',$,$,#62,$,$,.ELEMENT.,0.); +#64=IFCRELAGGREGATES('11xeFLS5j8bwtxDJIILMRV',#20,'Site Container',$,#57,(#63,#70)); #65=IFCDIRECTION((0.,0.,1.)); #66=IFCDIRECTION((1.,0.,0.)); #67=IFCCARTESIANPOINT((0.,0.,0.)); #68=IFCAXIS2PLACEMENT3D(#67,#65,#66); #69=IFCLOCALPLACEMENT(#56,#68); -#70=IFCBUILDINGSTOREY('38wyeAtIf7feholt0f10MR',#20,'StoreyB2',$,$,#69,$,$,.ELEMENT.,0.); +#70=IFCBUILDINGSTOREY('3F8nChjX3uTPnUdNYJF4hP',#20,'StoreyB2',$,$,#69,$,$,.ELEMENT.,0.); #71=IFCISHAPEPROFILEDEF(.AREA.,'IPE200',$,0.10000000000000001,0.20000000000000001,0.0055999999999999999,0.0085000000000000006,$,$,$); #72=IFCPROPERTYSINGLEVALUE('sec_type',$,IFCTEXT('I'),$); #73=IFCPROPERTYSINGLEVALUE('h',$,IFCREAL(0.20000000000000001),$); @@ -85,7 +85,7 @@ DATA; #78=IFCPROPERTYSINGLEVALUE('t_fbtn',$,IFCREAL(0.0085000000000000006),$); #79=IFCPROPERTYSINGLEVALUE('sec_str',$,IFCTEXT('IPE200'),$); #80=IFCPROFILEPROPERTIES('ADA_SectionParameters',$,(#72,#73,#74,#75,#76,#77,#78,#79),#71); -#81=IFCBEAMTYPE('27lJFfiwvBohxkPpdwx0DZ',#20,'IPE200','IPE200',$,$,$,$,$,.BEAM.); +#81=IFCBEAMTYPE('2uI4epsC133RYQmTJP23Bh',#20,'IPE200','IPE200',$,$,$,$,$,.BEAM.); #82=IFCMATERIAL('S355',$,'Steel'); #83=IFCPROPERTYSINGLEVALUE('Grade',$,IFCTEXT('S355'),$); #84=IFCPROPERTYSINGLEVALUE('YieldStress',$,IFCPRESSUREMEASURE(355000000.),$); @@ -104,7 +104,7 @@ DATA; #98=IFCPROPERTYSINGLEVALUE('SpecificHeatCapacity',$,IFCSPECIFICHEATCAPACITYMEASURE(0.029999999999999999),$); #99=IFCPROPERTYSINGLEVALUE('MassDensity',$,IFCMASSDENSITYMEASURE(7850.),$); #100=IFCMATERIALPROPERTIES('MaterialMechanical','A Material property description',(#93,#94,#95,#96,#97,#98,#99),#92); -#101=IFCRELASSOCIATESMATERIAL('3vKULhFMvDWhXDOZN94Kw8',#20,'S420','Objects related to S420',(#118,#139,#159,#179,#379,#399,#419,#439,#639,#659,#679,#699,#899,#919,#939,#959),#92); +#101=IFCRELASSOCIATESMATERIAL('3rLP3S2k1BxwBnQEAf4tqa',#20,'S420','Objects related to S420',(#118,#139,#159,#179,#379,#399,#419,#439,#639,#659,#679,#699,#899,#919,#939,#959),#92); #102=IFCCARTESIANPOINT((0.,0.,0.)); #103=IFCDIRECTION((0.,0.,1.)); #104=IFCDIRECTION((1.,0.,0.)); @@ -121,7 +121,7 @@ DATA; #115=IFCEXTRUDEDAREASOLID(#113,#110,#114,0.01); #116=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#115)); #117=IFCPRODUCTDEFINITIONSHAPE($,$,(#116)); -#118=IFCPLATE('0F6Pl$zQz8Xw5ATcLIPy25',#20,'a1-pl0','a1-pl0',$,#106,#117,$,$); +#118=IFCPLATE('1Zv2vqhj4psd63TVD0VzUa',#20,'a1-pl0','a1-pl0',$,#106,#117,$,$); #119=IFCCOLOURRGB('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',0.80000000000000004,0.80000000000000004,0.80000000000000004); #120=IFCSURFACESTYLESHADING(#119,0.); #121=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#120)); @@ -142,7 +142,7 @@ DATA; #136=IFCEXTRUDEDAREASOLID(#134,#131,#135,0.01); #137=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#136)); #138=IFCPRODUCTDEFINITIONSHAPE($,$,(#137)); -#139=IFCPLATE('2htWNZQEHCoQ582Xq9xsvV',#20,'a1-pl1','a1-pl1',$,#127,#138,$,$); +#139=IFCPLATE('1WLiqxLpee_2zSTuD0kehJ',#20,'a1-pl1','a1-pl1',$,#127,#138,$,$); #140=IFCSURFACESTYLESHADING(#119,0.); #141=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#140)); #142=IFCSTYLEDITEM(#136,(#141),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -162,7 +162,7 @@ DATA; #156=IFCEXTRUDEDAREASOLID(#154,#151,#155,0.01); #157=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#156)); #158=IFCPRODUCTDEFINITIONSHAPE($,$,(#157)); -#159=IFCPLATE('0U35vqt7bFAv3fnAB3rxYe',#20,'a1-pl2','a1-pl2',$,#147,#158,$,$); +#159=IFCPLATE('3bDaNag$ex4EPxKhsBbinm',#20,'a1-pl2','a1-pl2',$,#147,#158,$,$); #160=IFCSURFACESTYLESHADING(#119,0.); #161=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#160)); #162=IFCSTYLEDITEM(#156,(#161),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -182,7 +182,7 @@ DATA; #176=IFCEXTRUDEDAREASOLID(#174,#171,#175,0.01); #177=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#176)); #178=IFCPRODUCTDEFINITIONSHAPE($,$,(#177)); -#179=IFCPLATE('0jAlfB23H0B9Ez4AnoZCH9',#20,'a1-pl3','a1-pl3',$,#167,#178,$,$); +#179=IFCPLATE('2iXbdRFJMFA$jMc8wfdjmw',#20,'a1-pl3','a1-pl3',$,#167,#178,$,$); #180=IFCSURFACESTYLESHADING(#119,0.); #181=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#180)); #182=IFCSTYLEDITEM(#176,(#181),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -211,11 +211,11 @@ DATA; #205=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#204)); #206=IFCSTYLEDITEM(#193,(#205),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #207=IFCPRODUCTDEFINITIONSHAPE($,$,(#194,#203)); -#208=IFCBEAM('0TG3Y1hD99dhMV3UIcjqUZ',#20,'a1-bm0','IPE200','Beam',#199,#207,$,$); +#208=IFCBEAM('2rRiKsI0OLUSI4LhvIlKH4',#20,'a1-bm0','IPE200','Beam',#199,#207,$,$); #209=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #210=IFCMATERIALPROFILESET('IPE200',$,(#209),$); #211=IFCMATERIALPROFILESETUSAGE(#210,5,$); -#212=IFCRELASSOCIATESMATERIAL('1lBOMVsCb4xfo4FsohmgeM',#20,$,$,(#208),#211); +#212=IFCRELASSOCIATESMATERIAL('1ihyhghAnA1Rm2j4S6vo3H',#20,$,$,(#208),#211); #213=IFCDIRECTION((0.,0.,1.)); #214=IFCDIRECTION((1.,0.,0.)); #215=IFCCARTESIANPOINT((0.,0.,0.)); @@ -241,11 +241,11 @@ DATA; #235=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#234)); #236=IFCSTYLEDITEM(#223,(#235),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #237=IFCPRODUCTDEFINITIONSHAPE($,$,(#224,#233)); -#238=IFCBEAM('30RP3QpC1C8uNtEhiVRxSO',#20,'a1-bm1','IPE200','Beam',#229,#237,$,$); +#238=IFCBEAM('0eBs4NDxpv9K_X3DQwK1tW',#20,'a1-bm1','IPE200','Beam',#229,#237,$,$); #239=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #240=IFCMATERIALPROFILESET('IPE200',$,(#239),$); #241=IFCMATERIALPROFILESETUSAGE(#240,5,$); -#242=IFCRELASSOCIATESMATERIAL('3kSrcZTO5EJAoQQad1EBPM',#20,$,$,(#238),#241); +#242=IFCRELASSOCIATESMATERIAL('2qCULxcFrExBLgndESDc5D',#20,$,$,(#238),#241); #243=IFCDIRECTION((0.,0.,1.)); #244=IFCDIRECTION((1.,0.,0.)); #245=IFCCARTESIANPOINT((0.,0.,0.)); @@ -271,11 +271,11 @@ DATA; #265=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#264)); #266=IFCSTYLEDITEM(#253,(#265),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #267=IFCPRODUCTDEFINITIONSHAPE($,$,(#254,#263)); -#268=IFCBEAM('1999gYpf14shU_FF1sY$9X',#20,'a1-bm2','IPE200','Beam',#259,#267,$,$); +#268=IFCBEAM('3S9QJipR1z$Kmigdsne4nr',#20,'a1-bm2','IPE200','Beam',#259,#267,$,$); #269=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #270=IFCMATERIALPROFILESET('IPE200',$,(#269),$); #271=IFCMATERIALPROFILESETUSAGE(#270,5,$); -#272=IFCRELASSOCIATESMATERIAL('1$ubUJf8H9NQoqZGD5Iudv',#20,$,$,(#268),#271); +#272=IFCRELASSOCIATESMATERIAL('2gSzc8axn5VAtSWGpdwAts',#20,$,$,(#268),#271); #273=IFCDIRECTION((0.,0.,1.)); #274=IFCDIRECTION((1.,0.,0.)); #275=IFCCARTESIANPOINT((0.,0.,0.)); @@ -301,11 +301,11 @@ DATA; #295=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#294)); #296=IFCSTYLEDITEM(#283,(#295),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #297=IFCPRODUCTDEFINITIONSHAPE($,$,(#284,#293)); -#298=IFCBEAM('1kJvTeAezAeBLVaj8k3rL1',#20,'a1-bm3','IPE200','Beam',#289,#297,$,$); +#298=IFCBEAM('1ARd9xz8L3jMNzGcOz37OW',#20,'a1-bm3','IPE200','Beam',#289,#297,$,$); #299=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #300=IFCMATERIALPROFILESET('IPE200',$,(#299),$); #301=IFCMATERIALPROFILESETUSAGE(#300,5,$); -#302=IFCRELASSOCIATESMATERIAL('3vq$jnB$j38u7dEsdazrmj',#20,$,$,(#298),#301); +#302=IFCRELASSOCIATESMATERIAL('2pErQxEmX98ePjFc9EsoSW',#20,$,$,(#298),#301); #303=IFCDIRECTION((0.,0.,1.)); #304=IFCDIRECTION((1.,0.,0.)); #305=IFCCARTESIANPOINT((0.,0.,0.)); @@ -331,11 +331,11 @@ DATA; #325=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#324)); #326=IFCSTYLEDITEM(#313,(#325),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #327=IFCPRODUCTDEFINITIONSHAPE($,$,(#314,#323)); -#328=IFCBEAM('1ev6Bs_On0cfxVmXeXLXu4',#20,'a1-bm4','IPE200','Beam',#319,#327,$,$); +#328=IFCBEAM('3CSgHwjekZBnKfgyU6QgrY',#20,'a1-bm4','IPE200','Beam',#319,#327,$,$); #329=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #330=IFCMATERIALPROFILESET('IPE200',$,(#329),$); #331=IFCMATERIALPROFILESETUSAGE(#330,5,$); -#332=IFCRELASSOCIATESMATERIAL('2fDMgf8LTCHwd4eAqedMJR',#20,$,$,(#328),#331); +#332=IFCRELASSOCIATESMATERIAL('0A3MDPPvb5K99rHmSFZXC5',#20,$,$,(#328),#331); #333=IFCDIRECTION((0.,0.,1.)); #334=IFCDIRECTION((1.,0.,0.)); #335=IFCCARTESIANPOINT((0.,0.,0.)); @@ -361,11 +361,11 @@ DATA; #355=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#354)); #356=IFCSTYLEDITEM(#343,(#355),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #357=IFCPRODUCTDEFINITIONSHAPE($,$,(#344,#353)); -#358=IFCBEAM('2BkPOEt5bFWAAlVoLcMPTA',#20,'a1-bm5','IPE200','Beam',#349,#357,$,$); +#358=IFCBEAM('2YvpXjKYZINP7xs0dCAn38',#20,'a1-bm5','IPE200','Beam',#349,#357,$,$); #359=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #360=IFCMATERIALPROFILESET('IPE200',$,(#359),$); #361=IFCMATERIALPROFILESETUSAGE(#360,5,$); -#362=IFCRELASSOCIATESMATERIAL('0aNXWmJWb2meNwPSgnZr7b',#20,$,$,(#358),#361); +#362=IFCRELASSOCIATESMATERIAL('2a4M4PcEHC7Q6XNYp2JqHh',#20,$,$,(#358),#361); #363=IFCCARTESIANPOINT((0.,0.,0.)); #364=IFCDIRECTION((0.,0.,1.)); #365=IFCDIRECTION((1.,0.,0.)); @@ -382,7 +382,7 @@ DATA; #376=IFCEXTRUDEDAREASOLID(#374,#371,#375,0.01); #377=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#376)); #378=IFCPRODUCTDEFINITIONSHAPE($,$,(#377)); -#379=IFCPLATE('2BpqK92PbA$fNyaf164n_z',#20,'aa-pl0','aa-pl0',$,#367,#378,$,$); +#379=IFCPLATE('0i_8x83V_3NJZOIBJ$434A',#20,'aa-pl0','aa-pl0',$,#367,#378,$,$); #380=IFCSURFACESTYLESHADING(#119,0.); #381=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#380)); #382=IFCSTYLEDITEM(#376,(#381),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -402,7 +402,7 @@ DATA; #396=IFCEXTRUDEDAREASOLID(#394,#391,#395,0.01); #397=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#396)); #398=IFCPRODUCTDEFINITIONSHAPE($,$,(#397)); -#399=IFCPLATE('1OtoN4bQ53Re8prR1Bx17i',#20,'aa-pl1','aa-pl1',$,#387,#398,$,$); +#399=IFCPLATE('3rDwjY2dJE3chaUACD89cQ',#20,'aa-pl1','aa-pl1',$,#387,#398,$,$); #400=IFCSURFACESTYLESHADING(#119,0.); #401=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#400)); #402=IFCSTYLEDITEM(#396,(#401),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -422,7 +422,7 @@ DATA; #416=IFCEXTRUDEDAREASOLID(#414,#411,#415,0.01); #417=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#416)); #418=IFCPRODUCTDEFINITIONSHAPE($,$,(#417)); -#419=IFCPLATE('2NI4LlpOb8OOJRFmZompXw',#20,'aa-pl2','aa-pl2',$,#407,#418,$,$); +#419=IFCPLATE('378UvpAy20x$ktcmtMncxD',#20,'aa-pl2','aa-pl2',$,#407,#418,$,$); #420=IFCSURFACESTYLESHADING(#119,0.); #421=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#420)); #422=IFCSTYLEDITEM(#416,(#421),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -442,7 +442,7 @@ DATA; #436=IFCEXTRUDEDAREASOLID(#434,#431,#435,0.01); #437=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#436)); #438=IFCPRODUCTDEFINITIONSHAPE($,$,(#437)); -#439=IFCPLATE('2NAhjP_QP5agCjjjSLkX2v',#20,'aa-pl3','aa-pl3',$,#427,#438,$,$); +#439=IFCPLATE('1wyjHnelNOU4NZ1z$I5Lza',#20,'aa-pl3','aa-pl3',$,#427,#438,$,$); #440=IFCSURFACESTYLESHADING(#119,0.); #441=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#440)); #442=IFCSTYLEDITEM(#436,(#441),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -471,11 +471,11 @@ DATA; #465=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#464)); #466=IFCSTYLEDITEM(#453,(#465),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #467=IFCPRODUCTDEFINITIONSHAPE($,$,(#454,#463)); -#468=IFCBEAM('1U70Nr3EHAjQ8sqdvBh9LT',#20,'aa-bm0','IPE200','Beam',#459,#467,$,$); +#468=IFCBEAM('1exFulb6cKYczlQNrV0z$b',#20,'aa-bm0','IPE200','Beam',#459,#467,$,$); #469=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #470=IFCMATERIALPROFILESET('IPE200',$,(#469),$); #471=IFCMATERIALPROFILESETUSAGE(#470,5,$); -#472=IFCRELASSOCIATESMATERIAL('3WTFhC9FbCh9ulgPpFDjRz',#20,$,$,(#468),#471); +#472=IFCRELASSOCIATESMATERIAL('1UXAkNvjH3kfFzA7qUCfy8',#20,$,$,(#468),#471); #473=IFCDIRECTION((0.,0.,1.)); #474=IFCDIRECTION((1.,0.,0.)); #475=IFCCARTESIANPOINT((0.,0.,0.)); @@ -501,11 +501,11 @@ DATA; #495=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#494)); #496=IFCSTYLEDITEM(#483,(#495),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #497=IFCPRODUCTDEFINITIONSHAPE($,$,(#484,#493)); -#498=IFCBEAM('2MSP7SMeb7sOvMwO3NG9gv',#20,'aa-bm1','IPE200','Beam',#489,#497,$,$); +#498=IFCBEAM('3pzXs9lBinwO9RMwyGrfR5',#20,'aa-bm1','IPE200','Beam',#489,#497,$,$); #499=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #500=IFCMATERIALPROFILESET('IPE200',$,(#499),$); #501=IFCMATERIALPROFILESETUSAGE(#500,5,$); -#502=IFCRELASSOCIATESMATERIAL('1M69BvdSz2IPFvndMP4GGL',#20,$,$,(#498),#501); +#502=IFCRELASSOCIATESMATERIAL('3vCWgdON92SvCSn$UFzKK8',#20,$,$,(#498),#501); #503=IFCDIRECTION((0.,0.,1.)); #504=IFCDIRECTION((1.,0.,0.)); #505=IFCCARTESIANPOINT((0.,0.,0.)); @@ -531,11 +531,11 @@ DATA; #525=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#524)); #526=IFCSTYLEDITEM(#513,(#525),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #527=IFCPRODUCTDEFINITIONSHAPE($,$,(#514,#523)); -#528=IFCBEAM('26fUqnPJ11BQuvZMpRqrUM',#20,'aa-bm2','IPE200','Beam',#519,#527,$,$); +#528=IFCBEAM('0jgbWX0Ce4434At$BZhw_e',#20,'aa-bm2','IPE200','Beam',#519,#527,$,$); #529=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #530=IFCMATERIALPROFILESET('IPE200',$,(#529),$); #531=IFCMATERIALPROFILESETUSAGE(#530,5,$); -#532=IFCRELASSOCIATESMATERIAL('0E20DWt1vC1xX8bgJ5LcCe',#20,$,$,(#528),#531); +#532=IFCRELASSOCIATESMATERIAL('1InzRN_YXF98y3$L0_FOVh',#20,$,$,(#528),#531); #533=IFCDIRECTION((0.,0.,1.)); #534=IFCDIRECTION((1.,0.,0.)); #535=IFCCARTESIANPOINT((0.,0.,0.)); @@ -561,11 +561,11 @@ DATA; #555=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#554)); #556=IFCSTYLEDITEM(#543,(#555),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #557=IFCPRODUCTDEFINITIONSHAPE($,$,(#544,#553)); -#558=IFCBEAM('1YhJcurQvBiAer1K_aCO0k',#20,'aa-bm3','IPE200','Beam',#549,#557,$,$); +#558=IFCBEAM('1V7gx6m2ilEj0dwOZcQkrC',#20,'aa-bm3','IPE200','Beam',#549,#557,$,$); #559=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #560=IFCMATERIALPROFILESET('IPE200',$,(#559),$); #561=IFCMATERIALPROFILESETUSAGE(#560,5,$); -#562=IFCRELASSOCIATESMATERIAL('25VzdsnvjF7RA5F5eE50Pl',#20,$,$,(#558),#561); +#562=IFCRELASSOCIATESMATERIAL('29YKmUo2rFD8Fa64TZSOvD',#20,$,$,(#558),#561); #563=IFCDIRECTION((0.,0.,1.)); #564=IFCDIRECTION((1.,0.,0.)); #565=IFCCARTESIANPOINT((0.,0.,0.)); @@ -591,11 +591,11 @@ DATA; #585=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#584)); #586=IFCSTYLEDITEM(#573,(#585),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #587=IFCPRODUCTDEFINITIONSHAPE($,$,(#574,#583)); -#588=IFCBEAM('0KHxfl3pf8tPzcCgueG$bf',#20,'aa-bm4','IPE200','Beam',#579,#587,$,$); +#588=IFCBEAM('1g6G0p5EVpbzr9lqbNhRaR',#20,'aa-bm4','IPE200','Beam',#579,#587,$,$); #589=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #590=IFCMATERIALPROFILESET('IPE200',$,(#589),$); #591=IFCMATERIALPROFILESETUSAGE(#590,5,$); -#592=IFCRELASSOCIATESMATERIAL('2TKhzTEE11ug5eDjGmC_4K',#20,$,$,(#588),#591); +#592=IFCRELASSOCIATESMATERIAL('33w9TcX3z7YR4lalgyOGhu',#20,$,$,(#588),#591); #593=IFCDIRECTION((0.,0.,1.)); #594=IFCDIRECTION((1.,0.,0.)); #595=IFCCARTESIANPOINT((0.,0.,0.)); @@ -621,11 +621,11 @@ DATA; #615=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#614)); #616=IFCSTYLEDITEM(#603,(#615),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #617=IFCPRODUCTDEFINITIONSHAPE($,$,(#604,#613)); -#618=IFCBEAM('1S1BAuYp16pxtLoYYQcgyf',#20,'aa-bm5','IPE200','Beam',#609,#617,$,$); +#618=IFCBEAM('3Q8zbdP_Z5nB$7EpwvQuLk',#20,'aa-bm5','IPE200','Beam',#609,#617,$,$); #619=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #620=IFCMATERIALPROFILESET('IPE200',$,(#619),$); #621=IFCMATERIALPROFILESETUSAGE(#620,5,$); -#622=IFCRELASSOCIATESMATERIAL('0uzd5EfZj48OH4kICqz$iS',#20,$,$,(#618),#621); +#622=IFCRELASSOCIATESMATERIAL('3hVHh84f9A$AahNDHnK$DV',#20,$,$,(#618),#621); #623=IFCCARTESIANPOINT((0.,0.,0.)); #624=IFCDIRECTION((0.,0.,1.)); #625=IFCDIRECTION((1.,0.,0.)); @@ -642,7 +642,7 @@ DATA; #636=IFCEXTRUDEDAREASOLID(#634,#631,#635,0.01); #637=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#636)); #638=IFCPRODUCTDEFINITIONSHAPE($,$,(#637)); -#639=IFCPLATE('2s9utN4U16ZO6sBuY01khI',#20,'b1-pl0','b1-pl0',$,#627,#638,$,$); +#639=IFCPLATE('1g5FnC2CpEF03O$iq0iBNU',#20,'b1-pl0','b1-pl0',$,#627,#638,$,$); #640=IFCSURFACESTYLESHADING(#119,0.); #641=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#640)); #642=IFCSTYLEDITEM(#636,(#641),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -662,7 +662,7 @@ DATA; #656=IFCEXTRUDEDAREASOLID(#654,#651,#655,0.01); #657=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#656)); #658=IFCPRODUCTDEFINITIONSHAPE($,$,(#657)); -#659=IFCPLATE('0glH5c27n96RO7xi3NEuJa',#20,'b1-pl1','b1-pl1',$,#647,#658,$,$); +#659=IFCPLATE('0qPP19WlUigt38jrSrsC0Y',#20,'b1-pl1','b1-pl1',$,#647,#658,$,$); #660=IFCSURFACESTYLESHADING(#119,0.); #661=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#660)); #662=IFCSTYLEDITEM(#656,(#661),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -682,7 +682,7 @@ DATA; #676=IFCEXTRUDEDAREASOLID(#674,#671,#675,0.01); #677=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#676)); #678=IFCPRODUCTDEFINITIONSHAPE($,$,(#677)); -#679=IFCPLATE('0hIrSID9HA6QmxeI_2c0yq',#20,'b1-pl2','b1-pl2',$,#667,#678,$,$); +#679=IFCPLATE('2QNHz3IxXtOCTiNEEZuamZ',#20,'b1-pl2','b1-pl2',$,#667,#678,$,$); #680=IFCSURFACESTYLESHADING(#119,0.); #681=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#680)); #682=IFCSTYLEDITEM(#676,(#681),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -702,7 +702,7 @@ DATA; #696=IFCEXTRUDEDAREASOLID(#694,#691,#695,0.01); #697=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#696)); #698=IFCPRODUCTDEFINITIONSHAPE($,$,(#697)); -#699=IFCPLATE('0HOdWVCtD6jA22D84rHoZF',#20,'b1-pl3','b1-pl3',$,#687,#698,$,$); +#699=IFCPLATE('0yVRjuoWOKO2HY_opVUVXc',#20,'b1-pl3','b1-pl3',$,#687,#698,$,$); #700=IFCSURFACESTYLESHADING(#119,0.); #701=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#700)); #702=IFCSTYLEDITEM(#696,(#701),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -731,11 +731,11 @@ DATA; #725=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#724)); #726=IFCSTYLEDITEM(#713,(#725),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #727=IFCPRODUCTDEFINITIONSHAPE($,$,(#714,#723)); -#728=IFCBEAM('1xUtFelqT8EOPR3iEDyZVj',#20,'b1-bm0','IPE200','Beam',#719,#727,$,$); +#728=IFCBEAM('1FP2qPBI8$jrsd5PlngRu$',#20,'b1-bm0','IPE200','Beam',#719,#727,$,$); #729=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #730=IFCMATERIALPROFILESET('IPE200',$,(#729),$); #731=IFCMATERIALPROFILESETUSAGE(#730,5,$); -#732=IFCRELASSOCIATESMATERIAL('3_LdmDfDv3AuKGZDOfGJGu',#20,$,$,(#728),#731); +#732=IFCRELASSOCIATESMATERIAL('2pWEtQ1NjD0e4rUn7OqeFg',#20,$,$,(#728),#731); #733=IFCDIRECTION((0.,0.,1.)); #734=IFCDIRECTION((1.,0.,0.)); #735=IFCCARTESIANPOINT((0.,0.,0.)); @@ -761,11 +761,11 @@ DATA; #755=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#754)); #756=IFCSTYLEDITEM(#743,(#755),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #757=IFCPRODUCTDEFINITIONSHAPE($,$,(#744,#753)); -#758=IFCBEAM('0DdM_AHS96yvBudKCWRYp4',#20,'b1-bm1','IPE200','Beam',#749,#757,$,$); +#758=IFCBEAM('0IyZfVFUUUgnwfdpDBTZMu',#20,'b1-bm1','IPE200','Beam',#749,#757,$,$); #759=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #760=IFCMATERIALPROFILESET('IPE200',$,(#759),$); #761=IFCMATERIALPROFILESETUSAGE(#760,5,$); -#762=IFCRELASSOCIATESMATERIAL('20sNsquIH1BRxY6eTv7vEg',#20,$,$,(#758),#761); +#762=IFCRELASSOCIATESMATERIAL('1umFGB7t14ohrR1FbS0fQU',#20,$,$,(#758),#761); #763=IFCDIRECTION((0.,0.,1.)); #764=IFCDIRECTION((1.,0.,0.)); #765=IFCCARTESIANPOINT((0.,0.,0.)); @@ -791,11 +791,11 @@ DATA; #785=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#784)); #786=IFCSTYLEDITEM(#773,(#785),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #787=IFCPRODUCTDEFINITIONSHAPE($,$,(#774,#783)); -#788=IFCBEAM('3w5QWUsiH8RR$ImLr6RDdD',#20,'b1-bm2','IPE200','Beam',#779,#787,$,$); +#788=IFCBEAM('0PXvnxnKhJJaVspNOSVrv1',#20,'b1-bm2','IPE200','Beam',#779,#787,$,$); #789=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #790=IFCMATERIALPROFILESET('IPE200',$,(#789),$); #791=IFCMATERIALPROFILESETUSAGE(#790,5,$); -#792=IFCRELASSOCIATESMATERIAL('1WjW3dKyb9_RrBFFClJOi2',#20,$,$,(#788),#791); +#792=IFCRELASSOCIATESMATERIAL('2oEV26aAXFoet60HlLp7vd',#20,$,$,(#788),#791); #793=IFCDIRECTION((0.,0.,1.)); #794=IFCDIRECTION((1.,0.,0.)); #795=IFCCARTESIANPOINT((0.,0.,0.)); @@ -821,11 +821,11 @@ DATA; #815=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#814)); #816=IFCSTYLEDITEM(#803,(#815),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #817=IFCPRODUCTDEFINITIONSHAPE($,$,(#804,#813)); -#818=IFCBEAM('2TYjj7LGL0wwFEpMaE2tYg',#20,'b1-bm3','IPE200','Beam',#809,#817,$,$); +#818=IFCBEAM('0bwrkTHTJfmVyDV3SWVzwK',#20,'b1-bm3','IPE200','Beam',#809,#817,$,$); #819=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #820=IFCMATERIALPROFILESET('IPE200',$,(#819),$); #821=IFCMATERIALPROFILESETUSAGE(#820,5,$); -#822=IFCRELASSOCIATESMATERIAL('3Ha1Zv60HDQfNM6ChNBCVq',#20,$,$,(#818),#821); +#822=IFCRELASSOCIATESMATERIAL('3W96c9lN1BROXiplOUFMOd',#20,$,$,(#818),#821); #823=IFCDIRECTION((0.,0.,1.)); #824=IFCDIRECTION((1.,0.,0.)); #825=IFCCARTESIANPOINT((0.,0.,0.)); @@ -851,11 +851,11 @@ DATA; #845=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#844)); #846=IFCSTYLEDITEM(#833,(#845),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #847=IFCPRODUCTDEFINITIONSHAPE($,$,(#834,#843)); -#848=IFCBEAM('1EV1qW9OT8xfN_WoYTHQLz',#20,'b1-bm4','IPE200','Beam',#839,#847,$,$); +#848=IFCBEAM('09DR0eOQl5NpATiEOWTwYn',#20,'b1-bm4','IPE200','Beam',#839,#847,$,$); #849=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #850=IFCMATERIALPROFILESET('IPE200',$,(#849),$); #851=IFCMATERIALPROFILESETUSAGE(#850,5,$); -#852=IFCRELASSOCIATESMATERIAL('2neRhzVCL2AeKDHWX98lrl',#20,$,$,(#848),#851); +#852=IFCRELASSOCIATESMATERIAL('1UyEOtGmDAphEow7O_HGjJ',#20,$,$,(#848),#851); #853=IFCDIRECTION((0.,0.,1.)); #854=IFCDIRECTION((1.,0.,0.)); #855=IFCCARTESIANPOINT((0.,0.,0.)); @@ -881,11 +881,11 @@ DATA; #875=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#874)); #876=IFCSTYLEDITEM(#863,(#875),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #877=IFCPRODUCTDEFINITIONSHAPE($,$,(#864,#873)); -#878=IFCBEAM('2A16LYTt99Pu4Uo9b8Wtc4',#20,'b1-bm5','IPE200','Beam',#869,#877,$,$); +#878=IFCBEAM('0XHu0yc8pT8WpfbO00soIg',#20,'b1-bm5','IPE200','Beam',#869,#877,$,$); #879=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #880=IFCMATERIALPROFILESET('IPE200',$,(#879),$); #881=IFCMATERIALPROFILESETUSAGE(#880,5,$); -#882=IFCRELASSOCIATESMATERIAL('1fIg_RT2j8YedOjOdu5Rbf',#20,$,$,(#878),#881); +#882=IFCRELASSOCIATESMATERIAL('3cFSUrTG9CfBiiCn53uc4v',#20,$,$,(#878),#881); #883=IFCCARTESIANPOINT((0.,0.,0.)); #884=IFCDIRECTION((0.,0.,1.)); #885=IFCDIRECTION((1.,0.,0.)); @@ -902,7 +902,7 @@ DATA; #896=IFCEXTRUDEDAREASOLID(#894,#891,#895,0.01); #897=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#896)); #898=IFCPRODUCTDEFINITIONSHAPE($,$,(#897)); -#899=IFCPLATE('3O9DBjHxjEK8sBksjhOi9I',#20,'b2-pl0','b2-pl0',$,#887,#898,$,$); +#899=IFCPLATE('1r2wNAhsYMOwzfCgjVNx8N',#20,'b2-pl0','b2-pl0',$,#887,#898,$,$); #900=IFCSURFACESTYLESHADING(#119,0.); #901=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#900)); #902=IFCSTYLEDITEM(#896,(#901),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -922,7 +922,7 @@ DATA; #916=IFCEXTRUDEDAREASOLID(#914,#911,#915,0.01); #917=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#916)); #918=IFCPRODUCTDEFINITIONSHAPE($,$,(#917)); -#919=IFCPLATE('0fXANsh1b7ewZ6gECr24nd',#20,'b2-pl1','b2-pl1',$,#907,#918,$,$); +#919=IFCPLATE('34b_niFbK_x7Ui15X5EdNj',#20,'b2-pl1','b2-pl1',$,#907,#918,$,$); #920=IFCSURFACESTYLESHADING(#119,0.); #921=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#920)); #922=IFCSTYLEDITEM(#916,(#921),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -942,7 +942,7 @@ DATA; #936=IFCEXTRUDEDAREASOLID(#934,#931,#935,0.01); #937=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#936)); #938=IFCPRODUCTDEFINITIONSHAPE($,$,(#937)); -#939=IFCPLATE('0TAzkseIL5ZB$X5tC5_4a4',#20,'b2-pl2','b2-pl2',$,#927,#938,$,$); +#939=IFCPLATE('1HvtXVX_7euSsiPX1YkYAa',#20,'b2-pl2','b2-pl2',$,#927,#938,$,$); #940=IFCSURFACESTYLESHADING(#119,0.); #941=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#940)); #942=IFCSTYLEDITEM(#936,(#941),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -962,7 +962,7 @@ DATA; #956=IFCEXTRUDEDAREASOLID(#954,#951,#955,0.01); #957=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#956)); #958=IFCPRODUCTDEFINITIONSHAPE($,$,(#957)); -#959=IFCPLATE('0arSvo2FT1GehZGKU9leNT',#20,'b2-pl3','b2-pl3',$,#947,#958,$,$); +#959=IFCPLATE('1WxipBp1UVEzYHB$OcBFsi',#20,'b2-pl3','b2-pl3',$,#947,#958,$,$); #960=IFCSURFACESTYLESHADING(#119,0.); #961=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#960)); #962=IFCSTYLEDITEM(#956,(#961),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); @@ -991,11 +991,11 @@ DATA; #985=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#984)); #986=IFCSTYLEDITEM(#973,(#985),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #987=IFCPRODUCTDEFINITIONSHAPE($,$,(#974,#983)); -#988=IFCBEAM('0zqpDz3mr0P8rnThbPG_lT',#20,'b2-bm0','IPE200','Beam',#979,#987,$,$); +#988=IFCBEAM('0v2L4Meq4BArP4UyilJqZ5',#20,'b2-bm0','IPE200','Beam',#979,#987,$,$); #989=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #990=IFCMATERIALPROFILESET('IPE200',$,(#989),$); #991=IFCMATERIALPROFILESETUSAGE(#990,5,$); -#992=IFCRELASSOCIATESMATERIAL('2mNqeJedT3jP23nqe8yOa_',#20,$,$,(#988),#991); +#992=IFCRELASSOCIATESMATERIAL('1q$J_FS$9EZxdqVSsefrSp',#20,$,$,(#988),#991); #993=IFCDIRECTION((0.,0.,1.)); #994=IFCDIRECTION((1.,0.,0.)); #995=IFCCARTESIANPOINT((0.,0.,0.)); @@ -1021,11 +1021,11 @@ DATA; #1015=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1014)); #1016=IFCSTYLEDITEM(#1003,(#1015),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #1017=IFCPRODUCTDEFINITIONSHAPE($,$,(#1004,#1013)); -#1018=IFCBEAM('1ukQ_P4O59uQ$WI40$x0Qz',#20,'b2-bm1','IPE200','Beam',#1009,#1017,$,$); +#1018=IFCBEAM('2dThkUTMzhozUEdWheLDhv',#20,'b2-bm1','IPE200','Beam',#1009,#1017,$,$); #1019=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #1020=IFCMATERIALPROFILESET('IPE200',$,(#1019),$); #1021=IFCMATERIALPROFILESETUSAGE(#1020,5,$); -#1022=IFCRELASSOCIATESMATERIAL('15AR0cxBD1EQbWJZPB8R06',#20,$,$,(#1018),#1021); +#1022=IFCRELASSOCIATESMATERIAL('3DrD0uKmLBLeNMm70D$xXo',#20,$,$,(#1018),#1021); #1023=IFCDIRECTION((0.,0.,1.)); #1024=IFCDIRECTION((1.,0.,0.)); #1025=IFCCARTESIANPOINT((0.,0.,0.)); @@ -1051,11 +1051,11 @@ DATA; #1045=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1044)); #1046=IFCSTYLEDITEM(#1033,(#1045),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #1047=IFCPRODUCTDEFINITIONSHAPE($,$,(#1034,#1043)); -#1048=IFCBEAM('0y7KELCHrE69j5MCVicVWZ',#20,'b2-bm2','IPE200','Beam',#1039,#1047,$,$); +#1048=IFCBEAM('1OnvJlQoUSaD5TGxs2jV_l',#20,'b2-bm2','IPE200','Beam',#1039,#1047,$,$); #1049=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #1050=IFCMATERIALPROFILESET('IPE200',$,(#1049),$); #1051=IFCMATERIALPROFILESETUSAGE(#1050,5,$); -#1052=IFCRELASSOCIATESMATERIAL('0VeKzn8TPC5BHjuuMzu8FH',#20,$,$,(#1048),#1051); +#1052=IFCRELASSOCIATESMATERIAL('3J2S0orrHA1Qz0Oe7Ic45A',#20,$,$,(#1048),#1051); #1053=IFCDIRECTION((0.,0.,1.)); #1054=IFCDIRECTION((1.,0.,0.)); #1055=IFCCARTESIANPOINT((0.,0.,0.)); @@ -1081,11 +1081,11 @@ DATA; #1075=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1074)); #1076=IFCSTYLEDITEM(#1063,(#1075),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #1077=IFCPRODUCTDEFINITIONSHAPE($,$,(#1064,#1073)); -#1078=IFCBEAM('1VLDYxnN16m8x7b$f4qnXQ',#20,'b2-bm3','IPE200','Beam',#1069,#1077,$,$); +#1078=IFCBEAM('3KMq7iS5SLHctLN9ewz5HX',#20,'b2-bm3','IPE200','Beam',#1069,#1077,$,$); #1079=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #1080=IFCMATERIALPROFILESET('IPE200',$,(#1079),$); #1081=IFCMATERIALPROFILESETUSAGE(#1080,5,$); -#1082=IFCRELASSOCIATESMATERIAL('1XVb7$MunFkB19S6QersBd',#20,$,$,(#1078),#1081); +#1082=IFCRELASSOCIATESMATERIAL('0Zb3kIJz11oPbEqH0z0gqs',#20,$,$,(#1078),#1081); #1083=IFCDIRECTION((0.,0.,1.)); #1084=IFCDIRECTION((1.,0.,0.)); #1085=IFCCARTESIANPOINT((0.,0.,0.)); @@ -1111,11 +1111,11 @@ DATA; #1105=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1104)); #1106=IFCSTYLEDITEM(#1093,(#1105),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #1107=IFCPRODUCTDEFINITIONSHAPE($,$,(#1094,#1103)); -#1108=IFCBEAM('2mB2TQK4f4sOpRxxyW5zSb',#20,'b2-bm4','IPE200','Beam',#1099,#1107,$,$); +#1108=IFCBEAM('1RB$JU5vWyObAfMV$JUre3',#20,'b2-bm4','IPE200','Beam',#1099,#1107,$,$); #1109=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #1110=IFCMATERIALPROFILESET('IPE200',$,(#1109),$); #1111=IFCMATERIALPROFILESETUSAGE(#1110,5,$); -#1112=IFCRELASSOCIATESMATERIAL('3unP9Z8jHAPxNsgbRMsDFN',#20,$,$,(#1108),#1111); +#1112=IFCRELASSOCIATESMATERIAL('15ISoIr4b7IvH3xR49Mp8Z',#20,$,$,(#1108),#1111); #1113=IFCDIRECTION((0.,0.,1.)); #1114=IFCDIRECTION((1.,0.,0.)); #1115=IFCCARTESIANPOINT((0.,0.,0.)); @@ -1141,15 +1141,15 @@ DATA; #1135=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1134)); #1136=IFCSTYLEDITEM(#1123,(#1135),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); #1137=IFCPRODUCTDEFINITIONSHAPE($,$,(#1124,#1133)); -#1138=IFCBEAM('1Ode1qKaTAQBmS9UQpYlUx',#20,'b2-bm5','IPE200','Beam',#1129,#1137,$,$); +#1138=IFCBEAM('1vLZwHHcHbHH5130oKqkUt',#20,'b2-bm5','IPE200','Beam',#1129,#1137,$,$); #1139=IFCMATERIALPROFILE('IPE200','A material profile',#82,#71,$,'LoadBearing'); #1140=IFCMATERIALPROFILESET('IPE200',$,(#1139),$); #1141=IFCMATERIALPROFILESETUSAGE(#1140,5,$); -#1142=IFCRELASSOCIATESMATERIAL('2H_2_UMeL2thSnIcFFWrPt',#20,$,$,(#1138),#1141); -#1143=IFCRELCONTAINEDINSPATIALSTRUCTURE('1JNO0jyvn4TxWsqA2WkcOh',#20,'Physical model',$,(#118,#139,#159,#179,#208,#238,#268,#298,#328,#358),#37); -#1144=IFCRELAGGREGATES('1y16d_oQf54R1A07y7ouem',#20,'Element Decomposition',$,#50,(#379,#399,#419,#439,#468,#498,#528,#558,#588,#618)); -#1145=IFCRELCONTAINEDINSPATIALSTRUCTURE('3hyyNFdBz5F8uGhsGksu7t',#20,'Physical model',$,(#639,#659,#679,#699,#728,#758,#788,#818,#848,#878),#63); -#1146=IFCRELCONTAINEDINSPATIALSTRUCTURE('3_1_wTpAP37xO9iV29TsnA',#20,'Physical model',$,(#899,#919,#939,#959,#988,#1018,#1048,#1078,#1108,#1138),#70); -#1147=IFCRELDEFINESBYTYPE('2Jo1VFNrj4NhGsCRk0e0t2',#20,'I',$,(#208,#238,#268,#298,#328,#358,#468,#498,#528,#558,#588,#618,#728,#758,#788,#818,#848,#878,#988,#1018,#1048,#1078,#1108,#1138),#81); +#1142=IFCRELASSOCIATESMATERIAL('09_W4zjurBkPH9nIccpL4O',#20,$,$,(#1138),#1141); +#1143=IFCRELCONTAINEDINSPATIALSTRUCTURE('3VuC2OVmb3xg603Wi0Vzp$',#20,'Physical model',$,(#118,#139,#159,#179,#208,#238,#268,#298,#328,#358),#37); +#1144=IFCRELAGGREGATES('0e$K6cn$HD4emEIIPKOCV1',#20,'Element Decomposition',$,#50,(#379,#399,#419,#439,#468,#498,#528,#558,#588,#618)); +#1145=IFCRELCONTAINEDINSPATIALSTRUCTURE('2BSLHIlZvEYQDKZ_t8U_K5',#20,'Physical model',$,(#639,#659,#679,#699,#728,#758,#788,#818,#848,#878),#63); +#1146=IFCRELCONTAINEDINSPATIALSTRUCTURE('0sWcIWOfT3CxRC74z4vqdX',#20,'Physical model',$,(#899,#919,#939,#959,#988,#1018,#1048,#1078,#1108,#1138),#70); +#1147=IFCRELDEFINESBYTYPE('2zIYp07lDC1QVL_q__Iq7J',#20,'I',$,(#208,#238,#268,#298,#328,#358,#468,#498,#528,#558,#588,#618,#728,#758,#788,#818,#848,#878,#988,#1018,#1048,#1078,#1108,#1138),#81); ENDSEC; END-ISO-10303-21; diff --git a/tests/core/assets/corpus/plant-a_v2-leaf.ifc b/tests/core/assets/corpus/plant-a_v2-leaf.ifc new file mode 100644 index 000000000..c0304be3b --- /dev/null +++ b/tests/core/assets/corpus/plant-a_v2-leaf.ifc @@ -0,0 +1,100 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('/dev/null','2026-09-22T21:27:53+02:00',('AdaUser'),(''),'IfcOpenShell 0.8.5','IfcOpenShell 0.8.5','Nobody'); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; +DATA; +#1=IFCPROJECT('23XAPev3D8aQu7GHqfwrOL',$,'plant-a',$,$,$,$,(#11),#6); +#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#5=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#6=IFCUNITASSIGNMENT((#4,#2,#3,#5)); +#7=IFCCARTESIANPOINT((0.,0.,0.)); +#8=IFCDIRECTION((0.,0.,1.)); +#9=IFCDIRECTION((1.,0.,0.)); +#10=IFCAXIS2PLACEMENT3D(#7,#8,#9); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0000000000000001E-05,#10,$); +#12=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#11,$,.MODEL_VIEW.,$); +#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#11,$,.GRAPH_VIEW.,$); +#14=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#11,$,.MODEL_VIEW.,$); +#15=IFCACTORROLE(.ENGINEER.,$,$); +#16=IFCPERSON('AdaUser',$,$,$,$,$,(#15),$); +#17=IFCORGANIZATION('ADA','Assembly For Design and Analysis',$,$,$); +#18=IFCPERSONANDORGANIZATION(#16,#17,$); +#19=IFCAPPLICATION(#17,'XXX','ADA','ADA'); +#20=IFCOWNERHISTORY(#18,#19,.READWRITE.,$,$,#18,#19,1790105273); +#21=IFCDIRECTION((0.,0.,1.)); +#22=IFCDIRECTION((1.,0.,0.)); +#23=IFCCARTESIANPOINT((0.,0.,0.)); +#24=IFCAXIS2PLACEMENT3D(#23,#21,#22); +#25=IFCLOCALPLACEMENT($,#24); +#26=IFCSITE('3WJPj2eAGiVce6lnlPqOcA',#20,'SiteA',$,$,#25,$,$,.ELEMENT.,$,$,$,$,$); +#27=IFCRELAGGREGATES('26OSEXv4bAguf_EQv8bJAz',#20,'Project Container',$,#1,(#26)); +#28=IFCPROPERTYSINGLEVALUE('project',$,IFCTEXT('plant-a'),$); +#29=IFCPROPERTYSINGLEVALUE('schema',$,IFCTEXT('IFC4X3_add2'),$); +#30=IFCPROPERTYSET('0E7GlMLMj2YRbG7oqBOVXI',#20,'Properties',$,(#28,#29)); +#31=IFCRELDEFINESBYPROPERTIES('1Z3Jj7x2z5_hlnCJiexjYB',#20,'Properties',$,(#26),#30); +#32=IFCDIRECTION((0.,0.,1.)); +#33=IFCDIRECTION((1.,0.,0.)); +#34=IFCCARTESIANPOINT((0.,0.,0.)); +#35=IFCAXIS2PLACEMENT3D(#34,#32,#33); +#36=IFCLOCALPLACEMENT(#25,#35); +#37=IFCBUILDINGSTOREY('0Hca7T9wUjR9OHh$Rir0LD',#20,'StoreyA1Mini',$,$,#36,$,$,.ELEMENT.,0.); +#38=IFCRELAGGREGATES('1vIvRDj5X6Z9pTGzVhJYhH',#20,'Site Container',$,#26,(#37)); +#39=IFCISHAPEPROFILEDEF(.AREA.,'IPE200',$,0.10000000000000001,0.20000000000000001,0.0055999999999999999,0.0085000000000000006,$,$,$); +#40=IFCPROPERTYSINGLEVALUE('sec_type',$,IFCTEXT('I'),$); +#41=IFCPROPERTYSINGLEVALUE('h',$,IFCREAL(0.20000000000000001),$); +#42=IFCPROPERTYSINGLEVALUE('w_top',$,IFCREAL(0.10000000000000001),$); +#43=IFCPROPERTYSINGLEVALUE('w_btn',$,IFCREAL(0.10000000000000001),$); +#44=IFCPROPERTYSINGLEVALUE('t_w',$,IFCREAL(0.0055999999999999999),$); +#45=IFCPROPERTYSINGLEVALUE('t_ftop',$,IFCREAL(0.0085000000000000006),$); +#46=IFCPROPERTYSINGLEVALUE('t_fbtn',$,IFCREAL(0.0085000000000000006),$); +#47=IFCPROPERTYSINGLEVALUE('sec_str',$,IFCTEXT('IPE200'),$); +#48=IFCPROFILEPROPERTIES('ADA_SectionParameters',$,(#40,#41,#42,#43,#44,#45,#46,#47),#39); +#49=IFCBEAMTYPE('2uI4epsC133RYQmTJP23Bh',#20,'IPE200','IPE200',$,$,$,$,$,.BEAM.); +#50=IFCMATERIAL('S355',$,'Steel'); +#51=IFCPROPERTYSINGLEVALUE('Grade',$,IFCTEXT('S355'),$); +#52=IFCPROPERTYSINGLEVALUE('YieldStress',$,IFCPRESSUREMEASURE(355000000.),$); +#53=IFCPROPERTYSINGLEVALUE('YoungModulus',$,IFCMODULUSOFELASTICITYMEASURE(210000000000.),$); +#54=IFCPROPERTYSINGLEVALUE('PoissonRatio',$,IFCPOSITIVERATIOMEASURE(0.29999999999999999),$); +#55=IFCPROPERTYSINGLEVALUE('ThermalExpansionCoefficient',$,IFCTHERMALEXPANSIONCOEFFICIENTMEASURE(1.2E-05),$); +#56=IFCPROPERTYSINGLEVALUE('SpecificHeatCapacity',$,IFCSPECIFICHEATCAPACITYMEASURE(0.029999999999999999),$); +#57=IFCPROPERTYSINGLEVALUE('MassDensity',$,IFCMASSDENSITYMEASURE(7850.),$); +#58=IFCMATERIALPROPERTIES('MaterialMechanical','A Material property description',(#51,#52,#53,#54,#55,#56,#57),#50); +#60=IFCDIRECTION((0.,0.,1.)); +#61=IFCDIRECTION((1.,0.,0.)); +#62=IFCCARTESIANPOINT((0.,0.,0.)); +#63=IFCAXIS2PLACEMENT3D(#62,#60,#61); +#64=IFCLOCALPLACEMENT(#36,#63); +#65=IFCCARTESIANPOINT((0.,0.,0.)); +#66=IFCDIRECTION((0.,1.,0.)); +#67=IFCDIRECTION((-1.,0.,0.)); +#68=IFCAXIS2PLACEMENT3D(#65,#66,#67); +#69=IFCDIRECTION((0.,0.,1.)); +#70=IFCEXTRUDEDAREASOLID(#39,#68,#69,5.); +#71=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#70)); +#72=IFCDIRECTION((0.,0.,1.)); +#73=IFCDIRECTION((1.,0.,0.)); +#74=IFCCARTESIANPOINT((0.,0.,0.)); +#75=IFCAXIS2PLACEMENT3D(#74,#72,#73); +#76=IFCLOCALPLACEMENT(#64,#75); +#77=IFCCARTESIANPOINT((0.,0.,0.)); +#78=IFCCARTESIANPOINT((0.,5.,0.)); +#79=IFCPOLYLINE((#77,#78)); +#80=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#79)); +#81=IFCCOLOURRGB('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',0.80000000000000004,0.80000000000000004,0.80000000000000004); +#82=IFCSURFACESTYLESHADING(#81,0.); +#83=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#82)); +#84=IFCSTYLEDITEM(#70,(#83),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#85=IFCPRODUCTDEFINITIONSHAPE($,$,(#71,#80)); +#86=IFCBEAM('2rRiKsI0OLUSI4LhvIlKH4',#20,'a1-bm0','IPE200','Beam',#76,#85,$,$); +#87=IFCMATERIALPROFILE('IPE200','A material profile',#50,#39,$,'LoadBearing'); +#88=IFCMATERIALPROFILESET('IPE200',$,(#87),$); +#89=IFCMATERIALPROFILESETUSAGE(#88,5,$); +#90=IFCRELASSOCIATESMATERIAL('3XVNtHR5L4txwOy9GlBbkd',#20,$,$,(#86),#89); +#91=IFCRELCONTAINEDINSPATIALSTRUCTURE('1XtzZ_r1T2VvZgjI4Zfndq',#20,'Physical model',$,(#86),#37); +#92=IFCRELDEFINESBYTYPE('3M4L7S5$52lfW5YXvfiSJx',#20,'I',$,(#86),#49); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/core/assets/corpus/plant-a_v2.ifc b/tests/core/assets/corpus/plant-a_v2.ifc new file mode 100644 index 000000000..0457f86cd --- /dev/null +++ b/tests/core/assets/corpus/plant-a_v2.ifc @@ -0,0 +1,1167 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('/dev/null','2026-09-22T21:27:53+02:00',('AdaUser'),(''),'IfcOpenShell 0.8.5','IfcOpenShell 0.8.5','Nobody'); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; +DATA; +#1=IFCPROJECT('0I3JCwX8X0POwPQpPO8yps',$,'plant-a',$,$,$,$,(#11),#6); +#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#5=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#6=IFCUNITASSIGNMENT((#4,#2,#5,#3)); +#7=IFCCARTESIANPOINT((0.,0.,0.)); +#8=IFCDIRECTION((0.,0.,1.)); +#9=IFCDIRECTION((1.,0.,0.)); +#10=IFCAXIS2PLACEMENT3D(#7,#8,#9); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0000000000000001E-05,#10,$); +#12=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#11,$,.MODEL_VIEW.,$); +#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#11,$,.GRAPH_VIEW.,$); +#14=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#11,$,.MODEL_VIEW.,$); +#15=IFCACTORROLE(.ENGINEER.,$,$); +#16=IFCPERSON('AdaUser',$,$,$,$,$,(#15),$); +#17=IFCORGANIZATION('ADA','Assembly For Design and Analysis',$,$,$); +#18=IFCPERSONANDORGANIZATION(#16,#17,$); +#19=IFCAPPLICATION(#17,'XXX','ADA','ADA'); +#20=IFCOWNERHISTORY(#18,#19,.READWRITE.,$,$,#18,#19,1790105273); +#21=IFCDIRECTION((0.,0.,1.)); +#22=IFCDIRECTION((1.,0.,0.)); +#23=IFCCARTESIANPOINT((0.,0.,0.)); +#24=IFCAXIS2PLACEMENT3D(#23,#21,#22); +#25=IFCLOCALPLACEMENT($,#24); +#26=IFCSITE('3WJPj2eAGiVce6lnlPqOcA',#20,'SiteA',$,$,#25,$,$,.ELEMENT.,$,$,$,$,$); +#27=IFCRELAGGREGATES('02MNYXAlX1dfJCK8yr4hfy',#20,'Project Container',$,#1,(#26)); +#28=IFCPROPERTYSINGLEVALUE('project',$,IFCTEXT('plant-a'),$); +#29=IFCPROPERTYSINGLEVALUE('schema',$,IFCTEXT('IFC4X3_add2'),$); +#30=IFCPROPERTYSET('1GcCLjHl5DPewKBqk4K8U0',#20,'Properties',$,(#28,#29)); +#31=IFCRELDEFINESBYPROPERTIES('2a0p$CR$z6CBA_Sxic9xUr',#20,'Properties',$,(#26),#30); +#32=IFCDIRECTION((0.,0.,1.)); +#33=IFCDIRECTION((1.,0.,0.)); +#34=IFCCARTESIANPOINT((0.,0.,0.)); +#35=IFCAXIS2PLACEMENT3D(#34,#32,#33); +#36=IFCLOCALPLACEMENT(#25,#35); +#37=IFCBUILDINGSTOREY('19QMj6Mx9ReIRYpLTAiA9Q',#20,'StoreyA1',$,$,#36,$,$,.ELEMENT.,0.); +#38=IFCRELAGGREGATES('0DQhk5Kx1CYuV$xOniOLn1',#20,'Site Container',$,#26,(#37,#44,#57)); +#39=IFCDIRECTION((0.,0.,1.)); +#40=IFCDIRECTION((1.,0.,0.)); +#41=IFCCARTESIANPOINT((0.,0.,0.)); +#42=IFCAXIS2PLACEMENT3D(#41,#39,#40); +#43=IFCLOCALPLACEMENT(#25,#42); +#44=IFCBUILDINGSTOREY('0CRDvtMxpOLYR108n5OI5p',#20,'StoreyA2',$,$,#43,$,$,.ELEMENT.,0.); +#45=IFCDIRECTION((0.,0.,1.)); +#46=IFCDIRECTION((1.,0.,0.)); +#47=IFCCARTESIANPOINT((0.,0.,0.)); +#48=IFCAXIS2PLACEMENT3D(#47,#45,#46); +#49=IFCLOCALPLACEMENT(#43,#48); +#50=IFCELEMENTASSEMBLY('2X7izBbpgQdGz5JuZgCj2U',#20,'AssemblyAA',$,$,#49,$,$,$,$); +#51=IFCRELAGGREGATES('2rPpDwiwrFoQQly_8aHn$X',#20,'Site Container',$,#44,(#50)); +#52=IFCDIRECTION((0.,0.,1.)); +#53=IFCDIRECTION((1.,0.,0.)); +#54=IFCCARTESIANPOINT((0.,0.,0.)); +#55=IFCAXIS2PLACEMENT3D(#54,#52,#53); +#56=IFCLOCALPLACEMENT(#25,#55); +#57=IFCSITE('1TvWWM_HihayqyFE0Yo_T0',#20,'SiteB',$,$,#56,$,$,.ELEMENT.,$,$,$,$,$); +#58=IFCDIRECTION((0.,0.,1.)); +#59=IFCDIRECTION((1.,0.,0.)); +#60=IFCCARTESIANPOINT((0.,0.,0.)); +#61=IFCAXIS2PLACEMENT3D(#60,#58,#59); +#62=IFCLOCALPLACEMENT(#56,#61); +#63=IFCBUILDINGSTOREY('0JOGaKUe2AkPAEc7XHFNAL',#20,'StoreyB1',$,$,#62,$,$,.ELEMENT.,0.); +#64=IFCRELAGGREGATES('0ZW4buqnb0wxCY1G6N5E_7',#20,'Site Container',$,#57,(#63,#70)); +#65=IFCDIRECTION((0.,0.,1.)); +#66=IFCDIRECTION((1.,0.,0.)); +#67=IFCCARTESIANPOINT((0.,0.,0.)); +#68=IFCAXIS2PLACEMENT3D(#67,#65,#66); +#69=IFCLOCALPLACEMENT(#56,#68); +#70=IFCBUILDINGSTOREY('3F8nChjX3uTPnUdNYJF4hP',#20,'StoreyB2',$,$,#69,$,$,.ELEMENT.,0.); +#71=IFCISHAPEPROFILEDEF(.AREA.,'IPE200',$,0.10000000000000001,0.20000000000000001,0.0055999999999999999,0.0085000000000000006,$,$,$); +#72=IFCPROPERTYSINGLEVALUE('sec_type',$,IFCTEXT('I'),$); +#73=IFCPROPERTYSINGLEVALUE('h',$,IFCREAL(0.20000000000000001),$); +#74=IFCPROPERTYSINGLEVALUE('w_top',$,IFCREAL(0.10000000000000001),$); +#75=IFCPROPERTYSINGLEVALUE('w_btn',$,IFCREAL(0.10000000000000001),$); +#76=IFCPROPERTYSINGLEVALUE('t_w',$,IFCREAL(0.0055999999999999999),$); +#77=IFCPROPERTYSINGLEVALUE('t_ftop',$,IFCREAL(0.0085000000000000006),$); +#78=IFCPROPERTYSINGLEVALUE('t_fbtn',$,IFCREAL(0.0085000000000000006),$); +#79=IFCPROPERTYSINGLEVALUE('sec_str',$,IFCTEXT('IPE200'),$); +#80=IFCPROFILEPROPERTIES('ADA_SectionParameters',$,(#72,#73,#74,#75,#76,#77,#78,#79),#71); +#81=IFCBEAMTYPE('2uI4epsC133RYQmTJP23Bh',#20,'IPE200','IPE200',$,$,$,$,$,.BEAM.); +#82=IFCISHAPEPROFILEDEF(.AREA.,'IPE300',$,0.14999999999999999,0.29999999999999999,0.0071000000000000004,0.010699999999999999,$,$,$); +#83=IFCPROPERTYSINGLEVALUE('sec_type',$,IFCTEXT('I'),$); +#84=IFCPROPERTYSINGLEVALUE('h',$,IFCREAL(0.29999999999999999),$); +#85=IFCPROPERTYSINGLEVALUE('w_top',$,IFCREAL(0.14999999999999999),$); +#86=IFCPROPERTYSINGLEVALUE('w_btn',$,IFCREAL(0.14999999999999999),$); +#87=IFCPROPERTYSINGLEVALUE('t_w',$,IFCREAL(0.0071000000000000004),$); +#88=IFCPROPERTYSINGLEVALUE('t_ftop',$,IFCREAL(0.010699999999999999),$); +#89=IFCPROPERTYSINGLEVALUE('t_fbtn',$,IFCREAL(0.010699999999999999),$); +#90=IFCPROPERTYSINGLEVALUE('sec_str',$,IFCTEXT('IPE300'),$); +#91=IFCPROFILEPROPERTIES('ADA_SectionParameters',$,(#83,#84,#85,#86,#87,#88,#89,#90),#82); +#92=IFCBEAMTYPE('2j6RNXOf99POg_b2or22Po',#20,'IPE300','IPE300',$,$,$,$,$,.BEAM.); +#93=IFCMATERIAL('S355',$,'Steel'); +#94=IFCPROPERTYSINGLEVALUE('Grade',$,IFCTEXT('S355'),$); +#95=IFCPROPERTYSINGLEVALUE('YieldStress',$,IFCPRESSUREMEASURE(355000000.),$); +#96=IFCPROPERTYSINGLEVALUE('YoungModulus',$,IFCMODULUSOFELASTICITYMEASURE(210000000000.),$); +#97=IFCPROPERTYSINGLEVALUE('PoissonRatio',$,IFCPOSITIVERATIOMEASURE(0.29999999999999999),$); +#98=IFCPROPERTYSINGLEVALUE('ThermalExpansionCoefficient',$,IFCTHERMALEXPANSIONCOEFFICIENTMEASURE(1.2E-05),$); +#99=IFCPROPERTYSINGLEVALUE('SpecificHeatCapacity',$,IFCSPECIFICHEATCAPACITYMEASURE(0.029999999999999999),$); +#100=IFCPROPERTYSINGLEVALUE('MassDensity',$,IFCMASSDENSITYMEASURE(7850.),$); +#101=IFCMATERIALPROPERTIES('MaterialMechanical','A Material property description',(#94,#95,#96,#97,#98,#99,#100),#93); +#103=IFCMATERIAL('S420',$,'Steel'); +#104=IFCPROPERTYSINGLEVALUE('Grade',$,IFCTEXT('S420'),$); +#105=IFCPROPERTYSINGLEVALUE('YieldStress',$,IFCPRESSUREMEASURE(420000000.),$); +#106=IFCPROPERTYSINGLEVALUE('YoungModulus',$,IFCMODULUSOFELASTICITYMEASURE(210000000000.),$); +#107=IFCPROPERTYSINGLEVALUE('PoissonRatio',$,IFCPOSITIVERATIOMEASURE(0.29999999999999999),$); +#108=IFCPROPERTYSINGLEVALUE('ThermalExpansionCoefficient',$,IFCTHERMALEXPANSIONCOEFFICIENTMEASURE(1.2E-05),$); +#109=IFCPROPERTYSINGLEVALUE('SpecificHeatCapacity',$,IFCSPECIFICHEATCAPACITYMEASURE(0.029999999999999999),$); +#110=IFCPROPERTYSINGLEVALUE('MassDensity',$,IFCMASSDENSITYMEASURE(7850.),$); +#111=IFCMATERIALPROPERTIES('MaterialMechanical','A Material property description',(#104,#105,#106,#107,#108,#109,#110),#103); +#112=IFCRELASSOCIATESMATERIAL('06gRczJ$T27uAFWoMqlSNI',#20,'S420','Objects related to S420',(#129,#150,#170,#190,#390,#410,#430,#450,#650,#670,#690,#710,#910,#930,#950,#970),#103); +#113=IFCCARTESIANPOINT((0.,0.,0.)); +#114=IFCDIRECTION((0.,0.,1.)); +#115=IFCDIRECTION((1.,0.,0.)); +#116=IFCAXIS2PLACEMENT3D(#113,#114,#115); +#117=IFCLOCALPLACEMENT($,#116); +#118=IFCCARTESIANPOINT((0.,0.,0.)); +#119=IFCDIRECTION((0.,0.,1.)); +#120=IFCDIRECTION((1.,0.,0.)); +#121=IFCAXIS2PLACEMENT3D(#118,#119,#120); +#122=IFCCARTESIANPOINTLIST2D(((0.,0.),(0.,1.5),(1.5,0.),(1.5,1.5)),$); +#123=IFCINDEXEDPOLYCURVE(#122,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#124=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#123); +#125=IFCDIRECTION((0.,0.,1.)); +#126=IFCEXTRUDEDAREASOLID(#124,#121,#125,0.01); +#127=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#126)); +#128=IFCPRODUCTDEFINITIONSHAPE($,$,(#127)); +#129=IFCPLATE('1Zv2vqhj4psd63TVD0VzUa',#20,'a1-pl0','a1-pl0',$,#117,#128,$,$); +#130=IFCCOLOURRGB('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',0.80000000000000004,0.80000000000000004,0.80000000000000004); +#131=IFCSURFACESTYLESHADING(#130,0.); +#132=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#131)); +#133=IFCSTYLEDITEM(#126,(#132),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#134=IFCCARTESIANPOINT((0.,0.,0.)); +#135=IFCDIRECTION((0.,0.,1.)); +#136=IFCDIRECTION((1.,0.,0.)); +#137=IFCAXIS2PLACEMENT3D(#134,#135,#136); +#138=IFCLOCALPLACEMENT($,#137); +#139=IFCCARTESIANPOINT((0.,0.,0.)); +#140=IFCDIRECTION((0.,0.,1.)); +#141=IFCDIRECTION((1.,0.,0.)); +#142=IFCAXIS2PLACEMENT3D(#139,#140,#141); +#143=IFCCARTESIANPOINTLIST2D(((2.,0.),(2.,1.5),(3.5,0.),(3.5,1.5)),$); +#144=IFCINDEXEDPOLYCURVE(#143,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#145=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#144); +#146=IFCDIRECTION((0.,0.,1.)); +#147=IFCEXTRUDEDAREASOLID(#145,#142,#146,0.01); +#148=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#147)); +#149=IFCPRODUCTDEFINITIONSHAPE($,$,(#148)); +#150=IFCPLATE('1WLiqxLpee_2zSTuD0kehJ',#20,'a1-pl1','a1-pl1',$,#138,#149,$,$); +#151=IFCSURFACESTYLESHADING(#130,0.); +#152=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#151)); +#153=IFCSTYLEDITEM(#147,(#152),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#154=IFCCARTESIANPOINT((0.,0.,0.)); +#155=IFCDIRECTION((0.,0.,1.)); +#156=IFCDIRECTION((1.,0.,0.)); +#157=IFCAXIS2PLACEMENT3D(#154,#155,#156); +#158=IFCLOCALPLACEMENT($,#157); +#159=IFCCARTESIANPOINT((0.,0.,0.)); +#160=IFCDIRECTION((0.,0.,1.)); +#161=IFCDIRECTION((1.,0.,0.)); +#162=IFCAXIS2PLACEMENT3D(#159,#160,#161); +#163=IFCCARTESIANPOINTLIST2D(((4.,0.),(4.,1.5),(5.5,0.),(5.5,1.5)),$); +#164=IFCINDEXEDPOLYCURVE(#163,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#165=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#164); +#166=IFCDIRECTION((0.,0.,1.)); +#167=IFCEXTRUDEDAREASOLID(#165,#162,#166,0.01); +#168=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#167)); +#169=IFCPRODUCTDEFINITIONSHAPE($,$,(#168)); +#170=IFCPLATE('3bDaNag$ex4EPxKhsBbinm',#20,'a1-pl2','a1-pl2',$,#158,#169,$,$); +#171=IFCSURFACESTYLESHADING(#130,0.); +#172=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#171)); +#173=IFCSTYLEDITEM(#167,(#172),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#174=IFCCARTESIANPOINT((0.,0.,0.)); +#175=IFCDIRECTION((0.,0.,1.)); +#176=IFCDIRECTION((1.,0.,0.)); +#177=IFCAXIS2PLACEMENT3D(#174,#175,#176); +#178=IFCLOCALPLACEMENT($,#177); +#179=IFCCARTESIANPOINT((0.,0.,0.)); +#180=IFCDIRECTION((0.,0.,1.)); +#181=IFCDIRECTION((1.,0.,0.)); +#182=IFCAXIS2PLACEMENT3D(#179,#180,#181); +#183=IFCCARTESIANPOINTLIST2D(((6.,0.),(6.,1.5),(7.5,0.),(7.5,1.5)),$); +#184=IFCINDEXEDPOLYCURVE(#183,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#185=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#184); +#186=IFCDIRECTION((0.,0.,1.)); +#187=IFCEXTRUDEDAREASOLID(#185,#182,#186,0.01); +#188=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#187)); +#189=IFCPRODUCTDEFINITIONSHAPE($,$,(#188)); +#190=IFCPLATE('2iXbdRFJMFA$jMc8wfdjmw',#20,'a1-pl3','a1-pl3',$,#178,#189,$,$); +#191=IFCSURFACESTYLESHADING(#130,0.); +#192=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#191)); +#193=IFCSTYLEDITEM(#187,(#192),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#194=IFCDIRECTION((0.,0.,1.)); +#195=IFCDIRECTION((1.,0.,0.)); +#196=IFCCARTESIANPOINT((0.,0.,0.)); +#197=IFCAXIS2PLACEMENT3D(#196,#194,#195); +#198=IFCLOCALPLACEMENT(#36,#197); +#199=IFCCARTESIANPOINT((0.,0.,0.)); +#200=IFCDIRECTION((0.,1.,0.)); +#201=IFCDIRECTION((-1.,0.,0.)); +#202=IFCAXIS2PLACEMENT3D(#199,#200,#201); +#203=IFCDIRECTION((0.,0.,1.)); +#204=IFCEXTRUDEDAREASOLID(#71,#202,#203,5.); +#205=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#204)); +#206=IFCDIRECTION((0.,0.,1.)); +#207=IFCDIRECTION((1.,0.,0.)); +#208=IFCCARTESIANPOINT((0.,0.,0.)); +#209=IFCAXIS2PLACEMENT3D(#208,#206,#207); +#210=IFCLOCALPLACEMENT(#198,#209); +#211=IFCCARTESIANPOINT((0.,0.,0.)); +#212=IFCCARTESIANPOINT((0.,5.,0.)); +#213=IFCPOLYLINE((#211,#212)); +#214=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#213)); +#215=IFCSURFACESTYLESHADING(#130,0.); +#216=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#215)); +#217=IFCSTYLEDITEM(#204,(#216),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#218=IFCPRODUCTDEFINITIONSHAPE($,$,(#205,#214)); +#219=IFCBEAM('2rRiKsI0OLUSI4LhvIlKH4',#20,'a1-bm0','IPE200','Beam',#210,#218,$,$); +#220=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#221=IFCMATERIALPROFILESET('IPE200',$,(#220),$); +#222=IFCMATERIALPROFILESETUSAGE(#221,5,$); +#223=IFCRELASSOCIATESMATERIAL('2cWnZ2nbr52QjBP8PjrdJ5',#20,$,$,(#219),#222); +#224=IFCDIRECTION((0.,0.,1.)); +#225=IFCDIRECTION((1.,0.,0.)); +#226=IFCCARTESIANPOINT((0.,0.,0.)); +#227=IFCAXIS2PLACEMENT3D(#226,#224,#225); +#228=IFCLOCALPLACEMENT(#36,#227); +#229=IFCCARTESIANPOINT((2.,0.,0.)); +#230=IFCDIRECTION((0.,1.,0.)); +#231=IFCDIRECTION((-1.,0.,0.)); +#232=IFCAXIS2PLACEMENT3D(#229,#230,#231); +#233=IFCDIRECTION((0.,0.,1.)); +#234=IFCEXTRUDEDAREASOLID(#71,#232,#233,5.); +#235=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#234)); +#236=IFCDIRECTION((0.,0.,1.)); +#237=IFCDIRECTION((1.,0.,0.)); +#238=IFCCARTESIANPOINT((0.,0.,0.)); +#239=IFCAXIS2PLACEMENT3D(#238,#236,#237); +#240=IFCLOCALPLACEMENT(#228,#239); +#241=IFCCARTESIANPOINT((2.,0.,0.)); +#242=IFCCARTESIANPOINT((2.,5.,0.)); +#243=IFCPOLYLINE((#241,#242)); +#244=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#243)); +#245=IFCSURFACESTYLESHADING(#130,0.); +#246=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#245)); +#247=IFCSTYLEDITEM(#234,(#246),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#248=IFCPRODUCTDEFINITIONSHAPE($,$,(#235,#244)); +#249=IFCBEAM('0eBs4NDxpv9K_X3DQwK1tW',#20,'a1-bm1','IPE200','Beam',#240,#248,$,$); +#250=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#251=IFCMATERIALPROFILESET('IPE200',$,(#250),$); +#252=IFCMATERIALPROFILESETUSAGE(#251,5,$); +#253=IFCRELASSOCIATESMATERIAL('3KGqzmuAz3nfvaKfXVes$l',#20,$,$,(#249),#252); +#254=IFCDIRECTION((0.,0.,1.)); +#255=IFCDIRECTION((1.,0.,0.)); +#256=IFCCARTESIANPOINT((0.,0.,0.)); +#257=IFCAXIS2PLACEMENT3D(#256,#254,#255); +#258=IFCLOCALPLACEMENT(#36,#257); +#259=IFCCARTESIANPOINT((4.,0.,0.)); +#260=IFCDIRECTION((0.,1.,0.)); +#261=IFCDIRECTION((-1.,0.,0.)); +#262=IFCAXIS2PLACEMENT3D(#259,#260,#261); +#263=IFCDIRECTION((0.,0.,1.)); +#264=IFCEXTRUDEDAREASOLID(#71,#262,#263,5.); +#265=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#264)); +#266=IFCDIRECTION((0.,0.,1.)); +#267=IFCDIRECTION((1.,0.,0.)); +#268=IFCCARTESIANPOINT((0.,0.,0.)); +#269=IFCAXIS2PLACEMENT3D(#268,#266,#267); +#270=IFCLOCALPLACEMENT(#258,#269); +#271=IFCCARTESIANPOINT((4.,0.,0.)); +#272=IFCCARTESIANPOINT((4.,5.,0.)); +#273=IFCPOLYLINE((#271,#272)); +#274=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#273)); +#275=IFCSURFACESTYLESHADING(#130,0.); +#276=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#275)); +#277=IFCSTYLEDITEM(#264,(#276),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#278=IFCPRODUCTDEFINITIONSHAPE($,$,(#265,#274)); +#279=IFCBEAM('3S9QJipR1z$Kmigdsne4nr',#20,'a1-bm2','IPE200','Beam',#270,#278,$,$); +#280=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#281=IFCMATERIALPROFILESET('IPE200',$,(#280),$); +#282=IFCMATERIALPROFILESETUSAGE(#281,5,$); +#283=IFCRELASSOCIATESMATERIAL('0Fi49ASo98MQz5HXDO1jWr',#20,$,$,(#279),#282); +#284=IFCDIRECTION((0.,0.,1.)); +#285=IFCDIRECTION((1.,0.,0.)); +#286=IFCCARTESIANPOINT((0.,0.,0.)); +#287=IFCAXIS2PLACEMENT3D(#286,#284,#285); +#288=IFCLOCALPLACEMENT(#36,#287); +#289=IFCCARTESIANPOINT((6.,0.,0.)); +#290=IFCDIRECTION((0.,1.,0.)); +#291=IFCDIRECTION((-1.,0.,0.)); +#292=IFCAXIS2PLACEMENT3D(#289,#290,#291); +#293=IFCDIRECTION((0.,0.,1.)); +#294=IFCEXTRUDEDAREASOLID(#71,#292,#293,5.); +#295=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#294)); +#296=IFCDIRECTION((0.,0.,1.)); +#297=IFCDIRECTION((1.,0.,0.)); +#298=IFCCARTESIANPOINT((0.,0.,0.)); +#299=IFCAXIS2PLACEMENT3D(#298,#296,#297); +#300=IFCLOCALPLACEMENT(#288,#299); +#301=IFCCARTESIANPOINT((6.,0.,0.)); +#302=IFCCARTESIANPOINT((6.,5.,0.)); +#303=IFCPOLYLINE((#301,#302)); +#304=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#303)); +#305=IFCSURFACESTYLESHADING(#130,0.); +#306=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#305)); +#307=IFCSTYLEDITEM(#294,(#306),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#308=IFCPRODUCTDEFINITIONSHAPE($,$,(#295,#304)); +#309=IFCBEAM('1ARd9xz8L3jMNzGcOz37OW',#20,'a1-bm3','IPE200','Beam',#300,#308,$,$); +#310=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#311=IFCMATERIALPROFILESET('IPE200',$,(#310),$); +#312=IFCMATERIALPROFILESETUSAGE(#311,5,$); +#313=IFCRELASSOCIATESMATERIAL('3hSZiKPXjDJPi$$AxkWgvA',#20,$,$,(#309),#312); +#314=IFCDIRECTION((0.,0.,1.)); +#315=IFCDIRECTION((1.,0.,0.)); +#316=IFCCARTESIANPOINT((0.,0.,0.)); +#317=IFCAXIS2PLACEMENT3D(#316,#314,#315); +#318=IFCLOCALPLACEMENT(#36,#317); +#319=IFCCARTESIANPOINT((8.,0.,0.)); +#320=IFCDIRECTION((0.,1.,0.)); +#321=IFCDIRECTION((-1.,0.,0.)); +#322=IFCAXIS2PLACEMENT3D(#319,#320,#321); +#323=IFCDIRECTION((0.,0.,1.)); +#324=IFCEXTRUDEDAREASOLID(#71,#322,#323,5.); +#325=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#324)); +#326=IFCDIRECTION((0.,0.,1.)); +#327=IFCDIRECTION((1.,0.,0.)); +#328=IFCCARTESIANPOINT((0.,0.,0.)); +#329=IFCAXIS2PLACEMENT3D(#328,#326,#327); +#330=IFCLOCALPLACEMENT(#318,#329); +#331=IFCCARTESIANPOINT((8.,0.,0.)); +#332=IFCCARTESIANPOINT((8.,5.,0.)); +#333=IFCPOLYLINE((#331,#332)); +#334=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#333)); +#335=IFCSURFACESTYLESHADING(#130,0.); +#336=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#335)); +#337=IFCSTYLEDITEM(#324,(#336),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#338=IFCPRODUCTDEFINITIONSHAPE($,$,(#325,#334)); +#339=IFCBEAM('3CSgHwjekZBnKfgyU6QgrY',#20,'a1-bm4','IPE200','Beam',#330,#338,$,$); +#340=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#341=IFCMATERIALPROFILESET('IPE200',$,(#340),$); +#342=IFCMATERIALPROFILESETUSAGE(#341,5,$); +#343=IFCRELASSOCIATESMATERIAL('3WYZDdue1C4Qll7CI7v4hW',#20,$,$,(#339),#342); +#344=IFCDIRECTION((0.,0.,1.)); +#345=IFCDIRECTION((1.,0.,0.)); +#346=IFCCARTESIANPOINT((0.,0.,0.)); +#347=IFCAXIS2PLACEMENT3D(#346,#344,#345); +#348=IFCLOCALPLACEMENT(#36,#347); +#349=IFCCARTESIANPOINT((10.,0.,0.)); +#350=IFCDIRECTION((0.,1.,0.)); +#351=IFCDIRECTION((-1.,0.,0.)); +#352=IFCAXIS2PLACEMENT3D(#349,#350,#351); +#353=IFCDIRECTION((0.,0.,1.)); +#354=IFCEXTRUDEDAREASOLID(#71,#352,#353,5.); +#355=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#354)); +#356=IFCDIRECTION((0.,0.,1.)); +#357=IFCDIRECTION((1.,0.,0.)); +#358=IFCCARTESIANPOINT((0.,0.,0.)); +#359=IFCAXIS2PLACEMENT3D(#358,#356,#357); +#360=IFCLOCALPLACEMENT(#348,#359); +#361=IFCCARTESIANPOINT((10.,0.,0.)); +#362=IFCCARTESIANPOINT((10.,5.,0.)); +#363=IFCPOLYLINE((#361,#362)); +#364=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#363)); +#365=IFCSURFACESTYLESHADING(#130,0.); +#366=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#365)); +#367=IFCSTYLEDITEM(#354,(#366),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#368=IFCPRODUCTDEFINITIONSHAPE($,$,(#355,#364)); +#369=IFCBEAM('2YvpXjKYZINP7xs0dCAn38',#20,'a1-bm5','IPE200','Beam',#360,#368,$,$); +#370=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#371=IFCMATERIALPROFILESET('IPE200',$,(#370),$); +#372=IFCMATERIALPROFILESETUSAGE(#371,5,$); +#373=IFCRELASSOCIATESMATERIAL('0EryeEEyrDDfWQE_bg6I82',#20,$,$,(#369),#372); +#374=IFCCARTESIANPOINT((0.,0.,0.)); +#375=IFCDIRECTION((0.,0.,1.)); +#376=IFCDIRECTION((1.,0.,0.)); +#377=IFCAXIS2PLACEMENT3D(#374,#375,#376); +#378=IFCLOCALPLACEMENT($,#377); +#379=IFCCARTESIANPOINT((0.,0.,3.)); +#380=IFCDIRECTION((0.,0.,1.)); +#381=IFCDIRECTION((1.,0.,0.)); +#382=IFCAXIS2PLACEMENT3D(#379,#380,#381); +#383=IFCCARTESIANPOINTLIST2D(((0.,0.),(0.,1.5),(1.5,0.),(1.5,1.5)),$); +#384=IFCINDEXEDPOLYCURVE(#383,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#385=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#384); +#386=IFCDIRECTION((0.,0.,1.)); +#387=IFCEXTRUDEDAREASOLID(#385,#382,#386,0.01); +#388=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#387)); +#389=IFCPRODUCTDEFINITIONSHAPE($,$,(#388)); +#390=IFCPLATE('0i_8x83V_3NJZOIBJ$434A',#20,'aa-pl0','aa-pl0',$,#378,#389,$,$); +#391=IFCSURFACESTYLESHADING(#130,0.); +#392=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#391)); +#393=IFCSTYLEDITEM(#387,(#392),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#394=IFCCARTESIANPOINT((0.,0.,0.)); +#395=IFCDIRECTION((0.,0.,1.)); +#396=IFCDIRECTION((1.,0.,0.)); +#397=IFCAXIS2PLACEMENT3D(#394,#395,#396); +#398=IFCLOCALPLACEMENT($,#397); +#399=IFCCARTESIANPOINT((0.,0.,3.)); +#400=IFCDIRECTION((0.,0.,1.)); +#401=IFCDIRECTION((1.,0.,0.)); +#402=IFCAXIS2PLACEMENT3D(#399,#400,#401); +#403=IFCCARTESIANPOINTLIST2D(((2.,0.),(2.,1.5),(3.5,0.),(3.5,1.5)),$); +#404=IFCINDEXEDPOLYCURVE(#403,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#405=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#404); +#406=IFCDIRECTION((0.,0.,1.)); +#407=IFCEXTRUDEDAREASOLID(#405,#402,#406,0.01); +#408=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#407)); +#409=IFCPRODUCTDEFINITIONSHAPE($,$,(#408)); +#410=IFCPLATE('3rDwjY2dJE3chaUACD89cQ',#20,'aa-pl1','aa-pl1',$,#398,#409,$,$); +#411=IFCSURFACESTYLESHADING(#130,0.); +#412=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#411)); +#413=IFCSTYLEDITEM(#407,(#412),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#414=IFCCARTESIANPOINT((0.,0.,0.)); +#415=IFCDIRECTION((0.,0.,1.)); +#416=IFCDIRECTION((1.,0.,0.)); +#417=IFCAXIS2PLACEMENT3D(#414,#415,#416); +#418=IFCLOCALPLACEMENT($,#417); +#419=IFCCARTESIANPOINT((0.,0.,3.)); +#420=IFCDIRECTION((0.,0.,1.)); +#421=IFCDIRECTION((1.,0.,0.)); +#422=IFCAXIS2PLACEMENT3D(#419,#420,#421); +#423=IFCCARTESIANPOINTLIST2D(((4.,0.),(4.,1.5),(5.5,0.),(5.5,1.5)),$); +#424=IFCINDEXEDPOLYCURVE(#423,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#425=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#424); +#426=IFCDIRECTION((0.,0.,1.)); +#427=IFCEXTRUDEDAREASOLID(#425,#422,#426,0.01); +#428=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#427)); +#429=IFCPRODUCTDEFINITIONSHAPE($,$,(#428)); +#430=IFCPLATE('378UvpAy20x$ktcmtMncxD',#20,'aa-pl2','aa-pl2',$,#418,#429,$,$); +#431=IFCSURFACESTYLESHADING(#130,0.); +#432=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#431)); +#433=IFCSTYLEDITEM(#427,(#432),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#434=IFCCARTESIANPOINT((0.,0.,0.)); +#435=IFCDIRECTION((0.,0.,1.)); +#436=IFCDIRECTION((1.,0.,0.)); +#437=IFCAXIS2PLACEMENT3D(#434,#435,#436); +#438=IFCLOCALPLACEMENT($,#437); +#439=IFCCARTESIANPOINT((0.,0.,3.)); +#440=IFCDIRECTION((0.,0.,1.)); +#441=IFCDIRECTION((1.,0.,0.)); +#442=IFCAXIS2PLACEMENT3D(#439,#440,#441); +#443=IFCCARTESIANPOINTLIST2D(((6.,0.),(6.,1.5),(7.5,0.),(7.5,1.5)),$); +#444=IFCINDEXEDPOLYCURVE(#443,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#445=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#444); +#446=IFCDIRECTION((0.,0.,1.)); +#447=IFCEXTRUDEDAREASOLID(#445,#442,#446,0.01); +#448=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#447)); +#449=IFCPRODUCTDEFINITIONSHAPE($,$,(#448)); +#450=IFCPLATE('1wyjHnelNOU4NZ1z$I5Lza',#20,'aa-pl3','aa-pl3',$,#438,#449,$,$); +#451=IFCSURFACESTYLESHADING(#130,0.); +#452=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#451)); +#453=IFCSTYLEDITEM(#447,(#452),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#454=IFCDIRECTION((0.,0.,1.)); +#455=IFCDIRECTION((1.,0.,0.)); +#456=IFCCARTESIANPOINT((0.,0.,0.)); +#457=IFCAXIS2PLACEMENT3D(#456,#454,#455); +#458=IFCLOCALPLACEMENT(#49,#457); +#459=IFCCARTESIANPOINT((0.5,0.,3.)); +#460=IFCDIRECTION((0.,1.,0.)); +#461=IFCDIRECTION((-1.,0.,0.)); +#462=IFCAXIS2PLACEMENT3D(#459,#460,#461); +#463=IFCDIRECTION((0.,0.,1.)); +#464=IFCEXTRUDEDAREASOLID(#82,#462,#463,5.5); +#465=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#464)); +#466=IFCDIRECTION((0.,0.,1.)); +#467=IFCDIRECTION((1.,0.,0.)); +#468=IFCCARTESIANPOINT((0.,0.,0.)); +#469=IFCAXIS2PLACEMENT3D(#468,#466,#467); +#470=IFCLOCALPLACEMENT(#458,#469); +#471=IFCCARTESIANPOINT((0.5,0.,3.)); +#472=IFCCARTESIANPOINT((0.5,5.5,3.)); +#473=IFCPOLYLINE((#471,#472)); +#474=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#473)); +#475=IFCSURFACESTYLESHADING(#130,0.); +#476=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#475)); +#477=IFCSTYLEDITEM(#464,(#476),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#478=IFCPRODUCTDEFINITIONSHAPE($,$,(#465,#474)); +#479=IFCBEAM('1exFulb6cKYczlQNrV0z$b',#20,'aa-bm0','IPE300','Beam',#470,#478,$,$); +#480=IFCMATERIALPROFILE('IPE300','A material profile',#93,#82,$,'LoadBearing'); +#481=IFCMATERIALPROFILESET('IPE300',$,(#480),$); +#482=IFCMATERIALPROFILESETUSAGE(#481,5,$); +#483=IFCRELASSOCIATESMATERIAL('1bhiRSVfP9dOWPfyP1Noz1',#20,$,$,(#479),#482); +#484=IFCDIRECTION((0.,0.,1.)); +#485=IFCDIRECTION((1.,0.,0.)); +#486=IFCCARTESIANPOINT((0.,0.,0.)); +#487=IFCAXIS2PLACEMENT3D(#486,#484,#485); +#488=IFCLOCALPLACEMENT(#49,#487); +#489=IFCCARTESIANPOINT((4.,0.,3.)); +#490=IFCDIRECTION((0.,1.,0.)); +#491=IFCDIRECTION((-1.,0.,0.)); +#492=IFCAXIS2PLACEMENT3D(#489,#490,#491); +#493=IFCDIRECTION((0.,0.,1.)); +#494=IFCEXTRUDEDAREASOLID(#71,#492,#493,5.); +#495=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#494)); +#496=IFCDIRECTION((0.,0.,1.)); +#497=IFCDIRECTION((1.,0.,0.)); +#498=IFCCARTESIANPOINT((0.,0.,0.)); +#499=IFCAXIS2PLACEMENT3D(#498,#496,#497); +#500=IFCLOCALPLACEMENT(#488,#499); +#501=IFCCARTESIANPOINT((4.,0.,3.)); +#502=IFCCARTESIANPOINT((4.,5.,3.)); +#503=IFCPOLYLINE((#501,#502)); +#504=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#503)); +#505=IFCSURFACESTYLESHADING(#130,0.); +#506=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#505)); +#507=IFCSTYLEDITEM(#494,(#506),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#508=IFCPRODUCTDEFINITIONSHAPE($,$,(#495,#504)); +#509=IFCBEAM('0jgbWX0Ce4434At$BZhw_e',#20,'aa-bm2','IPE200','Beam',#500,#508,$,$); +#510=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#511=IFCMATERIALPROFILESET('IPE200',$,(#510),$); +#512=IFCMATERIALPROFILESETUSAGE(#511,5,$); +#513=IFCRELASSOCIATESMATERIAL('3tBDTKFlzCkuCJNri97NF4',#20,$,$,(#509),#512); +#514=IFCDIRECTION((0.,0.,1.)); +#515=IFCDIRECTION((1.,0.,0.)); +#516=IFCCARTESIANPOINT((0.,0.,0.)); +#517=IFCAXIS2PLACEMENT3D(#516,#514,#515); +#518=IFCLOCALPLACEMENT(#49,#517); +#519=IFCCARTESIANPOINT((6.,0.,3.)); +#520=IFCDIRECTION((0.,1.,0.)); +#521=IFCDIRECTION((-1.,0.,0.)); +#522=IFCAXIS2PLACEMENT3D(#519,#520,#521); +#523=IFCDIRECTION((0.,0.,1.)); +#524=IFCEXTRUDEDAREASOLID(#71,#522,#523,5.); +#525=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#524)); +#526=IFCDIRECTION((0.,0.,1.)); +#527=IFCDIRECTION((1.,0.,0.)); +#528=IFCCARTESIANPOINT((0.,0.,0.)); +#529=IFCAXIS2PLACEMENT3D(#528,#526,#527); +#530=IFCLOCALPLACEMENT(#518,#529); +#531=IFCCARTESIANPOINT((6.,0.,3.)); +#532=IFCCARTESIANPOINT((6.,5.,3.)); +#533=IFCPOLYLINE((#531,#532)); +#534=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#533)); +#535=IFCSURFACESTYLESHADING(#130,0.); +#536=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#535)); +#537=IFCSTYLEDITEM(#524,(#536),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#538=IFCPRODUCTDEFINITIONSHAPE($,$,(#525,#534)); +#539=IFCBEAM('1V7gx6m2ilEj0dwOZcQkrC',#20,'aa-bm3','IPE200','Beam',#530,#538,$,$); +#540=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#541=IFCMATERIALPROFILESET('IPE200',$,(#540),$); +#542=IFCMATERIALPROFILESETUSAGE(#541,5,$); +#543=IFCRELASSOCIATESMATERIAL('0iPFGjU8H8sxhULfno7OGy',#20,$,$,(#539),#542); +#544=IFCDIRECTION((0.,0.,1.)); +#545=IFCDIRECTION((1.,0.,0.)); +#546=IFCCARTESIANPOINT((0.,0.,0.)); +#547=IFCAXIS2PLACEMENT3D(#546,#544,#545); +#548=IFCLOCALPLACEMENT(#49,#547); +#549=IFCCARTESIANPOINT((8.,0.,3.)); +#550=IFCDIRECTION((0.,1.,0.)); +#551=IFCDIRECTION((-1.,0.,0.)); +#552=IFCAXIS2PLACEMENT3D(#549,#550,#551); +#553=IFCDIRECTION((0.,0.,1.)); +#554=IFCEXTRUDEDAREASOLID(#71,#552,#553,5.); +#555=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#554)); +#556=IFCDIRECTION((0.,0.,1.)); +#557=IFCDIRECTION((1.,0.,0.)); +#558=IFCCARTESIANPOINT((0.,0.,0.)); +#559=IFCAXIS2PLACEMENT3D(#558,#556,#557); +#560=IFCLOCALPLACEMENT(#548,#559); +#561=IFCCARTESIANPOINT((8.,0.,3.)); +#562=IFCCARTESIANPOINT((8.,5.,3.)); +#563=IFCPOLYLINE((#561,#562)); +#564=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#563)); +#565=IFCSURFACESTYLESHADING(#130,0.); +#566=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#565)); +#567=IFCSTYLEDITEM(#554,(#566),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#568=IFCPRODUCTDEFINITIONSHAPE($,$,(#555,#564)); +#569=IFCBEAM('1g6G0p5EVpbzr9lqbNhRaR',#20,'aa-bm4','IPE200','Beam',#560,#568,$,$); +#570=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#571=IFCMATERIALPROFILESET('IPE200',$,(#570),$); +#572=IFCMATERIALPROFILESETUSAGE(#571,5,$); +#573=IFCRELASSOCIATESMATERIAL('3jIR23_Bf7JvliOneR$C9S',#20,$,$,(#569),#572); +#574=IFCDIRECTION((0.,0.,1.)); +#575=IFCDIRECTION((1.,0.,0.)); +#576=IFCCARTESIANPOINT((0.,0.,0.)); +#577=IFCAXIS2PLACEMENT3D(#576,#574,#575); +#578=IFCLOCALPLACEMENT(#49,#577); +#579=IFCCARTESIANPOINT((10.,0.,3.)); +#580=IFCDIRECTION((0.,1.,0.)); +#581=IFCDIRECTION((-1.,0.,0.)); +#582=IFCAXIS2PLACEMENT3D(#579,#580,#581); +#583=IFCDIRECTION((0.,0.,1.)); +#584=IFCEXTRUDEDAREASOLID(#71,#582,#583,5.); +#585=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#584)); +#586=IFCDIRECTION((0.,0.,1.)); +#587=IFCDIRECTION((1.,0.,0.)); +#588=IFCCARTESIANPOINT((0.,0.,0.)); +#589=IFCAXIS2PLACEMENT3D(#588,#586,#587); +#590=IFCLOCALPLACEMENT(#578,#589); +#591=IFCCARTESIANPOINT((10.,0.,3.)); +#592=IFCCARTESIANPOINT((10.,5.,3.)); +#593=IFCPOLYLINE((#591,#592)); +#594=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#593)); +#595=IFCSURFACESTYLESHADING(#130,0.); +#596=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#595)); +#597=IFCSTYLEDITEM(#584,(#596),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#598=IFCPRODUCTDEFINITIONSHAPE($,$,(#585,#594)); +#599=IFCBEAM('3Q8zbdP_Z5nB$7EpwvQuLk',#20,'aa-bm5','IPE200','Beam',#590,#598,$,$); +#600=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#601=IFCMATERIALPROFILESET('IPE200',$,(#600),$); +#602=IFCMATERIALPROFILESETUSAGE(#601,5,$); +#603=IFCRELASSOCIATESMATERIAL('1y6nNKOPL25RoE9ALG0itW',#20,$,$,(#599),#602); +#604=IFCDIRECTION((0.,0.,1.)); +#605=IFCDIRECTION((1.,0.,0.)); +#606=IFCCARTESIANPOINT((0.,0.,0.)); +#607=IFCAXIS2PLACEMENT3D(#606,#604,#605); +#608=IFCLOCALPLACEMENT(#49,#607); +#609=IFCCARTESIANPOINT((12.,0.,3.)); +#610=IFCDIRECTION((0.,1.,0.)); +#611=IFCDIRECTION((-1.,0.,0.)); +#612=IFCAXIS2PLACEMENT3D(#609,#610,#611); +#613=IFCDIRECTION((0.,0.,1.)); +#614=IFCEXTRUDEDAREASOLID(#71,#612,#613,5.); +#615=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#614)); +#616=IFCDIRECTION((0.,0.,1.)); +#617=IFCDIRECTION((1.,0.,0.)); +#618=IFCCARTESIANPOINT((0.,0.,0.)); +#619=IFCAXIS2PLACEMENT3D(#618,#616,#617); +#620=IFCLOCALPLACEMENT(#608,#619); +#621=IFCCARTESIANPOINT((12.,0.,3.)); +#622=IFCCARTESIANPOINT((12.,5.,3.)); +#623=IFCPOLYLINE((#621,#622)); +#624=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#623)); +#625=IFCSURFACESTYLESHADING(#130,0.); +#626=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#625)); +#627=IFCSTYLEDITEM(#614,(#626),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#628=IFCPRODUCTDEFINITIONSHAPE($,$,(#615,#624)); +#629=IFCBEAM('1ysA6Ao8xU8rVgFwYsnSQR',#20,'aa-bm6','IPE200','Beam',#620,#628,$,$); +#630=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#631=IFCMATERIALPROFILESET('IPE200',$,(#630),$); +#632=IFCMATERIALPROFILESETUSAGE(#631,5,$); +#633=IFCRELASSOCIATESMATERIAL('2dg20eieX47PSHnS4NpY05',#20,$,$,(#629),#632); +#634=IFCCARTESIANPOINT((0.,0.,0.)); +#635=IFCDIRECTION((0.,0.,1.)); +#636=IFCDIRECTION((1.,0.,0.)); +#637=IFCAXIS2PLACEMENT3D(#634,#635,#636); +#638=IFCLOCALPLACEMENT($,#637); +#639=IFCCARTESIANPOINT((0.,0.,0.)); +#640=IFCDIRECTION((0.,0.,1.)); +#641=IFCDIRECTION((1.,0.,0.)); +#642=IFCAXIS2PLACEMENT3D(#639,#640,#641); +#643=IFCCARTESIANPOINTLIST2D(((20.,0.),(20.,1.5),(21.5,0.),(21.5,1.5)),$); +#644=IFCINDEXEDPOLYCURVE(#643,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#645=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#644); +#646=IFCDIRECTION((0.,0.,1.)); +#647=IFCEXTRUDEDAREASOLID(#645,#642,#646,0.01); +#648=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#647)); +#649=IFCPRODUCTDEFINITIONSHAPE($,$,(#648)); +#650=IFCPLATE('1g5FnC2CpEF03O$iq0iBNU',#20,'b1-pl0','b1-pl0',$,#638,#649,$,$); +#651=IFCSURFACESTYLESHADING(#130,0.); +#652=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#651)); +#653=IFCSTYLEDITEM(#647,(#652),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#654=IFCCARTESIANPOINT((0.,0.,0.)); +#655=IFCDIRECTION((0.,0.,1.)); +#656=IFCDIRECTION((1.,0.,0.)); +#657=IFCAXIS2PLACEMENT3D(#654,#655,#656); +#658=IFCLOCALPLACEMENT($,#657); +#659=IFCCARTESIANPOINT((0.,0.,0.)); +#660=IFCDIRECTION((0.,0.,1.)); +#661=IFCDIRECTION((1.,0.,0.)); +#662=IFCAXIS2PLACEMENT3D(#659,#660,#661); +#663=IFCCARTESIANPOINTLIST2D(((22.,0.),(22.,1.5),(23.5,0.),(23.5,1.5)),$); +#664=IFCINDEXEDPOLYCURVE(#663,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#665=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#664); +#666=IFCDIRECTION((0.,0.,1.)); +#667=IFCEXTRUDEDAREASOLID(#665,#662,#666,0.01); +#668=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#667)); +#669=IFCPRODUCTDEFINITIONSHAPE($,$,(#668)); +#670=IFCPLATE('0qPP19WlUigt38jrSrsC0Y',#20,'b1-pl1','b1-pl1',$,#658,#669,$,$); +#671=IFCSURFACESTYLESHADING(#130,0.); +#672=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#671)); +#673=IFCSTYLEDITEM(#667,(#672),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#674=IFCCARTESIANPOINT((0.,0.,0.)); +#675=IFCDIRECTION((0.,0.,1.)); +#676=IFCDIRECTION((1.,0.,0.)); +#677=IFCAXIS2PLACEMENT3D(#674,#675,#676); +#678=IFCLOCALPLACEMENT($,#677); +#679=IFCCARTESIANPOINT((0.,0.,0.)); +#680=IFCDIRECTION((0.,0.,1.)); +#681=IFCDIRECTION((1.,0.,0.)); +#682=IFCAXIS2PLACEMENT3D(#679,#680,#681); +#683=IFCCARTESIANPOINTLIST2D(((24.,0.),(24.,1.5),(25.5,0.),(25.5,1.5)),$); +#684=IFCINDEXEDPOLYCURVE(#683,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#685=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#684); +#686=IFCDIRECTION((0.,0.,1.)); +#687=IFCEXTRUDEDAREASOLID(#685,#682,#686,0.01); +#688=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#687)); +#689=IFCPRODUCTDEFINITIONSHAPE($,$,(#688)); +#690=IFCPLATE('2QNHz3IxXtOCTiNEEZuamZ',#20,'b1-pl2','b1-pl2',$,#678,#689,$,$); +#691=IFCSURFACESTYLESHADING(#130,0.); +#692=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#691)); +#693=IFCSTYLEDITEM(#687,(#692),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#694=IFCCARTESIANPOINT((0.,0.,0.)); +#695=IFCDIRECTION((0.,0.,1.)); +#696=IFCDIRECTION((1.,0.,0.)); +#697=IFCAXIS2PLACEMENT3D(#694,#695,#696); +#698=IFCLOCALPLACEMENT($,#697); +#699=IFCCARTESIANPOINT((0.,0.,0.)); +#700=IFCDIRECTION((0.,0.,1.)); +#701=IFCDIRECTION((1.,0.,0.)); +#702=IFCAXIS2PLACEMENT3D(#699,#700,#701); +#703=IFCCARTESIANPOINTLIST2D(((26.,0.),(26.,1.5),(27.5,0.),(27.5,1.5)),$); +#704=IFCINDEXEDPOLYCURVE(#703,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#705=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#704); +#706=IFCDIRECTION((0.,0.,1.)); +#707=IFCEXTRUDEDAREASOLID(#705,#702,#706,0.01); +#708=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#707)); +#709=IFCPRODUCTDEFINITIONSHAPE($,$,(#708)); +#710=IFCPLATE('0yVRjuoWOKO2HY_opVUVXc',#20,'b1-pl3','b1-pl3',$,#698,#709,$,$); +#711=IFCSURFACESTYLESHADING(#130,0.); +#712=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#711)); +#713=IFCSTYLEDITEM(#707,(#712),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#714=IFCDIRECTION((0.,0.,1.)); +#715=IFCDIRECTION((1.,0.,0.)); +#716=IFCCARTESIANPOINT((0.,0.,0.)); +#717=IFCAXIS2PLACEMENT3D(#716,#714,#715); +#718=IFCLOCALPLACEMENT(#62,#717); +#719=IFCCARTESIANPOINT((20.,0.,0.)); +#720=IFCDIRECTION((0.,1.,0.)); +#721=IFCDIRECTION((-1.,0.,0.)); +#722=IFCAXIS2PLACEMENT3D(#719,#720,#721); +#723=IFCDIRECTION((0.,0.,1.)); +#724=IFCEXTRUDEDAREASOLID(#71,#722,#723,5.); +#725=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#724)); +#726=IFCDIRECTION((0.,0.,1.)); +#727=IFCDIRECTION((1.,0.,0.)); +#728=IFCCARTESIANPOINT((0.,0.,0.)); +#729=IFCAXIS2PLACEMENT3D(#728,#726,#727); +#730=IFCLOCALPLACEMENT(#718,#729); +#731=IFCCARTESIANPOINT((20.,0.,0.)); +#732=IFCCARTESIANPOINT((20.,5.,0.)); +#733=IFCPOLYLINE((#731,#732)); +#734=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#733)); +#735=IFCSURFACESTYLESHADING(#130,0.); +#736=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#735)); +#737=IFCSTYLEDITEM(#724,(#736),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#738=IFCPRODUCTDEFINITIONSHAPE($,$,(#725,#734)); +#739=IFCBEAM('1FP2qPBI8$jrsd5PlngRu$',#20,'b1-bm0','IPE200','Beam',#730,#738,$,$); +#740=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#741=IFCMATERIALPROFILESET('IPE200',$,(#740),$); +#742=IFCMATERIALPROFILESETUSAGE(#741,5,$); +#743=IFCRELASSOCIATESMATERIAL('1G9Gg35dX72OVFZXrCetVO',#20,$,$,(#739),#742); +#744=IFCDIRECTION((0.,0.,1.)); +#745=IFCDIRECTION((1.,0.,0.)); +#746=IFCCARTESIANPOINT((0.,0.,0.)); +#747=IFCAXIS2PLACEMENT3D(#746,#744,#745); +#748=IFCLOCALPLACEMENT(#62,#747); +#749=IFCCARTESIANPOINT((22.,0.,0.)); +#750=IFCDIRECTION((0.,1.,0.)); +#751=IFCDIRECTION((-1.,0.,0.)); +#752=IFCAXIS2PLACEMENT3D(#749,#750,#751); +#753=IFCDIRECTION((0.,0.,1.)); +#754=IFCEXTRUDEDAREASOLID(#71,#752,#753,5.); +#755=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#754)); +#756=IFCDIRECTION((0.,0.,1.)); +#757=IFCDIRECTION((1.,0.,0.)); +#758=IFCCARTESIANPOINT((0.,0.,0.)); +#759=IFCAXIS2PLACEMENT3D(#758,#756,#757); +#760=IFCLOCALPLACEMENT(#748,#759); +#761=IFCCARTESIANPOINT((22.,0.,0.)); +#762=IFCCARTESIANPOINT((22.,5.,0.)); +#763=IFCPOLYLINE((#761,#762)); +#764=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#763)); +#765=IFCSURFACESTYLESHADING(#130,0.); +#766=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#765)); +#767=IFCSTYLEDITEM(#754,(#766),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#768=IFCPRODUCTDEFINITIONSHAPE($,$,(#755,#764)); +#769=IFCBEAM('0IyZfVFUUUgnwfdpDBTZMu',#20,'b1-bm1','IPE200','Beam',#760,#768,$,$); +#770=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#771=IFCMATERIALPROFILESET('IPE200',$,(#770),$); +#772=IFCMATERIALPROFILESETUSAGE(#771,5,$); +#773=IFCRELASSOCIATESMATERIAL('2aA_WUfwv9ohlvJO$a0yoP',#20,$,$,(#769),#772); +#774=IFCDIRECTION((0.,0.,1.)); +#775=IFCDIRECTION((1.,0.,0.)); +#776=IFCCARTESIANPOINT((0.,0.,0.)); +#777=IFCAXIS2PLACEMENT3D(#776,#774,#775); +#778=IFCLOCALPLACEMENT(#62,#777); +#779=IFCCARTESIANPOINT((24.,0.,0.)); +#780=IFCDIRECTION((0.,1.,0.)); +#781=IFCDIRECTION((-1.,0.,0.)); +#782=IFCAXIS2PLACEMENT3D(#779,#780,#781); +#783=IFCDIRECTION((0.,0.,1.)); +#784=IFCEXTRUDEDAREASOLID(#71,#782,#783,5.); +#785=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#784)); +#786=IFCDIRECTION((0.,0.,1.)); +#787=IFCDIRECTION((1.,0.,0.)); +#788=IFCCARTESIANPOINT((0.,0.,0.)); +#789=IFCAXIS2PLACEMENT3D(#788,#786,#787); +#790=IFCLOCALPLACEMENT(#778,#789); +#791=IFCCARTESIANPOINT((24.,0.,0.)); +#792=IFCCARTESIANPOINT((24.,5.,0.)); +#793=IFCPOLYLINE((#791,#792)); +#794=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#793)); +#795=IFCSURFACESTYLESHADING(#130,0.); +#796=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#795)); +#797=IFCSTYLEDITEM(#784,(#796),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#798=IFCPRODUCTDEFINITIONSHAPE($,$,(#785,#794)); +#799=IFCBEAM('0PXvnxnKhJJaVspNOSVrv1',#20,'b1-bm2','IPE200','Beam',#790,#798,$,$); +#800=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#801=IFCMATERIALPROFILESET('IPE200',$,(#800),$); +#802=IFCMATERIALPROFILESETUSAGE(#801,5,$); +#803=IFCRELASSOCIATESMATERIAL('1mxxbCv_T7avcjjmuZBjfK',#20,$,$,(#799),#802); +#804=IFCDIRECTION((0.,0.,1.)); +#805=IFCDIRECTION((1.,0.,0.)); +#806=IFCCARTESIANPOINT((0.,0.,0.)); +#807=IFCAXIS2PLACEMENT3D(#806,#804,#805); +#808=IFCLOCALPLACEMENT(#62,#807); +#809=IFCCARTESIANPOINT((26.,0.,0.)); +#810=IFCDIRECTION((0.,1.,0.)); +#811=IFCDIRECTION((-1.,0.,0.)); +#812=IFCAXIS2PLACEMENT3D(#809,#810,#811); +#813=IFCDIRECTION((0.,0.,1.)); +#814=IFCEXTRUDEDAREASOLID(#71,#812,#813,5.); +#815=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#814)); +#816=IFCDIRECTION((0.,0.,1.)); +#817=IFCDIRECTION((1.,0.,0.)); +#818=IFCCARTESIANPOINT((0.,0.,0.)); +#819=IFCAXIS2PLACEMENT3D(#818,#816,#817); +#820=IFCLOCALPLACEMENT(#808,#819); +#821=IFCCARTESIANPOINT((26.,0.,0.)); +#822=IFCCARTESIANPOINT((26.,5.,0.)); +#823=IFCPOLYLINE((#821,#822)); +#824=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#823)); +#825=IFCSURFACESTYLESHADING(#130,0.); +#826=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#825)); +#827=IFCSTYLEDITEM(#814,(#826),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#828=IFCPRODUCTDEFINITIONSHAPE($,$,(#815,#824)); +#829=IFCBEAM('0bwrkTHTJfmVyDV3SWVzwK',#20,'b1-bm3','IPE200','Beam',#820,#828,$,$); +#830=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#831=IFCMATERIALPROFILESET('IPE200',$,(#830),$); +#832=IFCMATERIALPROFILESETUSAGE(#831,5,$); +#833=IFCRELASSOCIATESMATERIAL('3vIGNPbfHEBQothnjfhyZJ',#20,$,$,(#829),#832); +#834=IFCDIRECTION((0.,0.,1.)); +#835=IFCDIRECTION((1.,0.,0.)); +#836=IFCCARTESIANPOINT((0.,0.,0.)); +#837=IFCAXIS2PLACEMENT3D(#836,#834,#835); +#838=IFCLOCALPLACEMENT(#62,#837); +#839=IFCCARTESIANPOINT((28.,0.,0.)); +#840=IFCDIRECTION((0.,1.,0.)); +#841=IFCDIRECTION((-1.,0.,0.)); +#842=IFCAXIS2PLACEMENT3D(#839,#840,#841); +#843=IFCDIRECTION((0.,0.,1.)); +#844=IFCEXTRUDEDAREASOLID(#71,#842,#843,5.); +#845=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#844)); +#846=IFCDIRECTION((0.,0.,1.)); +#847=IFCDIRECTION((1.,0.,0.)); +#848=IFCCARTESIANPOINT((0.,0.,0.)); +#849=IFCAXIS2PLACEMENT3D(#848,#846,#847); +#850=IFCLOCALPLACEMENT(#838,#849); +#851=IFCCARTESIANPOINT((28.,0.,0.)); +#852=IFCCARTESIANPOINT((28.,5.,0.)); +#853=IFCPOLYLINE((#851,#852)); +#854=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#853)); +#855=IFCSURFACESTYLESHADING(#130,0.); +#856=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#855)); +#857=IFCSTYLEDITEM(#844,(#856),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#858=IFCPRODUCTDEFINITIONSHAPE($,$,(#845,#854)); +#859=IFCBEAM('09DR0eOQl5NpATiEOWTwYn',#20,'b1-bm4','IPE200','Beam',#850,#858,$,$); +#860=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#861=IFCMATERIALPROFILESET('IPE200',$,(#860),$); +#862=IFCMATERIALPROFILESETUSAGE(#861,5,$); +#863=IFCRELASSOCIATESMATERIAL('133E0BY7n0hh3ehWfunWCb',#20,$,$,(#859),#862); +#864=IFCDIRECTION((0.,0.,1.)); +#865=IFCDIRECTION((1.,0.,0.)); +#866=IFCCARTESIANPOINT((0.,0.,0.)); +#867=IFCAXIS2PLACEMENT3D(#866,#864,#865); +#868=IFCLOCALPLACEMENT(#62,#867); +#869=IFCCARTESIANPOINT((30.,0.,0.)); +#870=IFCDIRECTION((0.,1.,0.)); +#871=IFCDIRECTION((-1.,0.,0.)); +#872=IFCAXIS2PLACEMENT3D(#869,#870,#871); +#873=IFCDIRECTION((0.,0.,1.)); +#874=IFCEXTRUDEDAREASOLID(#71,#872,#873,5.); +#875=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#874)); +#876=IFCDIRECTION((0.,0.,1.)); +#877=IFCDIRECTION((1.,0.,0.)); +#878=IFCCARTESIANPOINT((0.,0.,0.)); +#879=IFCAXIS2PLACEMENT3D(#878,#876,#877); +#880=IFCLOCALPLACEMENT(#868,#879); +#881=IFCCARTESIANPOINT((30.,0.,0.)); +#882=IFCCARTESIANPOINT((30.,5.,0.)); +#883=IFCPOLYLINE((#881,#882)); +#884=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#883)); +#885=IFCSURFACESTYLESHADING(#130,0.); +#886=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#885)); +#887=IFCSTYLEDITEM(#874,(#886),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#888=IFCPRODUCTDEFINITIONSHAPE($,$,(#875,#884)); +#889=IFCBEAM('0XHu0yc8pT8WpfbO00soIg',#20,'b1-bm5','IPE200','Beam',#880,#888,$,$); +#890=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#891=IFCMATERIALPROFILESET('IPE200',$,(#890),$); +#892=IFCMATERIALPROFILESETUSAGE(#891,5,$); +#893=IFCRELASSOCIATESMATERIAL('3y_rGGe_f5AvLwNBKvOsIO',#20,$,$,(#889),#892); +#894=IFCCARTESIANPOINT((0.,0.,0.)); +#895=IFCDIRECTION((0.,0.,1.)); +#896=IFCDIRECTION((1.,0.,0.)); +#897=IFCAXIS2PLACEMENT3D(#894,#895,#896); +#898=IFCLOCALPLACEMENT($,#897); +#899=IFCCARTESIANPOINT((0.,0.,3.)); +#900=IFCDIRECTION((0.,0.,1.)); +#901=IFCDIRECTION((1.,0.,0.)); +#902=IFCAXIS2PLACEMENT3D(#899,#900,#901); +#903=IFCCARTESIANPOINTLIST2D(((20.,0.),(20.,1.5),(21.5,0.),(21.5,1.5)),$); +#904=IFCINDEXEDPOLYCURVE(#903,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#905=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#904); +#906=IFCDIRECTION((0.,0.,1.)); +#907=IFCEXTRUDEDAREASOLID(#905,#902,#906,0.01); +#908=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#907)); +#909=IFCPRODUCTDEFINITIONSHAPE($,$,(#908)); +#910=IFCPLATE('1r2wNAhsYMOwzfCgjVNx8N',#20,'b2-pl0','b2-pl0',$,#898,#909,$,$); +#911=IFCSURFACESTYLESHADING(#130,0.); +#912=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#911)); +#913=IFCSTYLEDITEM(#907,(#912),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#914=IFCCARTESIANPOINT((0.,0.,0.)); +#915=IFCDIRECTION((0.,0.,1.)); +#916=IFCDIRECTION((1.,0.,0.)); +#917=IFCAXIS2PLACEMENT3D(#914,#915,#916); +#918=IFCLOCALPLACEMENT($,#917); +#919=IFCCARTESIANPOINT((0.,0.,3.)); +#920=IFCDIRECTION((0.,0.,1.)); +#921=IFCDIRECTION((1.,0.,0.)); +#922=IFCAXIS2PLACEMENT3D(#919,#920,#921); +#923=IFCCARTESIANPOINTLIST2D(((22.,0.),(22.,1.5),(23.5,0.),(23.5,1.5)),$); +#924=IFCINDEXEDPOLYCURVE(#923,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#925=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#924); +#926=IFCDIRECTION((0.,0.,1.)); +#927=IFCEXTRUDEDAREASOLID(#925,#922,#926,0.01); +#928=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#927)); +#929=IFCPRODUCTDEFINITIONSHAPE($,$,(#928)); +#930=IFCPLATE('34b_niFbK_x7Ui15X5EdNj',#20,'b2-pl1','b2-pl1',$,#918,#929,$,$); +#931=IFCSURFACESTYLESHADING(#130,0.); +#932=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#931)); +#933=IFCSTYLEDITEM(#927,(#932),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#934=IFCCARTESIANPOINT((0.,0.,0.)); +#935=IFCDIRECTION((0.,0.,1.)); +#936=IFCDIRECTION((1.,0.,0.)); +#937=IFCAXIS2PLACEMENT3D(#934,#935,#936); +#938=IFCLOCALPLACEMENT($,#937); +#939=IFCCARTESIANPOINT((0.,0.,3.)); +#940=IFCDIRECTION((0.,0.,1.)); +#941=IFCDIRECTION((1.,0.,0.)); +#942=IFCAXIS2PLACEMENT3D(#939,#940,#941); +#943=IFCCARTESIANPOINTLIST2D(((24.,0.),(24.,1.5),(25.5,0.),(25.5,1.5)),$); +#944=IFCINDEXEDPOLYCURVE(#943,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#945=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#944); +#946=IFCDIRECTION((0.,0.,1.)); +#947=IFCEXTRUDEDAREASOLID(#945,#942,#946,0.01); +#948=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#947)); +#949=IFCPRODUCTDEFINITIONSHAPE($,$,(#948)); +#950=IFCPLATE('1HvtXVX_7euSsiPX1YkYAa',#20,'b2-pl2','b2-pl2',$,#938,#949,$,$); +#951=IFCSURFACESTYLESHADING(#130,0.); +#952=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#951)); +#953=IFCSTYLEDITEM(#947,(#952),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#954=IFCCARTESIANPOINT((0.,0.,0.)); +#955=IFCDIRECTION((0.,0.,1.)); +#956=IFCDIRECTION((1.,0.,0.)); +#957=IFCAXIS2PLACEMENT3D(#954,#955,#956); +#958=IFCLOCALPLACEMENT($,#957); +#959=IFCCARTESIANPOINT((0.,0.,3.)); +#960=IFCDIRECTION((0.,0.,1.)); +#961=IFCDIRECTION((1.,0.,0.)); +#962=IFCAXIS2PLACEMENT3D(#959,#960,#961); +#963=IFCCARTESIANPOINTLIST2D(((26.,0.),(26.,1.5),(27.5,0.),(27.5,1.5)),$); +#964=IFCINDEXEDPOLYCURVE(#963,(IFCLINEINDEX((3,1)),IFCLINEINDEX((1,2)),IFCLINEINDEX((2,4)),IFCLINEINDEX((4,3))),$); +#965=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#964); +#966=IFCDIRECTION((0.,0.,1.)); +#967=IFCEXTRUDEDAREASOLID(#965,#962,#966,0.01); +#968=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#967)); +#969=IFCPRODUCTDEFINITIONSHAPE($,$,(#968)); +#970=IFCPLATE('1WxipBp1UVEzYHB$OcBFsi',#20,'b2-pl3','b2-pl3',$,#958,#969,$,$); +#971=IFCSURFACESTYLESHADING(#130,0.); +#972=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#971)); +#973=IFCSTYLEDITEM(#967,(#972),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#974=IFCDIRECTION((0.,0.,1.)); +#975=IFCDIRECTION((1.,0.,0.)); +#976=IFCCARTESIANPOINT((0.,0.,0.)); +#977=IFCAXIS2PLACEMENT3D(#976,#974,#975); +#978=IFCLOCALPLACEMENT(#69,#977); +#979=IFCCARTESIANPOINT((20.,0.,3.)); +#980=IFCDIRECTION((0.,1.,0.)); +#981=IFCDIRECTION((-1.,0.,0.)); +#982=IFCAXIS2PLACEMENT3D(#979,#980,#981); +#983=IFCDIRECTION((0.,0.,1.)); +#984=IFCEXTRUDEDAREASOLID(#71,#982,#983,5.); +#985=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#984)); +#986=IFCDIRECTION((0.,0.,1.)); +#987=IFCDIRECTION((1.,0.,0.)); +#988=IFCCARTESIANPOINT((0.,0.,0.)); +#989=IFCAXIS2PLACEMENT3D(#988,#986,#987); +#990=IFCLOCALPLACEMENT(#978,#989); +#991=IFCCARTESIANPOINT((20.,0.,3.)); +#992=IFCCARTESIANPOINT((20.,5.,3.)); +#993=IFCPOLYLINE((#991,#992)); +#994=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#993)); +#995=IFCSURFACESTYLESHADING(#130,0.); +#996=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#995)); +#997=IFCSTYLEDITEM(#984,(#996),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#998=IFCPRODUCTDEFINITIONSHAPE($,$,(#985,#994)); +#999=IFCBEAM('0v2L4Meq4BArP4UyilJqZ5',#20,'b2-bm0','IPE200','Beam',#990,#998,$,$); +#1000=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#1001=IFCMATERIALPROFILESET('IPE200',$,(#1000),$); +#1002=IFCMATERIALPROFILESETUSAGE(#1001,5,$); +#1003=IFCRELASSOCIATESMATERIAL('2MigcwfZb6hPw8KtE1GQDl',#20,$,$,(#999),#1002); +#1004=IFCDIRECTION((0.,0.,1.)); +#1005=IFCDIRECTION((1.,0.,0.)); +#1006=IFCCARTESIANPOINT((0.,0.,0.)); +#1007=IFCAXIS2PLACEMENT3D(#1006,#1004,#1005); +#1008=IFCLOCALPLACEMENT(#69,#1007); +#1009=IFCCARTESIANPOINT((22.,0.,3.)); +#1010=IFCDIRECTION((0.,1.,0.)); +#1011=IFCDIRECTION((-1.,0.,0.)); +#1012=IFCAXIS2PLACEMENT3D(#1009,#1010,#1011); +#1013=IFCDIRECTION((0.,0.,1.)); +#1014=IFCEXTRUDEDAREASOLID(#71,#1012,#1013,5.); +#1015=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#1014)); +#1016=IFCDIRECTION((0.,0.,1.)); +#1017=IFCDIRECTION((1.,0.,0.)); +#1018=IFCCARTESIANPOINT((0.,0.,0.)); +#1019=IFCAXIS2PLACEMENT3D(#1018,#1016,#1017); +#1020=IFCLOCALPLACEMENT(#1008,#1019); +#1021=IFCCARTESIANPOINT((22.,0.,3.)); +#1022=IFCCARTESIANPOINT((22.,5.,3.)); +#1023=IFCPOLYLINE((#1021,#1022)); +#1024=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#1023)); +#1025=IFCSURFACESTYLESHADING(#130,0.); +#1026=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1025)); +#1027=IFCSTYLEDITEM(#1014,(#1026),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#1028=IFCPRODUCTDEFINITIONSHAPE($,$,(#1015,#1024)); +#1029=IFCBEAM('2dThkUTMzhozUEdWheLDhv',#20,'b2-bm1','IPE200','Beam',#1020,#1028,$,$); +#1030=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#1031=IFCMATERIALPROFILESET('IPE200',$,(#1030),$); +#1032=IFCMATERIALPROFILESETUSAGE(#1031,5,$); +#1033=IFCRELASSOCIATESMATERIAL('3GmWqu7Lj1URhg7Mud1njF',#20,$,$,(#1029),#1032); +#1034=IFCDIRECTION((0.,0.,1.)); +#1035=IFCDIRECTION((1.,0.,0.)); +#1036=IFCCARTESIANPOINT((0.,0.,0.)); +#1037=IFCAXIS2PLACEMENT3D(#1036,#1034,#1035); +#1038=IFCLOCALPLACEMENT(#69,#1037); +#1039=IFCCARTESIANPOINT((24.,0.,3.)); +#1040=IFCDIRECTION((0.,1.,0.)); +#1041=IFCDIRECTION((-1.,0.,0.)); +#1042=IFCAXIS2PLACEMENT3D(#1039,#1040,#1041); +#1043=IFCDIRECTION((0.,0.,1.)); +#1044=IFCEXTRUDEDAREASOLID(#71,#1042,#1043,5.); +#1045=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#1044)); +#1046=IFCDIRECTION((0.,0.,1.)); +#1047=IFCDIRECTION((1.,0.,0.)); +#1048=IFCCARTESIANPOINT((0.,0.,0.)); +#1049=IFCAXIS2PLACEMENT3D(#1048,#1046,#1047); +#1050=IFCLOCALPLACEMENT(#1038,#1049); +#1051=IFCCARTESIANPOINT((24.,0.,3.)); +#1052=IFCCARTESIANPOINT((24.,5.,3.)); +#1053=IFCPOLYLINE((#1051,#1052)); +#1054=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#1053)); +#1055=IFCSURFACESTYLESHADING(#130,0.); +#1056=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1055)); +#1057=IFCSTYLEDITEM(#1044,(#1056),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#1058=IFCPRODUCTDEFINITIONSHAPE($,$,(#1045,#1054)); +#1059=IFCBEAM('1OnvJlQoUSaD5TGxs2jV_l',#20,'b2-bm2','IPE200','Beam',#1050,#1058,$,$); +#1060=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#1061=IFCMATERIALPROFILESET('IPE200',$,(#1060),$); +#1062=IFCMATERIALPROFILESETUSAGE(#1061,5,$); +#1063=IFCRELASSOCIATESMATERIAL('0tjIK8ehT8YPomBRfZ9I$Z',#20,$,$,(#1059),#1062); +#1064=IFCDIRECTION((0.,0.,1.)); +#1065=IFCDIRECTION((1.,0.,0.)); +#1066=IFCCARTESIANPOINT((0.,0.,0.)); +#1067=IFCAXIS2PLACEMENT3D(#1066,#1064,#1065); +#1068=IFCLOCALPLACEMENT(#69,#1067); +#1069=IFCCARTESIANPOINT((26.,0.,3.)); +#1070=IFCDIRECTION((0.,1.,0.)); +#1071=IFCDIRECTION((-1.,0.,0.)); +#1072=IFCAXIS2PLACEMENT3D(#1069,#1070,#1071); +#1073=IFCDIRECTION((0.,0.,1.)); +#1074=IFCEXTRUDEDAREASOLID(#71,#1072,#1073,5.); +#1075=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#1074)); +#1076=IFCDIRECTION((0.,0.,1.)); +#1077=IFCDIRECTION((1.,0.,0.)); +#1078=IFCCARTESIANPOINT((0.,0.,0.)); +#1079=IFCAXIS2PLACEMENT3D(#1078,#1076,#1077); +#1080=IFCLOCALPLACEMENT(#1068,#1079); +#1081=IFCCARTESIANPOINT((26.,0.,3.)); +#1082=IFCCARTESIANPOINT((26.,5.,3.)); +#1083=IFCPOLYLINE((#1081,#1082)); +#1084=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#1083)); +#1085=IFCSURFACESTYLESHADING(#130,0.); +#1086=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1085)); +#1087=IFCSTYLEDITEM(#1074,(#1086),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#1088=IFCPRODUCTDEFINITIONSHAPE($,$,(#1075,#1084)); +#1089=IFCBEAM('3KMq7iS5SLHctLN9ewz5HX',#20,'b2-bm3','IPE200','Beam',#1080,#1088,$,$); +#1090=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#1091=IFCMATERIALPROFILESET('IPE200',$,(#1090),$); +#1092=IFCMATERIALPROFILESETUSAGE(#1091,5,$); +#1093=IFCRELASSOCIATESMATERIAL('09YEsZZKDEUBCwwQZLekb6',#20,$,$,(#1089),#1092); +#1094=IFCDIRECTION((0.,0.,1.)); +#1095=IFCDIRECTION((1.,0.,0.)); +#1096=IFCCARTESIANPOINT((0.,0.,0.)); +#1097=IFCAXIS2PLACEMENT3D(#1096,#1094,#1095); +#1098=IFCLOCALPLACEMENT(#69,#1097); +#1099=IFCCARTESIANPOINT((28.,0.,3.)); +#1100=IFCDIRECTION((0.,1.,0.)); +#1101=IFCDIRECTION((-1.,0.,0.)); +#1102=IFCAXIS2PLACEMENT3D(#1099,#1100,#1101); +#1103=IFCDIRECTION((0.,0.,1.)); +#1104=IFCEXTRUDEDAREASOLID(#71,#1102,#1103,5.); +#1105=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#1104)); +#1106=IFCDIRECTION((0.,0.,1.)); +#1107=IFCDIRECTION((1.,0.,0.)); +#1108=IFCCARTESIANPOINT((0.,0.,0.)); +#1109=IFCAXIS2PLACEMENT3D(#1108,#1106,#1107); +#1110=IFCLOCALPLACEMENT(#1098,#1109); +#1111=IFCCARTESIANPOINT((28.,0.,3.)); +#1112=IFCCARTESIANPOINT((28.,5.,3.)); +#1113=IFCPOLYLINE((#1111,#1112)); +#1114=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#1113)); +#1115=IFCSURFACESTYLESHADING(#130,0.); +#1116=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1115)); +#1117=IFCSTYLEDITEM(#1104,(#1116),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#1118=IFCPRODUCTDEFINITIONSHAPE($,$,(#1105,#1114)); +#1119=IFCBEAM('1RB$JU5vWyObAfMV$JUre3',#20,'b2-bm4','IPE200','Beam',#1110,#1118,$,$); +#1120=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#1121=IFCMATERIALPROFILESET('IPE200',$,(#1120),$); +#1122=IFCMATERIALPROFILESETUSAGE(#1121,5,$); +#1123=IFCRELASSOCIATESMATERIAL('0eFJ4iwyL2pxtxLyTxwOnz',#20,$,$,(#1119),#1122); +#1124=IFCDIRECTION((0.,0.,1.)); +#1125=IFCDIRECTION((1.,0.,0.)); +#1126=IFCCARTESIANPOINT((0.,0.,0.)); +#1127=IFCAXIS2PLACEMENT3D(#1126,#1124,#1125); +#1128=IFCLOCALPLACEMENT(#69,#1127); +#1129=IFCCARTESIANPOINT((30.,0.,3.)); +#1130=IFCDIRECTION((0.,1.,0.)); +#1131=IFCDIRECTION((-1.,0.,0.)); +#1132=IFCAXIS2PLACEMENT3D(#1129,#1130,#1131); +#1133=IFCDIRECTION((0.,0.,1.)); +#1134=IFCEXTRUDEDAREASOLID(#71,#1132,#1133,5.); +#1135=IFCSHAPEREPRESENTATION(#12,'Body','SolidModel',(#1134)); +#1136=IFCDIRECTION((0.,0.,1.)); +#1137=IFCDIRECTION((1.,0.,0.)); +#1138=IFCCARTESIANPOINT((0.,0.,0.)); +#1139=IFCAXIS2PLACEMENT3D(#1138,#1136,#1137); +#1140=IFCLOCALPLACEMENT(#1128,#1139); +#1141=IFCCARTESIANPOINT((30.,0.,3.)); +#1142=IFCCARTESIANPOINT((30.,5.,3.)); +#1143=IFCPOLYLINE((#1141,#1142)); +#1144=IFCSHAPEREPRESENTATION(#13,'Axis','Curve3D',(#1143)); +#1145=IFCSURFACESTYLESHADING(#130,0.); +#1146=IFCSURFACESTYLE('Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)',.BOTH.,(#1145)); +#1147=IFCSTYLEDITEM(#1134,(#1146),'Color(red=0.8, green=0.8, blue=0.8, opacity=1.0)'); +#1148=IFCPRODUCTDEFINITIONSHAPE($,$,(#1135,#1144)); +#1149=IFCBEAM('1vLZwHHcHbHH5130oKqkUt',#20,'b2-bm5','IPE200','Beam',#1140,#1148,$,$); +#1150=IFCMATERIALPROFILE('IPE200','A material profile',#93,#71,$,'LoadBearing'); +#1151=IFCMATERIALPROFILESET('IPE200',$,(#1150),$); +#1152=IFCMATERIALPROFILESETUSAGE(#1151,5,$); +#1153=IFCRELASSOCIATESMATERIAL('1dEYnjAkz5v9HWR4KlUgLo',#20,$,$,(#1149),#1152); +#1154=IFCRELCONTAINEDINSPATIALSTRUCTURE('0jjtExQTX5vvREd0gYFueE',#20,'Physical model',$,(#129,#150,#170,#190,#219,#249,#279,#309,#339,#369),#37); +#1155=IFCRELAGGREGATES('0HCzfBzMv6rvQtzIkFs5Nf',#20,'Element Decomposition',$,#50,(#390,#410,#430,#450,#479,#509,#539,#569,#599,#629)); +#1156=IFCRELCONTAINEDINSPATIALSTRUCTURE('11QzDW$Iv0wBR3ttskLXVK',#20,'Physical model',$,(#650,#670,#690,#710,#739,#769,#799,#829,#859,#889),#63); +#1157=IFCRELCONTAINEDINSPATIALSTRUCTURE('1GS$ujnov1hBgriRjDsRRa',#20,'Physical model',$,(#910,#930,#950,#970,#999,#1029,#1059,#1089,#1119,#1149),#70); +#1158=IFCRELDEFINESBYTYPE('1j5sLfJtn5CfXi6qTJdbY5',#20,'I',$,(#219,#249,#279,#309,#339,#369,#509,#539,#569,#599,#629,#739,#769,#799,#829,#859,#889,#999,#1029,#1059,#1089,#1119,#1149),#81); +#1159=IFCRELDEFINESBYTYPE('0nWPaP$0D4mvQ9grJOZgtv',#20,'I',$,(#479),#92); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/core/assets/fixture_provider/publisher.py b/tests/core/assets/fixture_provider/publisher.py new file mode 100644 index 000000000..c57c11527 --- /dev/null +++ b/tests/core/assets/fixture_provider/publisher.py @@ -0,0 +1,246 @@ +"""The ``fixture-lines`` PUBLISHER -- the ``AssetPublisher.derive()`` witness for a private format. + +``provider.py::publish_fixture`` already proves the fixture's translation (private newline-JSON in, +core's manifest/hierarchy schemas out) by WRITING straight into a ``FakeStore``. That is the right +shape for the ``AssetTreeProvider``/build tests, but it is not the shape the real publish surface +uses: a real publish is ``derive()`` PLANS, core WRITES (``ada.assets.publish``), so that the owner +gate and the manifests-last ordering are properties of the STORE rather than habits every provider +has to remember (see ``src/ada/assets/publish.py``'s module docstring). + +This module is that missing half: :class:`FixtureLinesPublisher` reads the staged private source +back out through the INJECTED ``storage`` (never a file handed to it directly -- a publish's staged +bytes are already in the scope, which is the whole point of staging surviving a reload) and returns +an ORDERED :class:`~ada.assets.publish.PublishPlan` instead of writing anything itself. It derives +EXACTLY the tree ``publish_fixture`` writes (same node set, same delivery assignment, same +collection/hierarchy/manifest shape), so the two are cross-checked by construction rather than by a +separate assertion. + +**The semantics-not-format witness (Decision 7).** This publisher sets no ``change`` on any +manifest, gives every artefact list no ``attributes`` role, and never sets ``action`` -- the fixture +provider's whole publish/unpublish/orphan suite has to pass with all three absent, from a provider +whose source format core has no reader for. What core adds on top (``change.published_by`` / +``published_via``) is CORE's own stamp (``ada.assets.publish.stamp_publish``), not something this +provider supplies -- which is exactly the distinction Decision 6 draws. + +WHY THIS LIVES IN ``tests/`` AND NOT ``src/ada/``. Registering a publisher for a private format +there would be the layering violation the fixture provider exists to catch. Nothing here runs +unless a test explicitly calls :func:`register_fixture_publisher` -- the same "presence is opt-in, +never an import side effect" discipline ``ada.assets.registry``/``ada.assets.publishers`` already +use for every other registration (see ``builder.py``'s identical note). +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +from tests.core.assets.fixture_provider.provider import ( + BUILD_CAPABILITY, + FIXTURE_PROVIDER_ID, + FIXTURE_SOURCE_LINES, + MESH_FILENAME, + SOURCE_FILENAME, + _parse_private_source, + _sha, + _tiny_glb, +) + +from ada.assets.keys import ( + ASSET_PREFIX, + STAGING_SEGMENT, + asset_key, + revision_from_instant, +) +from ada.assets.manifest import ( + HIERARCHY_FILENAME, + MANIFEST_FILENAME, + ArtefactEntry, + AssetManifest, + BuildSpec, +) +from ada.assets.projection import build_hierarchy +from ada.assets.publish import PlannedWrite, PublishPlan +from ada.assets.publishers import register_asset_publisher + +__all__ = [ + "DEFAULT_COLLECTION", + "DEFAULT_INSTANT", + "FixtureLinesPublisher", + "fixture_source_bytes", + "register_fixture_publisher", + "stage_fixture_source", +] + +# Kept identical to `provider.py::publish_fixture`'s defaults on purpose: any test that publishes +# through BOTH paths (the direct-write oracle and this derive()-based one) can compare their output +# byte for byte without threading its own instant/collection through each call. +DEFAULT_COLLECTION = "fixture-a" +DEFAULT_INSTANT = "2026-09-21T14:30:01Z" + + +def fixture_source_bytes() -> bytes: + """The private newline-delimited source, as an upload would deliver it. Never named outside + ``tests/`` -- see ``provider.py::_parse_private_source``'s own note.""" + return ("\n".join(json.dumps(line) for line in FIXTURE_SOURCE_LINES)).encode("utf-8") + + +def stage_fixture_source(store, staging_id: str = "up1") -> str: + """Put the private source where a real upload would -- ``assets/_staging//`` -- and + hand back its key. + + Staging is the ONLY way a real publish reaches a provider's ``derive()``: the staged bytes are + read back out through ``storage.get_bytes``, never handed to the provider directly, because a + publish that survives a process restart depends on the bytes being in the store already. + """ + key = f"{ASSET_PREFIX}/{STAGING_SEGMENT}/{staging_id}/{SOURCE_FILENAME}" + store.put(key, fixture_source_bytes()) + return key + + +class FixtureLinesPublisher: + """``derive()`` for the private vendor-lines format -- PLANS, never writes. + + Mirrors ``provider.py::publish_fixture()``'s derivation exactly (same node set, same delivery + assignment, same collection/hierarchy/manifest shape) but returns the writes as an ORDERED + ``PublishPlan`` instead of putting them into a store directly. That is what proves the + provider-plans/core-writes split (``ada.assets.publish``'s module docstring) rather than merely + asserting it: nothing here calls ``storage.put_bytes`` at all. + """ + + id = FIXTURE_PROVIDER_ID + + def derive( + self, + scope: Any, + staged: Mapping[str, str], + *, + storage: Any, + collection: str | None = None, + options: Mapping[str, Any] | None = None, + dry_run: bool = False, + ) -> PublishPlan: + del scope # this private format carries no scope-shaped concept of its own + del dry_run # derive() never writes either way -- see the class docstring + opts = dict(options or {}) + collection = collection or DEFAULT_COLLECTION + instant = opts.get("instant", DEFAULT_INSTANT) + mesh_node = opts.get("mesh_node", "pump-a") + + source_key = staged.get(SOURCE_FILENAME) + if source_key is None: + # A caller may stage under any role name it likes (`POST /assets/publish`'s `staged` + # map is caller-chosen); only when there is exactly one staged file is "which one is + # the source" unambiguous without a role convention this format does not have. + if len(staged) != 1: + raise ValueError( + f"fixture-lines derive(): expected a staged {SOURCE_FILENAME!r} or exactly one " + f"staged file, got {sorted(staged)}" + ) + source_key = next(iter(staged.values())) + raw = storage.get_bytes(source_key) + records = _parse_private_source(raw) + + revision = revision_from_instant(instant) + published_source_key = asset_key(collection, collection, revision, SOURCE_FILENAME) + + parents = {r["up"] for r in records if r["up"] is not None} + nodes: list[dict] = [] + for rec in records: + ref = rec["ref"] + is_leaf = ref not in parents + delivery = "mesh" if ref == mesh_node else ("build" if is_leaf else "") + nodes.append( + { + "id": ref, + "parent": rec["up"], + "label": rec["title"], # vendor "title" -> core "label" + "kind": rec["cat"], # vendor "cat" -> core "kind" + "leaf": is_leaf, + "delivery": delivery, + } + ) + + writes: list[PlannedWrite] = [PlannedWrite(published_source_key, raw)] + + for node in nodes: + # `key=`, never `file=`: every node manifest shares the ONE uploaded source rather than + # copying it -- the "N leaf manifests, one blob" property Decision 3 keeps from the + # prior art, and exactly the shape ``ada.assets.unpublish``'s refcount check exists for. + artefacts = [ArtefactEntry(role="source", key=published_source_key, sha256=_sha(raw), size=len(raw))] + build = None + if node["delivery"] == "build": + build = BuildSpec( + capability=BUILD_CAPABILITY, + options={"ref": node["id"], "source_key": published_source_key}, + fingerprint_inputs=("source_key", "ref"), + ) + if node["delivery"] == "mesh": + glb = _tiny_glb() + mesh_key = asset_key(collection, node["id"], revision, MESH_FILENAME) + writes.append(PlannedWrite(mesh_key, glb)) + artefacts.append(ArtefactEntry(role="mesh", file=MESH_FILENAME, sha256=_sha(glb), size=len(glb))) + manifest = AssetManifest( + provider=FIXTURE_PROVIDER_ID, + collection=collection, + subject=node["id"], + revision=revision, + node=node["id"], + produced_at=instant, + published_at=instant, + delivery=node["delivery"] or "none", + build=build, + artefacts=tuple(artefacts), + counts={"nodes": 1}, + # No `change=`: the fixture format carries no authorship of its own to relay, and + # this is the field the owner gate exists to police (Decision 6) -- leaving it out + # entirely is the honest answer, not a `ChangeRecord()` full of Nones. + ) + writes.append( + PlannedWrite(asset_key(collection, node["id"], revision, MANIFEST_FILENAME), manifest.to_json()) + ) + + slice_ = build_hierarchy( + provider=FIXTURE_PROVIDER_ID, collection=collection, produced_at=instant, nodes=nodes, depth=3 + ) + hierarchy_bytes = slice_.to_json() + writes.append(PlannedWrite(asset_key(collection, collection, revision, HIERARCHY_FILENAME), hierarchy_bytes)) + + leaves_published = sum(1 for n in nodes if n["leaf"]) + collection_manifest = AssetManifest( + provider=FIXTURE_PROVIDER_ID, + collection=collection, + subject=collection, + revision=revision, + node=None, + produced_at=instant, + published_at=instant, + delivery="none", + artefacts=( + ArtefactEntry( + role="hierarchy", file=HIERARCHY_FILENAME, sha256=_sha(hierarchy_bytes), size=len(hierarchy_bytes) + ), + ArtefactEntry(role="source", file=SOURCE_FILENAME, sha256=_sha(raw), size=len(raw)), + ), + counts={"nodes": len(nodes), "leaves": leaves_published}, + ) + # Collection manifest LAST, across every subject this publish wrote -- the one signal that + # "everything this publish promised is here" (mirrors `ada.assets.ifc.publish`'s ordering). + writes.append( + PlannedWrite(asset_key(collection, collection, revision, MANIFEST_FILENAME), collection_manifest.to_json()) + ) + + return PublishPlan( + collection=collection, + revision=revision, + subjects=tuple(n["id"] for n in nodes), + writes=tuple(writes), + counts={"nodes": len(nodes), "leaves": leaves_published}, + # No `change=` at the plan level either -- see the per-manifest note above. + ) + + +def register_fixture_publisher() -> None: + """Register :class:`FixtureLinesPublisher` for ``FIXTURE_PROVIDER_ID``. Call from a test's + setup, never at import -- ``ada.assets.publishers`` must not learn about this format unless a + test opts in (see the module docstring).""" + register_asset_publisher(FIXTURE_PROVIDER_ID, FixtureLinesPublisher, label="Fixture publisher (lines)") diff --git a/tests/core/assets/ifc/fake_store.py b/tests/core/assets/ifc/fake_store.py index 5ff6a7820..9102b001d 100644 --- a/tests/core/assets/ifc/fake_store.py +++ b/tests/core/assets/ifc/fake_store.py @@ -30,8 +30,12 @@ def reader(self) -> StorageReader: class FakeSyncStorageFacade: - """The subset of ``worker/source_nodes.py::_SyncStorageFacade`` a builder actually calls: - ``get_bytes`` / ``put_bytes(key, data, content_encoding=None)``.""" + """The subset of ``worker/source_nodes.py::_SyncStorageFacade`` a builder OR a publisher + actually calls: ``get_bytes`` / ``list_keys(prefix)`` / ``put_bytes(key, data, + content_encoding=None)``. ``AssetPublisher.derive()`` is handed exactly this shape + (``formats/asset_publish.py``, ``local_jobs.py::start_asset_publish``), so + ``IfcAssetPublisher``'s tests are driven against it rather than against + :class:`FakeStore`'s own ``list_prefix``-shaped reader.""" def __init__(self, store: FakeStore) -> None: self._store = store @@ -39,5 +43,8 @@ def __init__(self, store: FakeStore) -> None: def get_bytes(self, key: str) -> bytes: return self._store.get_bytes(key) + def list_keys(self, prefix: str = "") -> list[str]: + return list(self._store.list_prefix(prefix)) + def put_bytes(self, key: str, data: bytes, content_encoding: str | None = None) -> None: self._store.put_bytes(key, data) diff --git a/tests/core/assets/ifc/test_ifc_publisher.py b/tests/core/assets/ifc/test_ifc_publisher.py new file mode 100644 index 000000000..c1eaead3e --- /dev/null +++ b/tests/core/assets/ifc/test_ifc_publisher.py @@ -0,0 +1,301 @@ +"""``IfcAssetPublisher`` (Phase 4): ``derive()`` PLANS through the core ``AssetPublisher`` +protocol, and core (``apply_publish_plan``) writes it -- this is the same derivation +``publish_ifc`` uses (``ada.assets.ifc.publish._derive_ifc_plan``), so the two entry points can +never disagree about what a given staged file means. Pins: the owner gate (Decision 6), leaf +revision > storey revision under-without-stem (Decision 4), re-publish keeps the old revision, and +that ``source_actor`` is relayed ONLY when the IFC file genuinely has more than one owner history +(Decision 6's IFC rule) -- and never ``published_by``. +""" + +from __future__ import annotations + +import dataclasses +import pathlib + +import ifcopenshell +import pytest + +from ada.assets.ifc.publish import IFC_PROVIDER_ID, SOURCE_FILENAME +from ada.assets.ifc.publisher import IfcAssetPublisher +from ada.assets.keys import ASSET_PREFIX +from ada.assets.manifest import Actor, ChangeRecord, parse_manifest +from ada.assets.publish import PublishError, apply_publish_plan +from ada.assets.published import PublishedAssetProvider + +from .fake_store import FakeStore, FakeSyncStorageFacade + +CORPUS_DIR = pathlib.Path(__file__).parents[1] / "corpus" +V1 = CORPUS_DIR / "plant-a_v1.ifc" +V2 = CORPUS_DIR / "plant-a_v2.ifc" +V2_LEAF = CORPUS_DIR / "plant-a_v2-leaf.ifc" + +PUBLISHED_BY = Actor(id="u-krisa", display="Kris A.") + + +def _raw(path: pathlib.Path) -> bytes: + return path.read_bytes() + + +def _names(raw: bytes) -> dict[str, str]: + f = ifcopenshell.file.from_string(raw.decode()) + return {p.Name: p.GlobalId for p in f.by_type("IfcProduct") if p.Name} + + +@pytest.fixture +def store() -> FakeStore: + return FakeStore() + + +@pytest.fixture +def facade(store: FakeStore) -> FakeSyncStorageFacade: + return FakeSyncStorageFacade(store) + + +def _stage(store: FakeStore, raw: bytes, staging_id: str = "x") -> dict[str, str]: + key = f"assets/_staging/{staging_id}/{SOURCE_FILENAME}" + store.put_bytes(key, raw) + return {SOURCE_FILENAME: key} + + +def _derive_and_apply(facade, staged, *, collection="plant-a", options=None, dry_run=False, replace=False, via="user"): + publisher = IfcAssetPublisher() + plan = publisher.derive(None, staged, storage=facade, collection=collection, options=options or {}, dry_run=dry_run) + occupied = {k for k in facade.list_keys(f"{ASSET_PREFIX}/{collection}/") if any(k == w.key for w in plan.writes)} + outcome = apply_publish_plan( + plan, + published_by=PUBLISHED_BY, + published_via=via, + dry_run=dry_run, + replace_existing=replace, + occupied=occupied, + write=lambda key, data: facade.put_bytes(key, data), + ) + return plan, outcome + + +# --- whole-file publish, via the provider protocol ---------------------------------------------- + + +def test_derive_then_apply_fans_out_two_sites_one_source_one_index(store, facade): + staged = _stage(store, _raw(V1)) + plan, outcome = _derive_and_apply(facade, staged, options={"extracted_at": "2026-01-01T00:00:00Z"}) + + assert len(outcome.subjects) == 2 + assert plan.subjects == outcome.subjects # the plan and the outcome agree on what was published + + source_keys = [k for k in outcome.written if k.endswith(f"/{SOURCE_FILENAME}")] + assert len(source_keys) == 1 + + provider = PublishedAssetProvider(store.reader(), provider_id=IFC_PROVIDER_ID) + for subject in outcome.subjects: + manifest = provider.manifest("plant-a", subject) + assert manifest.delivery == "build" + assert manifest.change.published_by == PUBLISHED_BY + assert manifest.change.published_via == "user" + + +def test_apply_publish_plan_stamps_service_via_for_a_scheduled_republish(store, facade): + staged = _stage(store, _raw(V1)) + _, outcome = _derive_and_apply(facade, staged, options={"extracted_at": "2026-01-01T00:00:00Z"}, via="service") + + provider = PublishedAssetProvider(store.reader(), provider_id=IFC_PROVIDER_ID) + for subject in outcome.subjects: + manifest = provider.manifest("plant-a", subject) + assert manifest.change.published_via == "service" + + +def test_dry_run_via_provider_writes_nothing(store, facade): + staged = _stage(store, _raw(V1)) + plan, outcome = _derive_and_apply(facade, staged, options={"extracted_at": "2026-01-01T00:00:00Z"}, dry_run=True) + assert outcome.dry_run is True + assert len(outcome.written) == len(plan.writes) > 0 + assert set(store.blobs) == {list(staged.values())[0]} # only the staged input exists + + +def test_replace_is_the_only_way_into_an_occupied_revision(store, facade): + staged = _stage(store, _raw(V1)) + opts = {"extracted_at": "2026-01-01T00:00:00Z"} + _derive_and_apply(facade, staged, options=opts) + + with pytest.raises(PublishError, match="already exist"): + _derive_and_apply(facade, staged, options=opts) + + _, outcome = _derive_and_apply(facade, staged, options=opts, replace=True) + assert outcome.revision == "20260101T000000Z" + + +# --- re-publish (v2): new revision, old kept ----------------------------------------------------- + + +def test_republish_v2_keeps_v1_revision(store, facade): + staged_v1 = _stage(store, _raw(V1), "v1") + _, outcome1 = _derive_and_apply(facade, staged_v1, options={"extracted_at": "2026-01-01T00:00:00Z"}) + + staged_v2 = _stage(store, _raw(V2), "v2") + _, outcome2 = _derive_and_apply(facade, staged_v2, options={"extracted_at": "2026-01-02T00:00:00Z"}) + + assert outcome2.revision > outcome1.revision + assert outcome1.subjects == outcome2.subjects # same two sites, same guids (Decision-stable corpus) + + idx_key_v1 = f"{ASSET_PREFIX}/plant-a/plant-a/{outcome1.revision}/asset.json" + idx_key_v2 = f"{ASSET_PREFIX}/plant-a/plant-a/{outcome2.revision}/asset.json" + assert store.get_bytes(idx_key_v1) # old kept + assert store.get_bytes(idx_key_v2) # new present + + +# --- owner gate ------------------------------------------------------------------------------------ + + +def test_derived_manifests_never_set_published_by(store, facade): + """The provider itself never sets it -- the structural half of the owner gate.""" + staged = _stage(store, _raw(V1)) + publisher = IfcAssetPublisher() + plan = publisher.derive( + None, staged, storage=facade, collection="plant-a", options={"extracted_at": "2026-01-01T00:00:00Z"} + ) + for write in plan.writes: + if write.key.endswith("/asset.json"): + manifest = parse_manifest(write.data) + assert manifest.change is None or manifest.change.published_by is None + assert manifest.change is None or manifest.change.published_via is None + + +def test_a_manifest_that_sets_published_by_is_refused(store, facade): + """Core's procedural half of the same gate: if a plan's manifest DID carry published_by + (simulated here by tampering with one write after deriving), apply_publish_plan refuses it by + name rather than silently overwriting a lie.""" + staged = _stage(store, _raw(V1)) + publisher = IfcAssetPublisher() + plan = publisher.derive( + None, staged, storage=facade, collection="plant-a", options={"extracted_at": "2026-01-01T00:00:00Z"} + ) + manifest_write = next(w for w in plan.writes if w.key.endswith("/asset.json") and "/plant-a/plant-a/" not in w.key) + tampered_manifest = parse_manifest(manifest_write.data) + tampered_manifest = dataclasses.replace( + tampered_manifest, + change=dataclasses.replace(tampered_manifest.change or ChangeRecord(), published_by=PUBLISHED_BY), + ) + tampered_writes = tuple( + dataclasses.replace(w, data=tampered_manifest.to_json()) if w.key == manifest_write.key else w + for w in plan.writes + ) + tampered_plan = dataclasses.replace(plan, writes=tampered_writes) + + with pytest.raises(PublishError, match="published_by"): + apply_publish_plan( + tampered_plan, + published_by=PUBLISHED_BY, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=set(), + write=lambda key, data: facade.put_bytes(key, data), + ) + + +# --- leaf-without-stem (--leaf --source) ----------------------------------------------------------- + + +def test_leaf_without_stem_reuses_shared_source_and_records_hierarchy_revision(store, facade): + staged_v1 = _stage(store, _raw(V1), "v1") + _, outcome1 = _derive_and_apply(facade, staged_v1, options={"extracted_at": "2026-01-01T00:00:00Z"}) + shared_source_key = next(k for k in outcome1.written if k.endswith(f"/{SOURCE_FILENAME}")) + + names1 = _names(_raw(V1)) + beam_guid = names1["a1-bm0"] + + staged_leaf = _stage(store, _raw(V2_LEAF), "leaf") + publisher = IfcAssetPublisher() + plan = publisher.derive( + None, + staged_leaf, + storage=facade, + collection="plant-a", + options={"leaf": beam_guid, "source": shared_source_key, "extracted_at": "2026-01-03T00:00:00Z"}, + ) + occupied = set() + outcome = apply_publish_plan( + plan, + published_by=PUBLISHED_BY, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=occupied, + write=lambda key, data: facade.put_bytes(key, data), + ) + + assert outcome.subjects == (beam_guid,) + assert outcome.revision > outcome1.revision # leaf revision > storey/site revision + + # the shared source blob was NOT re-uploaded: still exactly one `source.ifc` under the collection + source_keys = [k for k in facade.list_keys(f"{ASSET_PREFIX}/plant-a/") if k.endswith(f"/{SOURCE_FILENAME}")] + assert source_keys == [shared_source_key] + + provider = PublishedAssetProvider(store.reader(), provider_id=IFC_PROVIDER_ID) + leaf_manifest = provider.manifest("plant-a", beam_guid) + site_a_manifest = provider.manifest("plant-a", names1["SiteA"]) + assert leaf_manifest.hierarchy_revision == site_a_manifest.revision + + +# --- source_actor relay (Decision 6's IFC rule) ----------------------------------------------------- + + +def _with_second_owner_history(raw: bytes, *, beam_name: str) -> bytes: + """Give ONE product a second, distinct ``IfcOwnerHistory`` -- the file now has more than one, + which is the trigger Decision 6 names (independent of which product this particular test asks + about: "more than one owner history" is a file-wide fact, so EVERY product becomes eligible + for relay once it is true, not just this one).""" + f = ifcopenshell.file.from_string(raw.decode()) + person = f.create_entity("IfcPerson", Identification="bob", GivenName="Bob", FamilyName="Builder") + org = f.create_entity("IfcOrganization", Name="Contractors Inc") + pao = f.create_entity("IfcPersonAndOrganization", ThePerson=person, TheOrganization=org) + app = f.create_entity( + "IfcApplication", + ApplicationDeveloper=org, + Version="1.0", + ApplicationFullName="BobCAD", + ApplicationIdentifier="bobcad", + ) + oh2 = f.create_entity( + "IfcOwnerHistory", OwningUser=pao, OwningApplication=app, State="READWRITE", CreationDate=1790105999 + ) + beam = next(p for p in f.by_type("IfcBeam") if p.Name == beam_name) + beam.OwnerHistory = oh2 + return f.to_string().encode() + + +def test_source_actor_absent_when_the_file_has_one_shared_owner_history(store, facade): + """The plant-a corpus's ordinary case: adapy writes ONE owner history for the whole file and + IfcProject has none (Decision 4's own instant-refusal test already documents this), so + Decision 6's rule ("more than one owner history, OR differs from the project's") never fires + and every manifest's `change` carries no `source_actor`.""" + staged = _stage(store, _raw(V1)) + publisher = IfcAssetPublisher() + plan = publisher.derive( + None, staged, storage=facade, collection="plant-a", options={"extracted_at": "2026-01-01T00:00:00Z"} + ) + for write in plan.writes: + if write.key.endswith("/asset.json"): + manifest = parse_manifest(write.data) + assert manifest.change is None or manifest.change.source_actor is None + + +def test_source_actor_relayed_when_the_file_has_more_than_one_owner_history(store, facade): + modified = _with_second_owner_history(_raw(V1), beam_name="a1-bm0") + staged = _stage(store, modified) + publisher = IfcAssetPublisher() + names = _names(modified) + beam_guid = names["a1-bm0"] + + plan = publisher.derive( + None, + staged, + storage=facade, + collection="plant-a", + options={"leaf": beam_guid, "extracted_at": "2026-01-01T00:00:00Z"}, + ) + (manifest_write,) = [w for w in plan.writes if w.key.endswith("/asset.json")] + manifest = parse_manifest(manifest_write.data) + assert manifest.change is not None + assert manifest.change.source_actor == Actor(id="bob", display="Bob Builder", application="BobCAD 1.0") + assert manifest.change.published_by is None # still never set by the provider diff --git a/tests/core/assets/ifc/test_ifc_sweep.py b/tests/core/assets/ifc/test_ifc_sweep.py new file mode 100644 index 000000000..63a57ee8d --- /dev/null +++ b/tests/core/assets/ifc/test_ifc_sweep.py @@ -0,0 +1,197 @@ +"""``asset-sweep-ifc`` (Phase 4): sweeping a STAGED newer file against a published spine, driven +against the ``plant-a`` corpus's v1 -> v2 delta (one member modified, one removed, one added, one +storey untouched, one site untouched -- ``tests/core/assets/corpus/make_plant_a.py``). + +Pins the change-feed honesty Decision 4 demands: the three touched members (and every ancestor +above them, up to and including their site) read as evidence of a change; the untouched site gets +exactly one row that does NOT advance past what is published (`current`); a `--root`-scoped sweep +never touches the other site at all (`not-recorded`, by absence); and `no-feed` -- no database -- +is a third, distinct "cannot say" state that must never be answered as `current`. +""" + +from __future__ import annotations + +import pathlib + +import ifcopenshell +import pytest + +from ada.assets.ifc.publish import publish_ifc +from ada.assets.ifc.sweep import IFC_SOURCE_ID, IfcSweepError, run_ifc_sweep, sweep_ifc + +from .fake_store import FakeStore, FakeSyncStorageFacade + +CORPUS_DIR = pathlib.Path(__file__).parents[1] / "corpus" +V1 = CORPUS_DIR / "plant-a_v1.ifc" +V2 = CORPUS_DIR / "plant-a_v2.ifc" + +V1_STAGED = "assets/_staging/v1/source.ifc" +V2_STAGED = "assets/_staging/v2/source.ifc" + +V1_INSTANT = "2026-01-01T00:00:00Z" +V2_INSTANT = "2026-01-02T00:00:00Z" + + +def _raw(path: pathlib.Path) -> bytes: + return path.read_bytes() + + +def _names(raw: bytes) -> dict[str, str]: + f = ifcopenshell.file.from_string(raw.decode()) + return {p.Name: p.GlobalId for p in f.by_type("IfcProduct") if p.Name} + + +@pytest.fixture +def published_v1() -> tuple[FakeStore, dict[str, str]]: + """v1 published whole-file (2 sites). Returns the store and name -> guid for v1.""" + store = FakeStore() + store.put_bytes(V1_STAGED, _raw(V1)) + publish_ifc(store, collection="plant-a", staged_key=V1_STAGED, extracted_at=V1_INSTANT) + store.put_bytes(V2_STAGED, _raw(V2)) + return store, _names(_raw(V1)) + + +def _sweep(store: FakeStore, **kwargs) -> "object": + return sweep_ifc(store, collection="plant-a", staged_key=V2_STAGED, extracted_at=V2_INSTANT, **kwargs) + + +# --- the whole-file sweep: three touched members, their ancestors, and the untouched site --------- + + +def test_touched_members_carry_action_exactly_the_three(published_v1): + store, names1 = published_v1 + result = _sweep(store) + + verdicts = {r.node_ref: r.action for r in result.rows if r.action is not None} + assert len(verdicts) == 3 + assert verdicts[names1["aa-bm0"]] == "modified" + assert verdicts[names1["aa-bm1"]] == "deleted" + + # aa-bm6 is v2-only -- not in `names1` (a v1 name -> guid map) -- so it is found by exclusion: + # the one verdict that is neither the modified nor the deleted member. + added = [n for n, a in verdicts.items() if n not in (names1["aa-bm0"], names1["aa-bm1"])] + assert len(added) == 1 + assert verdicts[added[0]] == "added" + assert added[0] not in names1.values() # a genuinely new guid, not a v1 product + + # every touched-member row is stamped with the SWEEP's own instant, not the published one + for guid in verdicts: + row = next(r for r in result.rows if r.node_ref == guid) + assert row.last_changed_at == V2_INSTANT + + +def test_touched_ancestors_are_bumped_without_their_own_action(published_v1): + store, names1 = published_v1 + result = _sweep(store) + by_ref = {r.node_ref: r for r in result.rows} + + for name in ("SiteA", "StoreyA2", "AssemblyAA"): + guid = names1[name] + assert guid in by_ref, f"{name} should have a rolled-up row" + row = by_ref[guid] + assert row.action is None # only the touched product itself carries a verdict + assert row.last_changed_at == V2_INSTANT # pulled forward -- this root reads 'behind' + + +def test_untouched_storey_and_its_members_get_no_row_at_all(published_v1): + """StoreyA1 (and everything under it) is byte-identical between v1 and v2 -- a row's absence + IS 'no change' (Decision 7); this is the corpus's 'one storey untouched' claim, checked.""" + store, names1 = published_v1 + result = _sweep(store) + by_ref = {r.node_ref for r in result.rows} + + assert names1["StoreyA1"] not in by_ref + for i in range(6): + assert names1[f"a1-bm{i}"] not in by_ref + for i in range(4): + assert names1[f"a1-pl{i}"] not in by_ref + + +def test_untouched_site_reads_current_not_behind(published_v1): + """SiteB's whole subtree is byte-identical between v1 and v2. It gets exactly ONE row -- an + administrative 'still current' stamp that RE-AFFIRMS the published produced_at rather than + advancing to the sweep's own instant (Decision 7: no per-node NOCHANGE value is ever stored, + so this one row per covered-but-unchanged root is what lets a reader tell `current` apart from + `not-recorded`).""" + store, names1 = published_v1 + result = _sweep(store) + + site_b_rows = [r for r in result.rows if r.node_ref == names1["SiteB"]] + assert len(site_b_rows) == 1 + row = site_b_rows[0] + assert row.action is None + assert row.last_changed_at == V1_INSTANT # the PUBLISHED produced_at, not the sweep's instant + assert row.last_changed_at != V2_INSTANT + + # and nothing under SiteB shows up at all + by_ref = {r.node_ref for r in result.rows} + for name in ("StoreyB1", "StoreyB2"): + assert names1[name] not in by_ref + for i in range(6): + assert names1[f"b1-bm{i}"] not in by_ref + assert names1[f"b2-bm{i}"] not in by_ref + + +def test_covered_roots_are_both_declared_sites(published_v1): + store, names1 = published_v1 + result = _sweep(store) + assert set(result.covered_roots) == {names1["SiteA"], names1["SiteB"]} + assert result.source == IFC_SOURCE_ID == "ifc" + + +# --- --root-scoped sweep: the other site is left `not-recorded` (by absence) ---------------------- + + +def test_root_scoped_sweep_never_touches_the_other_site(published_v1): + store, names1 = published_v1 + result = _sweep(store, root=names1["SiteA"]) + + assert result.covered_roots == (names1["SiteA"],) + by_ref = {r.node_ref for r in result.rows} + assert names1["SiteB"] not in by_ref # not-recorded, by absence -- never 'current' + for name in ("StoreyB1", "StoreyB2"): + assert names1[name] not in by_ref + + +def test_unknown_root_is_refused(published_v1): + store, _names1 = published_v1 + with pytest.raises(IfcSweepError, match="no published manifest"): + _sweep(store, root="not-a-real-guid") + + +# --- no-feed is a third, distinct state -- never answered as `current` ---------------------------- + + +# --- the worker entry: sweep + hand rows to an injected recorder ---------------------------------- + + +def test_run_ifc_sweep_records_through_the_injected_recorder(published_v1): + store, names1 = published_v1 + facade = FakeSyncStorageFacade(store) + + recorded: list[tuple[str, list]] = [] + + def _record(source: str, rows: list) -> int: + recorded.append((source, rows)) + return len(rows) + + written = run_ifc_sweep( + storage=facade, + record=_record, + collection="plant-a", + staged_key=V2_STAGED, + extracted_at=V2_INSTANT, + ) + + assert written > 0 + assert len(recorded) == 1 + source, rows = recorded[0] + assert source == "ifc" + node_refs = {r["node_ref"] for r in rows} + assert names1["aa-bm0"] in node_refs + assert names1["SiteB"] in node_refs + # migration 030's contract: action is present only where the sweep has a verdict + modified_row = next(r for r in rows if r["node_ref"] == names1["aa-bm0"]) + assert modified_row["action"] == "modified" + site_b_row = next(r for r in rows if r["node_ref"] == names1["SiteB"]) + assert "action" not in site_b_row diff --git a/tests/core/assets/test_fixture_publish_sequence.py b/tests/core/assets/test_fixture_publish_sequence.py new file mode 100644 index 000000000..6da9fa844 --- /dev/null +++ b/tests/core/assets/test_fixture_publish_sequence.py @@ -0,0 +1,272 @@ +"""End to end: publish -> leaf-publish -> unpublish -> orphan, driven by the private ``fixture-lines`` +format against the in-memory ``FakeStore``. + +This is the STRONGER witness §Verification asks for (Decision 7's semantics-not-format gate): every +manifest this sequence writes -- through the whole-tree publish AND the leaf-without-stem publish -- +carries no ``change.source_actor``/``action``, no ``attributes`` artefact, and the only ``change`` +field ever present is what CORE adds (``published_by``/``published_via``). The mechanics (a shared +source blob referenced by ``key``, the refcount-checked unpublish, a leaf revision the collection +index does not know about) are proven with a source format core has no reader for -- the point of +the fixture provider existing at all (Decision 1's "test that pins it"). + +The leaf-without-stem publish (``_leaf_publish_plan``) is hand-built rather than routed through +``FixtureLinesPublisher.derive()`` -- that method always derives the WHOLE private-format tree (it +has no ``--leaf`` scope of its own, unlike the IFC provider). Building the plan directly with the +same public contract (``PublishPlan``/``PlannedWrite``/``AssetManifest``) still proves what this +suite needs: that ``apply_publish_plan``/``plan_unpublish`` support leaf-scoped publishing for ANY +provider, not just the IFC one, and that a private-format manifest survives the whole trip with no +adopted IFC carrier ever set. +""" + +from __future__ import annotations + +import json + +from tests.core.assets.fixture_provider.provider import FakeStore, FixtureLinesProvider +from tests.core.assets.fixture_provider.publisher import ( + FixtureLinesPublisher, + stage_fixture_source, +) + +from ada.assets.index import fold_listing +from ada.assets.keys import ASSET_PREFIX, asset_key, revision_from_instant +from ada.assets.manifest import MANIFEST_FILENAME, Actor, ArtefactEntry, AssetManifest +from ada.assets.publish import PlannedWrite, PublishPlan, apply_publish_plan +from ada.assets.unpublish import plan_unpublish + +COLLECTION = "fixture-seq" +CORE_ACTOR = Actor(id="local-dev", display="Local Dev") +LEAF_INSTANT = "2026-09-22T09:00:00Z" # after DEFAULT_INSTANT: a later, leaf-only publish + + +def _publish_v1(store: FakeStore) -> tuple[PublishPlan, dict]: + stage_fixture_source(store, staging_id="up1") + staged = {"source.jsonl": f"{ASSET_PREFIX}/_staging/up1/source.jsonl"} + plan = FixtureLinesPublisher().derive(None, staged, storage=store, collection=COLLECTION, options={}, dry_run=False) + outcome = apply_publish_plan( + plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=set(store.list_prefix(f"{ASSET_PREFIX}/{COLLECTION}/")), + write=store.put, + ) + return plan, outcome.to_dict() + + +def _leaf_publish_plan(*, source_key: str, subject: str, instant: str) -> PublishPlan: + """A leaf-without-stem publish, by hand: one manifest, no fresh source upload -- it names the + ALREADY-published collection source blob by absolute ``key``, exactly the shape + ``ada.assets.unpublish``'s refcount check exists for (Decision 3).""" + revision = revision_from_instant(instant) + manifest = AssetManifest( + provider="fixture-lines", + collection=COLLECTION, + subject=subject, + revision=revision, + node=subject, + produced_at=instant, + published_at=instant, + delivery="none", + artefacts=(ArtefactEntry(role="source", key=source_key, sha256="reused", size=0),), + counts={"nodes": 1}, + # No change=: a leaf-only publish through this format relays nothing either. + ) + write = PlannedWrite(asset_key(COLLECTION, subject, revision, MANIFEST_FILENAME), manifest.to_json()) + return PublishPlan(collection=COLLECTION, revision=revision, subjects=(subject,), writes=(write,)) + + +def _all_manifests(store: FakeStore) -> dict[str, dict]: + out = {} + for key in store.list_prefix(f"{ASSET_PREFIX}/{COLLECTION}/"): + if key.rsplit("/", 1)[-1] == MANIFEST_FILENAME: + out[key] = json.loads(store.blobs[key]) + return out + + +# -------------------------------------------------------------------------------------------- +# Publish: the semantics-not-format witness. +# -------------------------------------------------------------------------------------------- + + +def test_publish_writes_manifests_with_change_attributes_and_action_all_absent(): + store = FakeStore() + _plan, outcome = _publish_v1(store) + assert outcome["dry_run"] is False + assert len(outcome["subjects"]) == 6 # site, unit-1, unit-2, pump-a, pump-b, tank-c + + manifests = _all_manifests(store) + assert len(manifests) == 7 # 6 nodes + the collection-level manifest + for key, raw in manifests.items(): + # The ONLY `change` field the fixture format's own publish ever produces is what CORE + # stamped -- published_by/published_via. Nothing the provider relays (source_actor, + # action, source_instant) is ever present, because this format's derive() never sets them. + change = raw.get("change") + assert change is not None, f"{key}: core must stamp published_by even for a silent provider" + assert set(change) == {"published_by", "published_via"}, f"{key}: unexpected change field {change}" + assert change["published_by"]["id"] == "local-dev" + assert change["published_via"] == "user" + # No adopted-carrier artefact role this format never produces. + assert all(a["role"] != "attributes" for a in raw["artefacts"]) + # `action` only ever lives inside `change`; confirm it never appears at all. + assert "action" not in change + + +def test_publish_writes_the_collection_manifest_last(): + store = FakeStore() + _plan, outcome = _publish_v1(store) + collection_manifest_key = asset_key(COLLECTION, COLLECTION, outcome["revision"], MANIFEST_FILENAME) + assert outcome["written"][-1] == collection_manifest_key + + +# -------------------------------------------------------------------------------------------- +# Leaf-publish: a second, independent revision that shares v1's source blob by key. +# -------------------------------------------------------------------------------------------- + + +def test_leaf_publish_shares_the_whole_trees_source_blob_and_needs_no_upload_of_its_own(): + store = FakeStore() + _plan, outcome_v1 = _publish_v1(store) + source_key = asset_key(COLLECTION, COLLECTION, outcome_v1["revision"], "source.jsonl") + assert source_key in store.blobs # sanity: v1 really did upload it + + leaf_plan = _leaf_publish_plan(source_key=source_key, subject="pump-b", instant=LEAF_INSTANT) + leaf_outcome = apply_publish_plan( + leaf_plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=set(), + write=store.put, + ) + + leaf_key = asset_key(COLLECTION, "pump-b", leaf_plan.revision, MANIFEST_FILENAME) + assert leaf_outcome.written == (leaf_key,) # ONE write: no fresh source blob for a leaf publish + written_manifest = json.loads(store.blobs[leaf_key]) + assert written_manifest["artefacts"][0]["key"] == source_key + assert "file" not in written_manifest["artefacts"][0] # shared by key, not copied by filename + assert "change" in written_manifest and set(written_manifest["change"]) == {"published_by", "published_via"} + + +def test_leaf_publish_is_what_the_index_resolves_as_pump_bs_latest_revision(): + """The mechanism an "ahead" orphan (Decision 3) is decided from: a subject with a NEWER + manifest the collection-level spine was never re-derived against still resolves as latest.""" + store = FakeStore() + _plan, outcome_v1 = _publish_v1(store) + source_key = asset_key(COLLECTION, COLLECTION, outcome_v1["revision"], "source.jsonl") + leaf_plan = _leaf_publish_plan(source_key=source_key, subject="pump-b", instant=LEAF_INSTANT) + apply_publish_plan( + leaf_plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=set(), + write=store.put, + ) + + idx = fold_listing(store.list_prefix(f"{ASSET_PREFIX}/{COLLECTION}/")) + pump_b = idx.subject(COLLECTION, "pump-b") + assert [r.revision for r in pump_b.revisions] == [leaf_plan.revision, outcome_v1["revision"]] # newest first + + provider = FixtureLinesProvider(store.reader()) + resolved = provider.manifest(COLLECTION, "pump-b") # no revision= -> "latest" + assert resolved.revision == leaf_plan.revision + assert resolved.change is None or resolved.change.action is None # still nothing "action"-shaped + + +# -------------------------------------------------------------------------------------------- +# Unpublish: refused while ANY surviving manifest references the shared source, across revisions. +# -------------------------------------------------------------------------------------------- + + +def _v1_node_subjects() -> tuple[str, ...]: + return ("site", "unit-1", "unit-2", "pump-a", "pump-b", "tank-c") + + +def _unpublish(store: FakeStore, *, subject: str, revision: str): + keys = list(store.list_prefix(f"{ASSET_PREFIX}/{COLLECTION}/")) + manifest_bytes = { + k: store.blobs[k] + for k in keys + if k.rsplit("/", 1)[-1] == MANIFEST_FILENAME + and not k.startswith(f"{ASSET_PREFIX}/{COLLECTION}/{subject}/{revision}/") + } + plan = plan_unpublish( + collection=COLLECTION, subject=subject, revision=revision, collection_keys=keys, manifest_bytes=manifest_bytes + ) + if not plan.refused: + for key in plan.deleted: + del store.blobs[key] + return plan + + +def test_unpublish_of_the_collection_revision_is_refused_while_v1_node_manifests_still_reference_it(): + store = FakeStore() + _plan, outcome_v1 = _publish_v1(store) + + plan = _unpublish(store, subject=COLLECTION, revision=outcome_v1["revision"]) + assert plan.refused + assert plan.held_by # named holders, not a bare refusal + for holder in plan.held_by: + assert holder.endswith(f"@{outcome_v1['revision']}") + + +def test_unpublish_of_the_collection_revision_still_refused_after_v1_siblings_go_while_the_leaf_publish_survives(): + """The refcount check spans REVISIONS, not just same-revision siblings: a leaf published later + against the same shared blob keeps the collection revision alive even once every v1 sibling + that originally referenced it is gone.""" + store = FakeStore() + _plan, outcome_v1 = _publish_v1(store) + source_key = asset_key(COLLECTION, COLLECTION, outcome_v1["revision"], "source.jsonl") + leaf_plan = _leaf_publish_plan(source_key=source_key, subject="pump-b", instant=LEAF_INSTANT) + apply_publish_plan( + leaf_plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=set(), + write=store.put, + ) + + for subject in _v1_node_subjects(): + result = _unpublish(store, subject=subject, revision=outcome_v1["revision"]) + assert not result.refused, f"{subject}@v1 should unpublish cleanly: {result.reason}" + + # Every v1 sibling is gone, but the leaf publish at LEAF_INSTANT still names the same key. + plan = _unpublish(store, subject=COLLECTION, revision=outcome_v1["revision"]) + assert plan.refused + assert any(h.startswith("pump-b@") for h in plan.held_by) + + +def test_unpublish_of_the_collection_revision_succeeds_once_the_leaf_publish_is_also_gone(): + store = FakeStore() + _plan, outcome_v1 = _publish_v1(store) + source_key = asset_key(COLLECTION, COLLECTION, outcome_v1["revision"], "source.jsonl") + leaf_plan = _leaf_publish_plan(source_key=source_key, subject="pump-b", instant=LEAF_INSTANT) + apply_publish_plan( + leaf_plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=set(), + write=store.put, + ) + for subject in _v1_node_subjects(): + assert not _unpublish(store, subject=subject, revision=outcome_v1["revision"]).refused + assert not _unpublish(store, subject="pump-b", revision=leaf_plan.revision).refused + + plan = _unpublish(store, subject=COLLECTION, revision=outcome_v1["revision"]) + assert not plan.refused + assert plan.deleted[0] == asset_key(COLLECTION, COLLECTION, outcome_v1["revision"], MANIFEST_FILENAME) + assert source_key in plan.deleted + assert source_key not in store.blobs # actually gone, not merely planned + + # Nothing under this collection was ever asked to carry change/attributes/action beyond core's + # own stamp -- the property the whole sequence exists to pin, checked one last time at the end. + for raw in _all_manifests(store).values(): # only pump-b's own manifest, if anything, survives + assert all(a["role"] != "attributes" for a in raw["artefacts"]) diff --git a/tests/core/assets/test_publish_plan.py b/tests/core/assets/test_publish_plan.py new file mode 100644 index 000000000..4592325a4 --- /dev/null +++ b/tests/core/assets/test_publish_plan.py @@ -0,0 +1,284 @@ +"""``ada.assets.publish`` -- the pure layer: a provider PLANS, core WRITES. + +Every test here drives ``apply_publish_plan``/``stamp_publish`` directly against a hand-built +``PublishPlan``, never through a provider -- the provider-shaped end-to-end proof is +``test_fixture_publish_sequence.py``. Pinning the pure layer separately is what lets a refusal be +attributed to the CONTRACT (this module) rather than to one provider's particular mistake. + +The two rules this module exists to make structural rather than habitual (Decision 6's owner gate, +and "manifests written last so a half-written publish is invisible") are exactly what each test +name states. +""" + +from __future__ import annotations + +import json + +import pytest + +from ada.assets.keys import asset_key +from ada.assets.manifest import ( + MANIFEST_FILENAME, + Actor, + AssetManifest, + ChangeRecord, + parse_manifest, +) +from ada.assets.publish import ( + PlannedWrite, + PublishError, + PublishPlan, + apply_publish_plan, + stamp_publish, +) + +COLLECTION = "plant-a" +REVISION = "20260921T143001Z" +CORE_ACTOR = Actor(id="local-dev", display="Local Dev") + + +def _manifest_key(subject: str, revision: str = REVISION) -> str: + return asset_key(COLLECTION, subject, revision, MANIFEST_FILENAME) + + +def _manifest(*, subject: str, revision: str = REVISION, change: ChangeRecord | None = None, **over) -> AssetManifest: + base = dict( + provider="fixture-lines", + collection=COLLECTION, + subject=subject, + revision=revision, + node=subject, + produced_at="2026-09-21T14:29:00Z", + published_at="2026-09-21T14:30:01Z", + delivery="none", + change=change, + ) + base.update(over) + return AssetManifest(**base) + + +def _manifest_write( + *, subject: str, revision: str = REVISION, change: ChangeRecord | None = None, **over +) -> PlannedWrite: + m = _manifest(subject=subject, revision=revision, change=change, **over) + return PlannedWrite(key=_manifest_key(subject, revision), data=m.to_json()) + + +def _apply(plan: PublishPlan, *, dry_run: bool = False, replace_existing: bool = False, occupied=None, write=None): + store: dict[str, bytes] = {} + if write is None: + write = store.__setitem__ + outcome = apply_publish_plan( + plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=dry_run, + replace_existing=replace_existing, + occupied=occupied, + write=write, + ) + return outcome, store + + +# -------------------------------------------------------------------------------------------- +# The owner gate (Decision 6): only core may set published_by / published_via. +# -------------------------------------------------------------------------------------------- + + +def test_a_provider_setting_published_by_is_refused_by_name(): + plan = PublishPlan( + collection=COLLECTION, + revision=REVISION, + subjects=("n1",), + writes=(_manifest_write(subject="n1", change=ChangeRecord(published_by=Actor(id="not-core"))),), + ) + with pytest.raises(PublishError, match="published_by"): + _apply(plan) + + +def test_a_provider_setting_published_via_is_refused_by_name(): + plan = PublishPlan( + collection=COLLECTION, + revision=REVISION, + subjects=("n1",), + writes=(_manifest_write(subject="n1", change=ChangeRecord(published_via="service")),), + ) + with pytest.raises(PublishError, match="published_via"): + _apply(plan) + + +def test_core_stamp_keeps_a_relayed_source_actor_action_and_instant_while_adding_published_by_via(): + """The provider's own claim about its SOURCE (never about who called core) survives the stamp + unchanged; core only ADDS published_by/published_via, it never touches source_actor/action/ + source_instant -- the two trust levels stay on their own fields (Decision 6).""" + relayed = ChangeRecord( + source_actor=Actor(id="upstream-tool", application="Vendor Exporter 9"), + action="modified", + source_instant="2026-09-18T09:00:00Z", + ) + manifest = _manifest(subject="n1", change=relayed) + stamped = stamp_publish(manifest, published_by=CORE_ACTOR, published_via="user") + + assert stamped.change.published_by == CORE_ACTOR + assert stamped.change.published_via == "user" + assert stamped.change.source_actor == relayed.source_actor + assert stamped.change.action == "modified" + assert stamped.change.source_instant == "2026-09-18T09:00:00Z" + + # Round-trips through JSON identically -- the stamp is not a display-only annotation. + reparsed = parse_manifest(stamped.to_json()) + assert reparsed.change.published_by == CORE_ACTOR + assert reparsed.change.source_actor == relayed.source_actor + + +# -------------------------------------------------------------------------------------------- +# Manifests-last ordering (Decision 2a). +# -------------------------------------------------------------------------------------------- + + +def test_an_artefact_written_after_its_own_manifest_is_refused_naming_the_key(): + late_artefact_key = asset_key(COLLECTION, "n1", REVISION, "model.glb") + plan = PublishPlan( + collection=COLLECTION, + revision=REVISION, + subjects=("n1",), + writes=( + _manifest_write(subject="n1"), # manifest first -- the mistake this refuses + PlannedWrite(key=late_artefact_key, data=b"glb-bytes"), + ), + ) + with pytest.raises(PublishError, match=late_artefact_key): + _apply(plan) + + +def test_artefacts_before_their_own_manifest_is_the_accepted_order(): + """The mirror image of the refusal above: the same subject-revision, artefact then manifest, + is exactly what a publish is supposed to look like and must not raise.""" + artefact_key = asset_key(COLLECTION, "n1", REVISION, "model.glb") + plan = PublishPlan( + collection=COLLECTION, + revision=REVISION, + subjects=("n1",), + writes=( + PlannedWrite(key=artefact_key, data=b"glb-bytes"), + _manifest_write(subject="n1"), + ), + ) + outcome, store = _apply(plan) + assert outcome.written[-1] == _manifest_key("n1") + assert store[artefact_key] == b"glb-bytes" + + +# -------------------------------------------------------------------------------------------- +# Occupancy: a revision is immutable unless the caller says replace. +# -------------------------------------------------------------------------------------------- + + +def test_an_occupied_key_is_refused_without_replace_existing(): + key = _manifest_key("n1") + plan = PublishPlan( + collection=COLLECTION, revision=REVISION, subjects=("n1",), writes=(_manifest_write(subject="n1"),) + ) + with pytest.raises(PublishError, match="replace"): + _apply(plan, occupied={key}) + + +def test_replace_existing_true_allows_and_reports_the_clash(): + key = _manifest_key("n1") + plan = PublishPlan( + collection=COLLECTION, revision=REVISION, subjects=("n1",), writes=(_manifest_write(subject="n1"),) + ) + outcome, store = _apply(plan, occupied={key}, replace_existing=True) + assert outcome.replaced == (key,) + assert key in store # actually rewritten, not merely permitted + + +# -------------------------------------------------------------------------------------------- +# dry_run: plans, never writes. +# -------------------------------------------------------------------------------------------- + + +def test_dry_run_writes_nothing_but_reports_the_same_written_list_and_echoes_dry_run_true(): + artefact_key = asset_key(COLLECTION, "n1", REVISION, "model.glb") + plan = PublishPlan( + collection=COLLECTION, + revision=REVISION, + subjects=("n1",), + writes=(PlannedWrite(key=artefact_key, data=b"glb-bytes"), _manifest_write(subject="n1")), + ) + + def _boom(key, data): # a real publish would call this; a dry run must never reach it + raise AssertionError(f"dry_run must not write {key}") + + outcome = apply_publish_plan( + plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=True, + replace_existing=False, + occupied=None, + write=_boom, + ) + assert outcome.dry_run is True + assert outcome.written == (artefact_key, _manifest_key("n1")) + assert outcome.to_dict()["dry_run"] is True + + +def test_a_real_publish_with_no_write_callable_is_refused(): + plan = PublishPlan( + collection=COLLECTION, revision=REVISION, subjects=("n1",), writes=(_manifest_write(subject="n1"),) + ) + with pytest.raises(PublishError, match="write callable"): + apply_publish_plan( + plan, + published_by=CORE_ACTOR, + published_via="user", + dry_run=False, + replace_existing=False, + occupied=None, + write=None, + ) + + +def test_an_empty_plan_is_refused_as_not_a_publish(): + plan = PublishPlan(collection=COLLECTION, revision=REVISION, subjects=(), writes=()) + with pytest.raises(PublishError, match="no writes"): + _apply(plan) + + +# -------------------------------------------------------------------------------------------- +# A manifest core cannot parse. +# -------------------------------------------------------------------------------------------- + + +def test_a_manifest_core_cannot_parse_is_refused_naming_the_key(): + bad_key = _manifest_key("n1") + plan = PublishPlan( + collection=COLLECTION, + revision=REVISION, + subjects=("n1",), + writes=(PlannedWrite(key=bad_key, data=b"not json at all"),), + ) + with pytest.raises(PublishError, match=bad_key): + _apply(plan) + + +# -------------------------------------------------------------------------------------------- +# Provider artefacts pass through untouched -- core only ever rewrites asset.json. +# -------------------------------------------------------------------------------------------- + + +def test_provider_artefacts_pass_through_byte_identical(): + artefact_key = asset_key(COLLECTION, "n1", REVISION, "model.glb") + opaque_bytes = b"\x00glTF-not-really-but-opaque-to-core\xff" + plan = PublishPlan( + collection=COLLECTION, + revision=REVISION, + subjects=("n1",), + writes=(PlannedWrite(key=artefact_key, data=opaque_bytes), _manifest_write(subject="n1")), + ) + outcome, store = _apply(plan) + assert store[artefact_key] == opaque_bytes # untouched, not even re-encoded + # The manifest, by contrast, WAS rewritten -- it now carries core's stamp. + written_manifest = json.loads(store[_manifest_key("n1")]) + assert written_manifest["change"]["published_by"]["id"] == "local-dev" diff --git a/tests/core/assets/test_unpublish_plan.py b/tests/core/assets/test_unpublish_plan.py new file mode 100644 index 000000000..771270d8d --- /dev/null +++ b/tests/core/assets/test_unpublish_plan.py @@ -0,0 +1,177 @@ +"""``ada.assets.unpublish`` -- the refcount check core owes its publishers. + +``plan_unpublish`` is pure (Decision 3, "deletion / orphan semantics"): the route does the listing +and the manifest reads, this module only decides. Every test builds ``collection_keys`` and +``manifest_bytes`` by hand -- no store, no provider -- so a failure here is unambiguously the +CONTRACT's, never a provider's construction of it (that end-to-end proof is +``test_fixture_publish_sequence.py``). +""" + +from __future__ import annotations + +from ada.assets.keys import asset_key +from ada.assets.manifest import MANIFEST_FILENAME, ArtefactEntry, AssetManifest +from ada.assets.unpublish import plan_unpublish + +COLLECTION = "plant-a" +STOREY_SUBJECT = "storey-1" +STOREY_REVISION = "20260921T143001Z" # v1: the revision that ORIGINALLY uploaded the shared source +LEAF_SUBJECT = "member-42" +LEAF_REVISION = "20260922T090000Z" # v2: a later, leaf-only publish that reuses v1's source blob + + +def _key(subject: str, revision: str, filename: str) -> str: + return asset_key(COLLECTION, subject, revision, filename) + + +def _manifest_bytes( + *, subject: str, revision: str, node: str | None = None, artefacts: tuple[ArtefactEntry, ...] = () +) -> bytes: + return AssetManifest( + provider="ifc", + collection=COLLECTION, + subject=subject, + revision=revision, + node=node if node is not None else subject, + produced_at="2026-09-21T14:29:00Z", + published_at="2026-09-21T14:30:01Z", + delivery="none", + artefacts=artefacts, + ).to_json() + + +def _storey_keys() -> list[str]: + """The storey's own revision: a manifest, a hierarchy, and the ACTUAL uploaded source blob -- + the "leaf without stem" shape (Decision 3), where a later leaf publish points at this source by + absolute `key` instead of uploading its own copy.""" + return [ + _key(STOREY_SUBJECT, STOREY_REVISION, MANIFEST_FILENAME), + _key(STOREY_SUBJECT, STOREY_REVISION, "hierarchy.json"), + _key(STOREY_SUBJECT, STOREY_REVISION, "source.ifc"), + ] + + +def _storey_source_key() -> str: + return _key(STOREY_SUBJECT, STOREY_REVISION, "source.ifc") + + +def _leaf_manifest_bytes(source_key: str) -> bytes: + """A leaf-without-stem manifest: it names the storey's source blob by absolute KEY, which is + the one thing that makes deleting the storey's revision unsafe while this survives.""" + return _manifest_bytes( + subject=LEAF_SUBJECT, + revision=LEAF_REVISION, + artefacts=(ArtefactEntry(role="source", key=source_key, sha256="s" * 64, size=100),), + ) + + +# -------------------------------------------------------------------------------------------- +# The refcount rule itself. +# -------------------------------------------------------------------------------------------- + + +def test_a_surviving_leaf_manifest_naming_the_shared_source_by_key_blocks_the_storey_delete(): + source_key = _storey_source_key() + leaf_manifest = _leaf_manifest_bytes(source_key) + collection_keys = _storey_keys() + [_key(LEAF_SUBJECT, LEAF_REVISION, MANIFEST_FILENAME)] + + plan = plan_unpublish( + collection=COLLECTION, + subject=STOREY_SUBJECT, + revision=STOREY_REVISION, + collection_keys=collection_keys, + # SURVIVING manifests only -- the storey's own manifest is excluded, as the docstring + # requires: the set being removed cannot hold itself alive. + manifest_bytes={_key(LEAF_SUBJECT, LEAF_REVISION, MANIFEST_FILENAME): leaf_manifest}, + ) + + assert plan.refused + assert plan.deleted == () + assert plan.kept # the storey's keys are named as KEPT, not silently dropped + holder = f"{LEAF_SUBJECT}@{LEAF_REVISION}" + assert holder in plan.held_by + assert holder in plan.reason + + +def test_the_storey_delete_succeeds_once_the_leaf_manifest_is_gone(): + collection_keys = _storey_keys() # the leaf has already been unpublished -- nothing else here + + plan = plan_unpublish( + collection=COLLECTION, + subject=STOREY_SUBJECT, + revision=STOREY_REVISION, + collection_keys=collection_keys, + manifest_bytes={}, # no surviving manifest references the storey's blobs any more + ) + + assert not plan.refused + assert set(plan.deleted) == set(_storey_keys()) + assert plan.kept == () + assert plan.held_by == () + + +# -------------------------------------------------------------------------------------------- +# Delete order: manifest first, so a partial delete reads as unpublished, never as +# published-and-incomplete (the mirror image of "manifests written last"). +# -------------------------------------------------------------------------------------------- + + +def test_delete_order_puts_the_manifest_first(): + plan = plan_unpublish( + collection=COLLECTION, + subject=STOREY_SUBJECT, + revision=STOREY_REVISION, + collection_keys=_storey_keys(), + manifest_bytes={}, + ) + assert not plan.refused + assert plan.deleted[0] == _key(STOREY_SUBJECT, STOREY_REVISION, MANIFEST_FILENAME) + assert set(plan.deleted[1:]) == { + _key(STOREY_SUBJECT, STOREY_REVISION, "hierarchy.json"), + _key(STOREY_SUBJECT, STOREY_REVISION, "source.ifc"), + } + + +# -------------------------------------------------------------------------------------------- +# An unreadable manifest is treated as holding everything it might have named -- the cautious +# direction, because a delete that should have been fine is recoverable and a dangling manifest +# is not. +# -------------------------------------------------------------------------------------------- + + +def test_an_unreadable_manifest_refuses_cautiously(): + bad_key = _key("other-subject", "20260920T000000Z", MANIFEST_FILENAME) + + plan = plan_unpublish( + collection=COLLECTION, + subject=STOREY_SUBJECT, + revision=STOREY_REVISION, + collection_keys=_storey_keys(), + manifest_bytes={bad_key: b"not a manifest, not even json"}, + ) + + assert plan.refused + assert plan.deleted == () + assert bad_key in plan.unreadable + assert "could not be read" in plan.reason + + +# -------------------------------------------------------------------------------------------- +# A subject-revision that was never published is reported as absent, not as an empty success -- +# the caller must be able to tell "nothing to do" from "there was nothing here to begin with". +# -------------------------------------------------------------------------------------------- + + +def test_a_subject_revision_that_does_not_exist_is_reported_as_absent_not_as_a_success(): + plan = plan_unpublish( + collection=COLLECTION, + subject="never-published", + revision=STOREY_REVISION, + collection_keys=_storey_keys(), # some OTHER subject's keys are present in the collection + manifest_bytes={}, + ) + assert plan.refused + assert plan.reason is not None and "nothing published" in plan.reason + assert plan.deleted == () + assert plan.kept == () # distinguishes "absent" from "held" -- both refuse, for different reasons + assert plan.held_by == ()