Wassam, I’ll take this in order. The short version: your pipeline is supported, nothing is broken on your project, and the blank 3D view comes from one field in the payload you build.
The nulls you found are correct, not a fault. Checking <v-new> on our side:
schemaVersion: 3, format: bundle, hasDataset: true
referencedObject: bundle.<project>.<model>.<v-new>
hasPackfile: false, packfileSizeBytes: null
objectKey and packfileSize are packfile-era fields, and a version that was born as a bundle never has them. The same goes for the 404 on /objects/... and the null from project.object(id:). The bundle.<project>.<model>.<version> string is a deliberately non-hex sentinel, designed to always 404 on the object endpoints so it can’t collide with a content hash. You read all of that correctly. It just isn’t what’s failing.
What’s actually blanking your 3D view. The visual never queries objectKey. The “This version predates Speckle 4.0” banner is driven by exactly one thing: the pipeline field in the modelInfo blob, which you build. The banner appears when any model in the blob has pipeline: "legacy".
That explains your 18 Sep cliff. Compare the two versions on that model:
| Version |
Date |
objectKey |
schemaVersion |
<v-old> |
16 Sep |
present (1.35 GB packfile) |
3 |
<v-new> |
22 Sep |
null |
3 |
<v-old> is a migrated version: it became a bundle but kept its packfile fields. <v-new> was born a bundle. Both load equally well. So if your Fabric code derives pipeline from something like objectKey != null (the natural reading of “does this have a packfile”), it silently flips to "legacy" at exactly the point the model started being published from a 2026.9 connector.
The fix. Our own Power Query connector hardcodes pipeline = "artifact", and it decides availability purely by whether the artifacts endpoint returns files. It never emits "legacy" at all. That value only comes from the old 3.x Speckle.GetByUrl path. So:
pipeline = "artifact" if GET /api/v2/projects/{p}/models/{m}/versions/{v}/artifacts returns a non-empty files array. Equivalently, gate on schemaVersion == 3. Never derive it from objectKey or packfileSize.
You already call that endpoint successfully for this version, and it’s the same endpoint the visual itself uses to load geometry. So a one-line change should bring your 3D view back.
The bundle format isn’t internal. It’s public, specified and versioned: github.com/specklesystems/speckle-bundle-spec. The spec is executable DuckDB SQL, with a generated human reference (docs/reference.md), a bundle validator, a semver’d schema_version with a documented bump policy, and generated constants for Python, TypeScript, C# and C++. Read it rather than reverse-engineering it. If your reverse-engineered layout disagrees with it anywhere, the spec wins.
Your two unknowns:
eav.model.parquet is in the spec as optional. It holds model- or document-scoped attributes: the EAV rows that have no object (Revit/Civil 3D document settings, project info, reference point).
path_stats is {base}.envelope.path_stats.parquet. It’s an optional sidecar the server builds, holding exact property-path statistics so consumers can skip a scan. It’s fine to ignore it.
Bulk data into a warehouse. A 2026.9 version has no object graph to traverse, so any advice about specklepy graph traversal is a dead end. What you want is specklepy.bundle, which is first-party and does exactly what you hand-rolled:
from specklepy.bundle.download import download_bundle
paths = download_bundle(account, project_id, model_id, version_id,
dest_dir, include_geometry=False)
It makes the same /api/v2/.../artifacts call and uses the same presigned URLs and the same parquet parts. On top of that, specklepy.bundle.spec gives you the vendored schema constants, so your column references track the spec instead of drifting. It installs as specklepy[bundle] (it needs pyarrow) and works fine in a Fabric Spark notebook. Your architecture was right. You just reimplemented a supported client.
Object Keys (your Q4). The intended binding is the dense Int64 key:
Object Key = ordinal * 2^32 + object_index
ordinal is the model’s position in your federated modelInfo list. object_index is the bundle’s integer object index, which is your eav.objects row key. Binding the Revit applicationId isn’t wrong either. It’s a supported compatibility binding, resolved through the per-version eav.objects.parquet dictionary. It just costs you a download of several tens of MB and a few hundred MB of heap on a model your size, and the Object Key binding skips all of that. It’s worth switching for 186k elements.
One trap: the identity mode is decided from Power BI column metadata first, and a numeric-looking text column is classified as an Application ID. Make sure the column lands as a genuine numeric type, or name it exactly Object Key.
The token (your Q5). Embedding a token in the blob is the architecture, not a workaround. Our connector does the same. What matters is which token. Don’t put a full PAT in there. Exchange it for a scoped embed token:
mutation CreateEmbedShareToken($input: CreateEmbedShareTokenInput!) {
sharingMutations { createEmbedShareToken(input: $input) { token } }
}
Call it with { projectId, resourceIdString }. That’s exactly what the connector mints, and it fails hard rather than falling back to the more powerful token.