TeamCraft documentation
TeamCraft is an open-source product lifecycle management (PLM) system written in pure Rust: one binary that serves a web interface, a JSON REST API and a command line, on top of PostgreSQL. It manages the engineering data of a team – items, revisions, CAD files and their versions, bills of materials, change orders – with the check-out / check-in discipline of an engineering vault.
Work in progress: there is no official release yet. TeamCraft is under active development (version 0.x). Features, the REST API, the configuration and the database schema can still change. You are welcome to try it out and test it, and please report what you find on GitHub. It is provided “as is”, without warranty of any kind (MPL-2.0, sections 6 and 7). Don’t use it for production data yet, and keep backups.
| Start here | |
|---|---|
| Getting started | build, run, first project group and item |
| Vault, check-out and check-in | the everyday workflow |
| Supported file formats | 160+ formats (200+ extensions) of CAD, BIM, DCC, mesh and point-cloud programs |
| Configuration | every setting, all optional for a local test |
| REST API | automate TeamCraft with personal access tokens |
| Rust API | the crate documentation generated by rustdoc |
The main pieces
| Module | Role |
|---|---|
domain | pure rules: roles, lifecycle states and transitions, revision letters, validation |
services | business logic – the only code that writes to the database (permissions, row locks, audit log) |
web | server-rendered HTML interface (cookie sessions, CSRF protection, strict CSP) |
api | JSON REST API under /api/v1 (bearer tokens) |
vault | content-addressed file storage (objects/ab/cd/<sha256>) |
cadformat, cadrefs | file formats of the CAD programs, references inside CAD files |
mesh, preview | pure-Rust readers for the 3D preview (meshes, point clouds, STEP via truck; simmesh: VTK, VTU, FRD results) |
Platforms
TeamCraft builds with cargo alone on Linux, macOS and Windows; the only
service it needs is PostgreSQL. Step-by-step guides:
Linux ·
macOS ·
Windows.
TeamCraft is free software under the Mozilla Public License 2.0. Contributions are welcome; see CONTRIBUTING.md. Report security problems privately, as described in SECURITY.md.
Getting started
Try it on your laptop
Download the desktop app with local server from the
Releases page (Windows x64,
macOS Apple silicon, Linux x86_64), start it and click Try TeamCraft on
this computer: the app starts a private server with its own database on
your computer and opens the browser, where you create the administrator. With
PostgreSQL installed, teamcraft local does the same on the command line.
Details: Try it on your computer.
Install on Linux with Docker
curl -fsSL https://raw.githubusercontent.com/rudalabs/TeamCraft/main/deploy/install.sh | bash
TeamCraft and PostgreSQL run as containers; the script offers to create the administrator at the end. See Install with Docker.
Build and run
You need Rust (stable, via rustup) and PostgreSQL 16 or newer.
The repository’s docker-compose.yml starts a development database that
needs no password:
git clone https://github.com/rudalabs/TeamCraft.git && cd TeamCraft
docker compose up -d db
cargo run --release
TeamCraft applies its database migrations on start and listens on http://localhost:8080. The platform guides cover native PostgreSQL installations and the desktop app: Linux · macOS · Windows.
First start
- Open http://localhost:8080. A server without accounts that listens on localhost offers Create the administrator – pick a username and a password (10+ characters). The form disappears afterwards.
- Projects → New project group: every item belongs to one. The creator manages the group and adds the team members.
- Admin → Users (admins): create accounts for your team, or connect single sign-on (see Users, roles and SSO).
- Items → New item: choose the CAD file (s) first – the model, its
drawing and a STEP export for the 3D preview. Name and type are taken from
the first file (an assembly for
.iam,.asm,.sldasm, …). The item is created together with its files; its first revision A is in work and checked out to you. - Check in, then release it with a change order.
Appearance: TeamCraft follows the light or dark appearance of your operating system. The menu under your name (top right) and Account → Appearance switch between System, Light and Dark; choose Dark to keep the dark look of earlier versions. The distribution skins (Arch, CachyOS, EndeavourOS, KDE, Kubuntu, openSUSE, Ubuntu) are dark. The choice is personal and also styles the login page in that browser.
Preferred CAD program: if your parts exist in several CAD formats, Account → Preferred CAD program shows the files of your program (and all neutral formats such as STEP) first and collapses the native files of other programs – see Files. The setting is personal and applies in the browser and the desktop app.
Keyboard: / or Ctrl+K (⌘K) jumps to the search field. The single-key
/ can be switched off under Account → Appearance → Keyboard (per browser).
Keyboard: / jumps to the search field (on the vault explorer: to its
filter); in menus, Esc closes and the arrow keys move.
On a server that is reachable from other machines, create the first administrator from the command line instead:
teamcraft user create --username admin --role admin # prompts for the password
Command line
| Command | Purpose |
|---|---|
teamcraft / teamcraft serve | run the web server (applies migrations) |
teamcraft migrate | apply migrations and exit |
teamcraft user create --username U --role viewer|editor|admin [--project CODE] | local account |
teamcraft user set-password --username U | reset a password |
teamcraft user list | all accounts |
teamcraft vault check [--verify-hashes] | every stored file present (and intact)? |
teamcraft rescan-references [--format id] | read stored CAD file references again |
teamcraft vault signatures [--convert] | shape signatures for the similar part search |
teamcraft healthcheck | exit 0 when the server answers on /healthz |
Try it on your computer
You can try TeamCraft on your own laptop without a server, Docker or a database administrator. Two ways lead to the same result: a private PostgreSQL database and the TeamCraft server, both running only on your computer, with all data in one folder of your user account.
This is for trying TeamCraft out. It is not a way to run TeamCraft for a team: nobody else can reach the server, there are no backups and no TLS. For a team, use the Docker install.
With the desktop app (no set-up)
-
Download the desktop app with local server from the Releases page:
System File Windows 10 / 11 (x64) TeamCraft-with-local-server_<version>_x64-setup.exemacOS (Apple silicon) TeamCraft-with-local-server_<version>_aarch64.dmgLinux (x86_64) TeamCraft-with-local-server_<version>_amd64.AppImageThe installers are not code-signed yet: on Windows, SmartScreen shows More info → Run anyway; on macOS, open the app the first time with right-click → Open; on Linux, make the AppImage executable (
chmod +x). -
Start TeamCraft and click Try TeamCraft on this computer. The first start creates the database and takes a few seconds, at most about a minute on a slow disk.
-
Your browser opens. Create the administrator – pick a username and a password (10+ characters) – and confirm the sign-in of the app. You are now signed in, in the browser and in the app.
-
Continue with Getting started: create a project group, then an item with a STEP file, and open it in 3D.
The app starts the local server again each time you open it while it is the configured server, and stops it when you quit the app. change next to the server name on the sign-in screen (or File → Sign Out) connects the same app to any other TeamCraft server; the local data stays where it is.
The installer with local server is the regular desktop app plus two
things: the server program (teamcraft-server) and PostgreSQL’s programs
(postgresql/, with its licence in COPYRIGHT.txt). It installs over the
regular desktop app. The regular installers stay small and do not show the
button.
On the command line
With PostgreSQL 16 or newer installed (its server programs initdb,
pg_ctl and postgres, not only the client), the server archive from the
Releases page does the same:
teamcraft local
Open http://127.0.0.1:8080 and create the administrator. Ctrl+C stops
the server and PostgreSQL.
| Option | Default | Meaning |
|---|---|---|
--port <port> | 8080 | port of the web server on 127.0.0.1 |
--data-dir <dir> | per-user data folder (below) | database, vault and logs |
--pg-bin <dir> | searched | folder with initdb, pg_ctl and postgres |
--exit-with-stdin | off | stop when standard input closes (the desktop app keeps a pipe open while it runs) |
Without --pg-bin, TeamCraft looks for PostgreSQL’s programs in this order:
postgresql/bin next to the teamcraft program (the desktop download
with local server), PATH, /usr/lib/postgresql/<version>/bin (Debian,
Ubuntu), Homebrew (postgresql@<version>), Postgres.app, and
C:\Program Files\PostgreSQL\<version>\bin on Windows – the newest version
first. PostgreSQL refuses to run as root (sudo): start teamcraft local
as your normal user.
Where the data is
The desktop app and teamcraft local use the same folder, so both see the
same data:
| System | Folder |
|---|---|
| Linux | ~/.local/share/teamcraft/local-server (or $XDG_DATA_HOME/teamcraft/local-server) |
| macOS | ~/Library/Application Support/TeamCraft/local-server |
| Windows | %LOCALAPPDATA%\TeamCraft\local-server |
Inside it:
postgres/ the database cluster
postgres.log PostgreSQL's log
pg_ctl.log output of the last `pg_ctl start` (why PostgreSQL did not start)
db-password the generated database password (readable by you only)
vault/ the stored files
The desktop app writes the server’s own log to local-server.log in its
settings folder. To start over, quit the app (or stop teamcraft local) and
delete the folder. Nothing in it is backed up: for data you want to keep, use
the Docker install with its backups.
How it is protected
- PostgreSQL and the server listen only on
127.0.0.1; other computers cannot connect. PostgreSQL gets a free port at every start and no Unix socket. - The database password is random (32 characters), stored in
db-password(mode0600on Linux and macOS), and checked withscram-sha-256, so other accounts on the same computer cannot use the database. - The page that creates the administrator is only offered while no account exists and the server listens on this computer – the same rule as for every other first start.
- A second
teamcraft localon the same folder refuses to start instead of stopping the running database.
When something goes wrong
- The button is missing – this is the regular desktop app; download the one with local server.
- “The local server stopped” / “did not start” – the message shows the
end of the server’s log.
postgres.login the data folder has PostgreSQL’s side. The app replaces a port that another program took with a free one;teamcraft localnames the port in use – choose another with--port. - macOS blocks the server or PostgreSQL – the app is not notarised yet.
If right-click → Open on the app was not enough, remove the quarantine
flag once:
xattr -dr com.apple.quarantine /Applications/TeamCraft.app. - “TeamCraft already runs on …” – another
teamcraft local(or the app in another window) serves the same folder; use that one, or stop it first.
Concepts
Items and revisions
An item is a part, assembly, document, software component, simulation
or as-built record (one physical unit or lot, see As-built
records) with a unique number – generated from the numbering
scheme of its project group (built in: PRT-000042, ASM-…, DOC-…,
SW-…, SIM-…, AB-…) or typed in by hand. Its state of development
is kept in revisions A, B, …, Y, AA, … (I, O, Q, S, X and Z are
skipped, as in ASME Y14.35). A revision carries the attributes (name,
description, material, mass, unit cost), the files and the bill of materials.
Next to the number on the revision page, Copy number puts the item number
on the clipboard (the item list has a small copy button per row), and Label opens a printable label with a QR code.
The code holds the short link
<public URL>/i/<number>, which opens the item’s latest revision after
sign-in – for parts, fixtures and prototypes on the shelf. The QR code is
also available as SVG (/items/{id}/qr.svg) for label printers.
Lifecycle
WIP ──submit──▶ In Review ──all reviewers approve──▶ Released ──▶ Obsolete
▲ │
└──reject/withdraw┘
| Transition | How | Who |
|---|---|---|
| WIP → In Review | submit a change order containing the revision | the change’s author (CAD editor of the item’s project group) or an admin |
| In Review → Released | automatically when every reviewer approved – all revisions of the change together | the assigned reviewers, never the author or someone who changed the data |
| In Review → WIP | a reviewer rejects with a reason, or the author withdraws | reviewers; author or admin |
| Released → Obsolete | Mark obsolete (final), or an obsolete / supersede line of a change order, applied when it is released | CAD editors of the project group, admins – or, when the group requires a change order, the reviewers of the change |
Only WIP revisions change, and only while they are checked out. Released data never changes – further work happens in a new revision (Revise).
Project groups and roles
| Role | May | | Viewer | read internal project groups and the restricted groups they belong to, raise and discuss change requests | | Viewer | read everything, raise and discuss change requests | | CAD editor | create and change items of the project groups they belong to, create project groups, review changes, triage and decide change requests on their groups | | Admin | everything, plus users, all project groups and lock overrides |
Every item belongs to a project group – a product or product line, a machine, a customer project, a building, a vehicle or a rover. CAD editors start their own groups and become their managers: managers edit the group and add or remove members. A group can also be bound to a group of the identity provider (administrators only); its members are then synchronised at every single sign-on.
A group is internal (everybody reads it) or restricted: then only its members – viewers included – and administrators see the group, its items, files and the changes about them; for everybody else it does not exist. Links from other groups show a Restricted item placeholder. Managers switch this on the group’s page; see Read permissions and restricted groups.
Item numbers
Each project group has a numbering scheme for the numbers TeamCraft generates when the number field is left empty – in the web UI, the REST API, the BOM import and Save as. Managers set it on the group’s page, where Preview shows the next number per item type before anything is saved.
| Placeholder | Becomes |
|---|---|
{PROJECT} | the project code, e.g. ROVER |
{TYPE} | PRT, ASM, DOC, SW, SIM or AB |
{YYYY} | the year of creation (UTC); {YY} its last two digits, {MM} the month |
{SEQ:n} | the sequence, zero-padded to n digits (1–12); exactly once |
Everything else is literal text from A–Z 0–9 . _ -. The built-in scheme is
{TYPE}-{SEQ:6}; {PROJECT}-{TYPE}-{YYYY}-{SEQ:4} gives ROVER-PRT-2026-0007.
Every distinct expansion counts on its own: a scheme with {YYYY} starts at 1
each year, one without {TYPE} shares the sequence between all item types,
and groups with the built-in scheme share its counters. Changing the padding
width keeps counting. When the next number is already taken (typed in by
hand or imported), the counter continues after the highest existing number
of the same pattern – so a new scheme can take over an existing numbering
convention – and existing items never change their numbers.
Attributes and classification
Besides name, description, material, mass and unit cost, a revision can
carry custom attributes that administrators define under Admin → Attributes: a label, a key for the API (vendor,
length_mm), a
type – text, number, yes/no or a choice list – an optional unit, the item
types it applies to and its position in the forms. Values are edited with
the other attributes while the revision is in work, frozen on release and
copied by Revise and Save as. Key and type are fixed once created;
deactivating an attribute stops offering it, stored values stay visible.
Every item also has a classification in its master data: in-house (the default), purchased or standard part – it can change at any time, like the project group.
The item list filters by classification and by one attribute: text and
choices match a part of the value, yes/no attributes yes or no, numbers
12, >=10, <=20 or 10..20; an empty value finds every item that has
the attribute. The search box also finds attribute values.
The BOM import fills both for the items it creates: a BOM structure or
procurement column sets the classification (Normal, Phantom → in-house; Purchased, Kaufteil → purchased;
Content Center, Normteil → standard
part), and a column whose header is the label or key of an active attribute (Vendor, length_mm) fills that
attribute. A value that does not fit – text
in a number column, a choice that does not exist, an attribute that does not
apply to the item type – is left out with a warning. The preview lists which
columns become attributes and which are not imported; existing items are not
changed.
Audit log
Every modification is recorded with user, time and details – shown on every revision page and, for admins, under Audit log.
Vault, check-out and check-in
TeamCraft works like an engineering vault: files are never edited in place. You check out a revision (take the exclusive lock), change attributes, files and the BOM, and check in – the new file versions are stored and the lock is released in one transaction. If anything fails, nothing changes.
The vault explorer
Vault in the top bar opens a three-pane explorer:
| Pane | Shows |
|---|---|
| Folders | My check-outs, ★ Favorites, All check-outs, every project group (items, locks), items without group |
| Items | number, revision, name, state and who holds the lock – filterable |
| Detail | files with version, size, format and a 3D button; Check out, Check in, Cancel check-out |
Every action returns to the explorer, so a typical session is: pick the folder, select the item, Check out, download the files, work in your CAD program, drop the changed files into Check in, done.
Browser downloads are for single files and for viewing. A browser saves files one by one into Downloads – without the folder structure an assembly needs, without knowing which version a file is and without read-only protection. For CAD work (assemblies, drawings, anything you will change and check in) use the desktop working folder: one folder per project group with the original folder structure, Get for whole assemblies, read-only files until you check out – see How files appear on your computer.
Adding files to an existing item
The Files card of a revision always says how to add files:
- Free revision in work: To add or replace files, check out this revision with a Check out and add files button – it checks out and returns to the card with the upload form.
- Checked out by you: the upload form; files can also be dropped onto the card (large files use the resumable chunked upload).
- Checked out by someone else: who and since when; files can be added after check-in (administrators can force-unlock).
- In review, released or obsolete: frozen. Revise (for editors) creates the next revision, which takes the files.
- Viewers and users outside the project group see no way in.
Simulations and parts show what typically belongs there (solver input, results, reports; CAD model, drawing, STEP). The desktop app has Add files… (see How files appear on your computer).
Rules
- Only revisions in work can be checked out, and only by CAD editors of the item’s project group.
- A new item and a new revision (Revise) are checked out to their author.
- Check in with files: files with the same name become the next version (Creo’s iteration suffixes
bracket.prt.7are folded into versions); identical content is recognised and not stored again. - Every file has a folder inside its project group (
chassis/brackets; empty = the top of the group). It is the file’s place in the desktop working folder, so assemblies find their parts. The folder import sets it from the scanned tree, uploads and check-ins may send it (folder,foldersin the API), new versions keep it, and while the revision is checked out the Folder column of the revision page moves a file. Folders are relative:.., drive letters and names that are invalid on Windows are refused. - Cancel check-out releases your lock at once – no dialog, nothing is uploaded. Force unlock (admins) releases someone else’s lock after a confirmation, is recorded in the audit log and notifies the owner (notification kind unlock).
- The revision page and the dashboard show how long a lock has been held (🔒 Max for 3 days), so forgotten check-outs stand out.
- Check in all (dashboard, Checked out by me) checks in every revision you hold without new files – the locks are released, the revisions keep their current files. Each revision is checked in on its own (own transaction, own audit record), so one failure does not block the others; the message lists what failed and why. Upload changed files with the revision’s own Check in first.
Concurrency: nothing is overwritten silently
The lock keeps two people from changing a revision at the same time. When a lock was broken (Force unlock) and someone else checked in meanwhile, the original owner’s files are out of date – checking them in would throw away the newer version. TeamCraft therefore checks every upload against the version it was based on (optimistic concurrency, as Vault and Teamcenter clients do):
- The forms of the revision page send the versions the page showed; the
desktop app sends the SHA-256 of the version each file was copied from;
API clients send
bases(see the REST API). - If a file moved on since, the check-in or upload is refused with 409 and a message such as hub.step changed on the server since you got it: your change is based on v1, the server has v2 by Erika (…). Nothing was stored – the whole check-in is rolled back, the lock stays yours. Get the current version, merge your changes and check in again.
- Identical content is never a conflict, and uploads without a base keep the plain last-writer-wins behaviour.
Per file, a workspace is in one of these states (desktop Workspace tab,
…/file-status in the API):
| State | Meaning |
|---|---|
| up to date | the local file is the current version |
| modified | changed locally, the server still has the version it was based on – check it in |
| outdated | unchanged locally, the server has a newer version – get the current files |
| conflict | changed locally and on the server since – a check-in would be refused |
| new | only local |
| missing | only on the server |
- Uploads show a progress bar; Cancel upload stops the transfer immediately and nothing changes on the server.
Large files: chunked, resumable uploads
CAD assemblies and simulation results reach several gigabytes. Sending them in one request means starting over after every network hiccup, so API clients – and the desktop app and the web UI for files above 32 MiB – upload them in chunks:
POST /api/v1/uploadsannounces the file (revision, name, size, optionally its SHA-256, the base version and its folder in the project group – the desktop app sends the folder of the working folder, so the file keeps its place there). The server refuses at once what it would refuse later: a file larger thanTEAMCRAFT_MAX_UPLOAD_MB, a revision you have not checked out, or a base the server file has moved on from.PATCH /api/v1/uploads/{id}appends one chunk (at mostTEAMCRAFT_UPLOAD_CHUNK_MB) at the offset the server confirmed last. A chunk at any other offset is refused with 409 and the server’s offset, so after a broken connection the client asks (HEAD/GET) and resumes there – bytes of an interrupted chunk that were never confirmed are cut off. Each chunk is on disk before its offset is confirmed.POST /api/v1/uploads/{id}/finish– or the upload id in theuploadsfield of a multipart check-in – hashes the received file, checks size and SHA-256 (a mismatch resets the upload to offset 0), moves it into the content-addressed vault and stores the version through the same code as every other upload: check-out required, base-version guard, audit log, notifications, one transaction with the other files of the check-in.
The chunks are collected in uploads/ below the vault directory (the same
file system as objects/, so the finished file is moved, not copied); only
one chunk per request is held in memory, which is why
TEAMCRAFT_MAX_UPLOAD_MB can be raised for large files without more memory.
Uploads are private to the user who started them. An unfinished upload
expires 24 hours after its last chunk; the server removes expired uploads and
their data at start-up and every hour. Each user may have at most 16
unfinished uploads at a time, so the space they reserve stays bounded (16 × TEAMCRAFT_MAX_UPLOAD_MB per user).
DELETE /api/v1/uploads/{id}
cancels one at once.
In the browser the upload and check-in forms of the revision page and
the vault explorer, and the New item form (its uploads have no revision yet
and can only join the creation of an item), do the same on their own: files above 32 MiB go first in
chunks (to /uploads, the same sessions, signed in with the browser
session), the progress bar counts all files, and the form then stores them
together with the small files and the comment in one step. A dropped chunk
is sent again after a short wait (up to six times); if the connection stays
away, or the page was closed or reloaded, choose the same files again and
submit – the upload continues where the server stopped, the browser
remembers the unfinished uploads per revision and file. Cancel upload
removes them on the server. The New item form still sends its files in one
request, within TEAMCRAFT_MAX_UPLOAD_MB; attach large files to the new
revision afterwards.
Favorites, saved searches, Save As
- The star next to the title of a revision page keeps the item in Vault → Favorites (like Teamcenter’s Favorites). Favorites are personal.
- Saved searches: filter the item list, then Save this search – the named search appears above the list for one-click reuse (like Teamcenter’s Saved Queries).
- More → Save as… on a revision page creates a new item from it: the attributes and, optionally, the current files and the BOM. The new item starts with revision A, checked out to you; the source does not change.
Desktop working folder
The desktop app keeps a local working folder: one folder per project
group that mirrors the folder structure of the files, one copy of each
file (TeamCraft/ROVER/chassis/brackets/bracket.prt); the revision is
metadata, not a folder level.
- Get copies an item with its BOM (revision rule latest or released) and the parts its CAD files reference; only missing and outdated files are downloaded, checked by SHA-256, with progress and cancel.
- Add files… (Files section, More ▾) copies picked files into the working folder of a revision (checking it out first) and lists them as new files in Check in….
- Files you have not checked out are read-only; Check out makes them writable, Check in uploads the changed ones and makes them read-only again.
- The workspace tab shows which files you changed, which are outdated or in conflict, name clashes (two items with the same folder and file name – never overwritten) and who holds the lock; Check in uploads exactly the changed files – based on the versions they were copied from. Opening an outdated file or one somebody else has checked out asks first.
- Check in all in the worklist checks in every check-out without local changes and lists the ones that still have changes.
See How files appear on your computer.
Files, versions and 3D preview
Versions and rollback
Every upload of a file name is a new version with an optional comment.
The revision page lists the version history of every file; any version can
be downloaded (name.v2.ext), previewed, or restored – a rollback that
stores the old content as a new version, so history is never rewritten.
Large result files. A file can be set to keep only the latest version (⋯ → Keep only the latest version, while you hold the check-out): older versions are then removed after a grace period (default 7 days) as long as the revision is in work – released revisions keep all of their versions. See Data retention.
The vault is content-addressed (SHA-256): identical files are stored once,
and teamcraft vault check --verify-hashes proves that nothing was altered.
File formats
TeamCraft stores files of any type and recognises the native and exchange formats of the common CAD, BIM, 3D and electronics programs – see Supported file formats. Recognised files are labelled with program and kind, get their registered content type, are searchable by program (Items → CAD program) and, where readable, scanned for the files they reference.
Preferred CAD program
When a part is kept in several CAD programs – an NX model next to a SOLIDWORKS and an Inventor copy, say – choose your program under Account → Preferred CAD program. On the revision page, in the detail pane of the vault explorer and in the Files tab of the desktop app, the native files of the other programs then move into a collapsed group below the list, e.g. 2 files in other CAD formats (SolidWorks, Inventor); one click opens it.
- A family is everything one program writes natively: for NX its parts,
assemblies, drawings and simulation files (
.prt,.sim,.fem,.afm); for Creo also its neutral and Creo View files; for CATIA also CGR and 3D XML. The families: SolidWorks, NX, Solid Edge, Inventor, Fusion, AutoCAD (DWG), Creo, CATIA, SpaceClaim, FreeCAD, SolidCraft, Rhino, Alias, OpenSCAD, Revit, Navisworks, MicroStation, Archicad, SketchUp, Blender, Maya, 3ds Max, Cinema 4D, Modo, Houdini, KiCad, Altium and Eagle. - Neutral and exchange formats are never collapsed: STEP, IGES, Parasolid, JT and PLM XML, DXF, meshes (STL, 3MF, 3DS, …), visualisation formats, point clouds, IFC, Gerber, simulation decks and results, documents and images.
- Only a revision that has at least one file of your program is grouped; any other revision lists its files as before.
- It is display only and personal: nothing is hidden from the search,
downloads, Pack and Go, the REST API or other users. The default, All
CAD programs alike, keeps the plain list. The REST API returns the setting
as
preferred_cadonGET /api/v1/meand each file’s family asformat_info.family.
3D preview
Meshes, point clouds and STEP models open in a WebGL viewer in the browser and in the desktop app. Drag to rotate, wheel or pinch to zoom, arrow keys to turn, double-click to reset; Z up / Y up switches the convention. Below the model, Section X / Y / Z cuts it open along a plane: the slider moves the plane through the model (its position is shown in model units), flip keeps the other side – for a look inside housings and assemblies. Measure picks two points on the model (click, don’t drag) and shows their distance and its X, Y and Z parts in model units; the decimal places (0–6) are remembered in the browser and apply to the section position too. Measured points are on the visible surface, so a section shows inner distances such as wall thickness. Compare with … (on the preview page, when there is something to compare) lays another version of the file – or the file of the same name in another revision of the item – over the model as a translucent red ghost: red shows where the other model sticks out, so added and removed material is visible at a glance; sections and measuring work on both.
Add note pins a note to a point of the model for a design review: click
the point, write the note. Notes belong to the shown version of the file,
appear as blue points with a list below the model, and everyone who may read
the file sees them and adds their own – also viewers. A note is removed by
its author or an administrator; adding and removing are recorded in the
revision’s history (annotation.add / annotation.remove). The REST API
has the same notes under /api/v1/file-versions/{id}/annotations.
| Previewed | Formats |
|---|---|
| B-rep (tessellated) | STEP (.step, .stp, .p21) |
| Native CAD | NX parts (.prt), SOLIDWORKS parts (.sldprt, 2015+), Inventor parts (.ipt) – the tessellation stored in the file; their assemblies from the parts |
| Native CAD | CATIA V5 parts (.CATPart) – the visualisation data stored in the file; products (.CATProduct) from the parts |
| Native CAD | Rhino models (.3dm) – the meshes stored in the file, SubD objects refined from their control net |
| Native CAD | SketchUp models (.skp, SketchUp 3 to today) – their faces, components and groups in place |
| Native CAD | Creo parts (.prt.N) – tessellated from the exact faces stored in the file; assemblies (.asm.N) from the parts |
Fusion designs (.f3d) – the display mesh Fusion stores | |
| Visualisation | JT (.jt): JT 10 parts and whole assemblies, placed; JT 9 as NX writes it |
| Meshes | STL, OBJ, PLY, OFF, 3MF (incl. components), AMF, glTF / GLB, 3DS |
| Point clouds | PTS, XYZ, LAS (uncompressed), PLY and OBJ without faces |
| Simulation results | VTK (.vtk), VTU, VTP, CalculiX FRD – coloured by result fields |
The server converts a file once, in a separate process with a time limit, and caches the result next to the vault; uploads start the conversion in the background so the first viewer does not wait. Everything is pure Rust: STEP is read and tessellated with the truck B-rep kernel (or from the tessellated geometry of AP242 files), the mesh readers are part of TeamCraft. Export STL (in the toolbar on the view) saves the shown triangles, e.g. for 3D printing. The same conversion computes the shape signature of the similar part search.
While the model loads, the canvas says so, and when there is nothing to show the reason appears on the canvas (and in the line below it) – in the web UI and in the desktop app alike: the server’s conversion error (for example a damaged or unsupported file), an empty, cut-off or invalid model, a graphics driver without WebGL 2 or one that could not compile the shaders, a model too large for the graphics memory, or a 3D view the graphics driver stopped (WebGL context lost). A preview never ends as a silently empty canvas.
When a STEP preview shows only part of the model, the line below the viewer says partial preview: N of M faces – the missing faces use geometry the kernel cannot tessellate. Details: STEP in the 3D preview.
Simulation results show the outer surface of the mesh with a colour scale (viridis), the range of the field above the canvas and, when the file has several fields, a selector – see Simulations.
Previews of native CAD files
Native CAD files (Inventor, Solid Edge, SolidWorks, NX, Creo, CATIA, …) are proprietary; TeamCraft does not read their exact geometry (see Research: previews of native CAD files). The exceptions are NX, SOLIDWORKS, Inventor, CATIA V5 and Creo. The first four save a tessellation of every part into the file: NX in a documented format (JT 9), SOLIDWORKS (2015 and later) as the display lists it draws the part with, Inventor as the triangle lists of its graphics segment, and CATIA as the visualisation data it draws on screen (see Research: 3D preview of CATIA V5 parts). Creo stores no tessellation, but its exact faces with every edge already sampled; TeamCraft triangulates those faces itself – planes, cylinders, cones, tori, spline, fillet and ruled faces (see Research: 3D preview of Creo parts), and puts Creo assemblies together from their parts (see Research: 3D preview of Creo assemblies).
TeamCraft shows that tessellation in the 3D viewer, refined along the exact surface normals stored with it, so round faces stay round.
An assembly of NX, SOLIDWORKS or Inventor and a CATIA product are put together from their part files. Check the parts in, under their file names, either in the assembly’s revision or as items of their own. The assembly then shows every component in its place, sub-assemblies included (see Research: 3D preview of NX parts, 3D preview of SOLIDWORKS files, 3D preview of Inventor files).
A CATIA product stores no file names, only an id of each component’s
document. TeamCraft finds the component among the CATIA files of the
product’s revision by that id, whatever the file is called now; a renamed
or copied part is still found. Otherwise it looks for
<part number>.CATPart (or .CATProduct) by name, as above (see
Research: 3D preview of CATIA V5 products).
Keep parts whose part number differs from their file name in the
product’s revision.
These components are left out:
- components that are not in TeamCraft;
- NX components on layers the assembly hides;
- SOLIDWORKS components that are suppressed or hidden;
- Inventor components that are hidden or suppressed in the assembly.
CATIA products show hidden components, too: TeamCraft does not recognise them yet.
Rhino models (.3dm, Rhino 1 to 8) are shown from the meshes Rhino
saves with them: mesh objects, the render meshes of polysurfaces and
extrusions, block instances in their places; SubD objects are refined
from their control net. Hidden objects and hidden layers are left out. A
model saved without render meshes (Save Small, or very old files) has
no 3D preview and shows the picture Rhino stored instead (see
Research: 3D preview of Rhino models).
Fusion designs (.f3d) carry the triangles Fusion draws on screen;
TeamCraft shows them, refined the same way (see
Research: 3D preview of Fusion designs).
SketchUp models (.skp, SketchUp 3 to the current version) open in the
3D viewer as well: SketchUp stores plain polygons, so TeamCraft triangulates
the faces (holes included) and places every component and group, nested ones
too, in millimetres. Hidden faces, hidden components and everything on a
hidden tag (layer) are left out; colours and textures are not shown. A file
with only edges and no faces keeps its stored picture (see
Research: 3D preview of SketchUp models).
For the rest TeamCraft shows what other PLM systems show:
- JT files (
.jt, version 10) carry parts and assembly in one file: the 3D preview shows every instance in its place, and the file’s picture is rendered from it (see Research: 3D preview of stand-alone JT files). - A picture of the 3D preview for NX, SOLIDWORKS and Inventor parts and assemblies, CATIA parts and products, Creo parts and assemblies, Rhino and SketchUp models and Fusion designs. It replaces the image stored in the file and is a 512 × 512 PNG with a transparent background, drawn once and cached. An assembly’s picture is drawn again when one of its parts changes. Drawings, and assemblies none of whose parts are in TeamCraft, keep the stored image.
- The preview image stored in the file. Inventor drawings and
presentations (
.idw,.ipn), Solid Edge (.par,.psm,.asm,.pwd,.dft), NX assemblies and drawings (.prt, a JPEG) and SolidWorks files (up to 2014 in their OLE container, from 2015 on in a compressed section that TeamCraft inflates) carry a picture the CAD program wrote when saving. It appears next to the file on the revision page and in the desktop app (GET /api/v1/files/{id}/thumbnail). Only the image is read; files saved without one simply show none. - 3D from a STEP export. Attach a STEP export next to the native file. The desktop app on Windows creates it with your own Inventor or Solid Edge – the STEP button in the workspace or the option at check-in, like the Inventor add-in of Autodesk Vault or the Solid Edge integration of Teamcenter. It uses your licence (student licences included, within their terms); TeamCraft contains no Autodesk or Siemens software.
Full-text search
The item search ranks matches in numbers, names, materials, descriptions and file names, and in the text inside STEP and IGES files (product names, authors, organisations), plain-text files (with their decimal numbers), the text layer of PDF reports and the text of HTML reports (table rows as lines; scripts, styles and hidden elements skipped). PDFs and HTML files are read in an isolated child process with size, time and memory limits; scanned PDFs have no text layer (there is no OCR).
BOM, references and change propagation
Bill of materials
A revision’s BOM lists child items with find numbers, quantities and notes. The BOM page expands the structure over all levels with mass and cost roll-up and three revision rules:
| Rule | Resolves each child to |
|---|---|
| Latest | its newest revision, whatever the state |
| Latest released | its newest released revision |
| As released | the exact revision the parent was released with (e.g. the as-built configuration) |
The roll-up adds the entered masses. The centre of gravity and the inertia tensor of the structure, computed from the 3D files of the parts, are on the Mass properties page – see Mass properties.
Working with a large structure
For a big product (hundreds or thousands of lines across several project groups) the BOM page offers:
- Fold and unfold – every line that has lines below it has a ▾ / ▸ button (keyboard operable,
aria-expanded); Collapse all and Expand all sit above the table. This needs JavaScript; without it every line stays visible. - Levels – Levels: 1, 2, 3, 4, 5, All (
?depth=n) lists only the first n levels, also without JavaScript, and the link can be shared. A line at the last level shown has a+nmarker with the number of lines hidden below it. Mass and cost always cover the whole structure, not just the levels shown. It combines with the revision rule (?rule=). - Group column – the project group of each line, linked to the group page. A line you may not see (a restricted group) stays a placeholder and shows no group.
- Group filter – Group (
?group=CODE, only groups that occur in the structure are offered) keeps the lines of that group (highlighted) and the path from the top to them, so they stay in context; other lines below a match are left out unless they lead to a match too. With a level limit as well, the filter applies first. An unknown or hidden code gives “not found”.
The CSV and Excel exports and Pack and Go ignore levels and group: they always contain every line. The desktop app’s structure tab does not have these controls yet.
Where-used shows the parents of an item; All levels on the revision page
opens the where-used page, which walks up through the sub-assemblies (1, 2,
3, 5 or all levels). Cycles are rejected. The BOM
exports as CSV and as Excel workbook (which the BOM import reads back), and Pack and Go downloads a revision with
the files of its whole structure
as ZIP (<item>_<revision>/, bom.csv, manifest.json with SHA-256 per
file). For every released revision the manifest also names the change order
that released it and who approved it when (released_by: change, title,
release time, approvals); a change order the downloader may not see is only
marked restricted.
Creating a BOM from the CAD references
An assembly uploaded as a file has no BOM, although the server already knows which part files it uses (the References tab). Create BOM from CAD references… on the BOM card of the revision (also offered as a hint while the BOM is empty) shows what the BOM would be, one row per child:
- item, name, quantity and where it comes from – CAD instances (NX: the placed instances of the component, hidden layers left out; SOLIDWORKS: the instances in the component tree without exclude from BOM; STEP assemblies with external part files: the assembly occurrences of each file) or assumed 1 (the file does not tell; the line gets the note quantity not known from the CAD file – check it);
- the CAD file that references it;
- a status: new line (ticked), already in the BOM, differs (BOM 2, CAD 4 – changed only when you tick it and Also change the quantity), or circular (refused, as when adding a line by hand).
Only assembly files count (NX files only when they have component
instances, STEP files only when their occurrences lead to external part
files); a drawing’s link to its model stays a drawing of reference.
Referenced files TeamCraft does not have are listed as not in TeamCraft and
never created; parts in project groups you cannot see are only counted. Add the ticked lines needs the revision
checked out by you (the page
offers Check out and create) and adds all lines in one transaction. The
REST API has the same two steps: GET …/bom/from-references returns the
plan, POST …/bom/from-references with
{"children": ["PRT-000004"], "update_quantities": false} applies it.
Comparing BOMs
Compare… on the BOM page lists the lines that were added, removed
or changed (quantity, find number, notes) between two revisions – by
default the previous revision of the same item; any other revision can be
chosen, and the API also compares by code (GET …/revisions/B/bom/compare?with=A).
Levels: All levels compares the whole structure: every child is resolved
with the chosen revision rule, the children of two matched lines are
compared the same way, and an added or removed sub-assembly brings its
whole sub-tree. A child that now resolves to another revision counts as
changed. When one of the two revisions is released, the default rule is As released: a released parent uses the child
revisions it was released
with, an in-work one the newest released children, so comparing two
revisions shows exactly which parts went into the new one; otherwise the
default is Latest. Latest and Latest released resolve a child by its
item alone – both sides then get the same child revision and sub-tree, and
only the direct lines can differ. In the API: depth=all and
rule=latest|released|as_released (same default). The Excel
download contains the level and the revisions too.
Alternates, supersession and replacing a part
The card Alternates and replacement on the revision page holds two relations of the item itself (not of one revision – they stay true for old assemblies too). Changing them needs edit rights in the item’s project group.
- Alternates are items that may be built in instead of this one in every assembly – same form, fit and function, e.g. a second source of a purchased part. Two-way (the default) also makes this item an alternate of the other one; that needs edit rights in the other item’s group too, otherwise add it one-way. The other item then lists it under may be replaced by.
- Supersession says: do not use this item for new designs any more, use its successor. An item has at most one successor, and a chain of successors never leads back to the item. The revision page of a superseded item shows a notice, BOM lines that use a superseded item are marked Superseded by …, and adding one to a BOM still works but says so. Superseding changes no BOM and no lifecycle state; making the old revisions obsolete stays a change order’s job.
Replace in assemblies on the where-used page puts another item (by default the successor, otherwise an alternate) in place of this one. The preview lists every assembly whose latest revision uses the item directly and what will happen to it:
| Latest revision of the assembly | Result |
|---|---|
| in work, not checked out or checked out by you | the line is replaced (quantity, find number and notes stay) |
| in work, checked out by somebody else | left alone – ask them, or replace it there yourself later |
| in work, in a group you cannot edit | left alone |
| in review | left alone until the review is finished or rejected |
| released | needs a new revision – revise it (usually in a change order) |
| would contain itself through the replacement | left alone (circular BOM) |
A revision that was not checked out is changed directly, like check out,
edit, check in; one checked out by you stays checked out. Older revisions keep
their BOM. Every replaced line is recorded in the history of the assembly (bom.replace). Assemblies in groups you
cannot see are only counted.
References
Besides the BOM, revisions reference other items: drawing of, specification for, references. CAD files are scanned for the files they reference (assemblies, drawings, STEP, KiCad; ASCII and UTF-16 paths as written by SolidWorks, Inventor, Solid Edge, CATIA, …; NX parts and SOLIDWORKS 2015+ files through their containers, see Formats) and matched against the vault: referenced files that are not in TeamCraft are flagged and the missing BOM lines are suggested. The other direction warns before damage: removing a file that CAD files of other items still reference names them (up to three, then a count) in the confirmation, so a part still used in an assembly or drawing is not deleted by accident. Deleting an item is refused while it is used in a BOM.
Change propagation
When a change releases a new revision (or a revision becomes obsolete), every dependent – via BOM, references, CAD file references or as the basis of a simulation – is flagged Check needed. A CAD editor acknowledges it with a note (form, fit and function unchanged) or revises and releases the dependent, which resolves the check and propagates one level further. Every change shows this impact analysis before it is approved.
Dependents to check on the dashboard (and in the desktop app) groups the open checks by the dependent revision: one header per assembly, drawing or simulation (ASM-000002/A Electronics enclosure – 4 changes to check) with Open, Revise and Acknowledge all…, and one compact row per source below it – the item that changed, released or obsolete via which change, and the link (BOM line, CAD file reference, drawing, specification, reference, simulation basis) – each with its own Acknowledge…. Acknowledge all… resolves exactly the checks shown with one note, in one step; checks resolved by someone else in the meantime are skipped, and every acknowledged check is recorded in the item’s history. The revision page shows its open checks the same way.
Finding similar parts
Before you model a new part, check whether one like it already exists. TeamCraft compares the shape of 3D models – no AI, no training data, the same answer every time – and lists parts of similar shape and size with a reason you can check.
Where you see it
- Revision page → Similar parts (below Files): up to six parts with a score, the matching file and a one-line explanation, e.g. extents 118 × 40 × 8 vs 120 × 40 × 8, shape distribution 94 % similar. On a brand-new part (only revision A, in work) the section is called Similar existing parts: if one fits, reuse it instead of creating a new item. It is a card on the page, not a pop-up: it appears whenever the revision has a file it can compare, and stays hidden otherwise. Right after Items → New item with a STEP or mesh file the card first says Comparing the shape … reload the page in a moment – the page does not refresh itself, reload it once the comparison is done.
- Desktop app: the revision overview has a Compare the shape in the browser button that opens this card in the web UI; the app itself shows no list.
- REST API:
GET /api/v1/items/{number}/revisions/{code}/similar(REST API). - AI assistants: the MCP tool
find_similar_parts(MCP server);find_duplicate_filesis its sibling for byte-identical copies.
The comparison needs a 3D model with a preview: STEP, a mesh (STL, OBJ,
PLY, OFF, 3MF, AMF, glTF/GLB, 3DS), an NX part (.prt with its JT
lightweight representation), a CATIA V5 part (.CATPart with its
visualisation data) or a Fusion design (.f3d with its display scene).
Other native CAD files are compared through
their STEP export – attach one. NX assemblies are not compared (they hold no
shape of their own), and neither are point clouds. Simulation result files (VTK, VTU, VTP, CalculiX FRD) have a 3D
preview too and are currently
compared like parts.
How it works
When a 3D file is attached or checked in (or first shown), the preview process computes a small shape signature of its mesh, once per file content:
| Part of the signature | What it is |
|---|---|
| Principal extents | edge lengths of the tightest box around the part, largest first – independent of how the part lies in the file |
| Surface area, volume | the volume only counts when the mesh is watertight; B-rep exports often are not, then it is left out |
| Shape distribution (D2) | histogram of the distances between 32 768 random point pairs on the surface, relative to the box diagonal – a fingerprint of the form that ignores position, rotation and size |
| Triangles | the size of the mesh |
Two parts are compared with
- shape = 1 − ½ · Σ |difference of the D2 histograms| (100 % = same form),
- size = exp(−2 · mean |ln(extent ratio)|) over the three extents (100 % = same size; 5 % larger in every direction gives about 90 %, twice the size about 25 %),
- score = shape × size.
Hits below 50 % are not shown; each item appears once (its best file), and only its latest revision counts. Units are the file’s model units (usually millimetres); a part exported in inches is therefore “smaller”.
Ignore scale (ignore_scale=true in the API and the MCP tool) compares
the proportions (middle and smallest extent relative to the largest) instead
of the extents, so scaled variants – M6 and M8 versions of a part – score
like copies.
Everyone who can sign in sees the hits, exactly like the item search: items of a restricted project group appear only for its members and administrators – for everybody else they are neither shown nor counted, even with identical file content.
Limits
The signature looks at the outer form. Parts that differ only in small features (a hole more, a different chamfer) score high – that is the point of the search, but it is no proof that they are interchangeable. Mirror images score as the same shape. Very different tessellations of the same part (a coarse STL vs a fine STEP) agree on extents and differ a little in the shape distribution.
A STEP file with a partial preview (some faces use geometry the kernel cannot tessellate, see Files) gets no signature: area, volume and shape distribution of a model with holes would compare it wrongly. It is neither searched from nor found as a hit; the similar-parts panel says why.
The search is fast for large vaults: an indexed prefilter on the extents (within a factor of three) or proportions keeps at most 2000 files, which are scored in memory.
Files stored before this feature get their signature in the background when
their revision page or 3D view is first opened (the page then says reload in a moment), or all at once with
teamcraft vault signatures (see
Operations; --convert also converts files that
were never previewed).
Mass properties (mass, CG, inertia)
TeamCraft computes the mass, the centre of gravity (CG) and the inertia tensor of an assembly – a vehicle, a rocket, a robot – from the 3D files of its parts, the densities of their materials and the masses you measured. Nobody has to load the whole assembly in the CAD system to get the numbers: open the assembly revision and choose Mass properties (on the revision page next to the BOM, or on the BOM page).
The method and its limits are described in the research note Mass properties from exported meshes.
The workflow
- Export every part in the assembly frame. In the CAD system, export each part as STL (or 3MF, OBJ, STEP …) in the coordinate system of the whole assembly or vehicle, not in the part’s own frame. TeamCraft then only adds the parts up – no placements, no CAD assembly file needed. A part used several times (four fins) is exported once with all its instances in one file; set Units shown in the file to 4.
- Attach the files to the part revisions as usual. Attach the files of one part to a part revision, in as many
formats as you like. The
geometry file is then picked automatically (STL, 3MF, OBJ, PLY, OFF, AMF,
glTF, 3DS, then STEP, then the lightweight data in NX, SOLIDWORKS, JT and
Rhino files; the native format of your preferred CAD program first among
the native ones) and the page says so: Geometry:
box.stl(chosen automatically; 2 other formats of the same model). Files belong to one model when their names agree apart from case,_,-, spaces, the extension and a trailingrev-X(box.prt,Box_rev-B.SLDPRT,box.stp). If the revision holds files of several different parts, TeamCraft does not guess – see Geometry ambiguous. - Name the material. The material attribute of the revision (e.g.
Aluminium 7075-T6) is looked up in the material library, ignoring case. The Material field of New item and Edit revision (and of the desktop app) suggests the library while you type, with the density next to each name; picking a suggestion enters exactly the library name. Free text stays allowed – for a bought part with a measured mass, say – but a name that is not in the library shows a hint, and the mass properties report a missing density. A density override in the mass settings wins over the library – for 3D-printed parts with partial infill, for example. - Enter measured values for bought parts and weighed sub-assemblies: mass, optionally the CG and the inertia tensor about that CG.
- Check the result on the mass properties page and export it as CSV or JSON for the flight simulation, the structural analysis or the balancing.
Where each number comes from
For every revision the first source that applies is used:
| Source | When | Gives |
|---|---|---|
| measured | a measured mass is entered | that mass; CG and inertia if entered; covers the whole sub-tree |
| sum of children | the revision has BOM lines | the lines added by the parallel axis theorem |
| computed | a 3D file and a density (override or material library) | mass, CG, inertia from the closed mesh |
| entered mass on geometry | a 3D file, no density, but the revision’s mass attribute | the entered mass spread uniformly over the mesh |
| entered mass (no position) | only the mass attribute | counts in the total mass, not in the CG and inertia |
| geometry ambiguous | 3D files of several different parts and none chosen, no measured mass, no BOM lines | nothing – the line is flagged, the totals are incomplete |
| missing | none of the above | nothing – the line is flagged |
Lines below a measured assembly are shown as covered by measured parent. An assembly’s own 3D file and entered mass are not added to the sum of its lines (they would count the parts twice); enter a measured mass to replace the sum.
A BOM line with quantity n adds n units. In the common frame these units sit at the same place unless the file shows them all: a line whose quantity differs from the units shown in the file is flagged copies stacked at the same place – the mass is right, the CG and the inertia usually are not.
Geometry ambiguous
A plausible but wrong number is worse than none, so a revision whose 3D files belong to several different parts (typically an assembly attached as one item: the parts, the assembly and the neutral JT or STEP file) does not get the mass of whichever file sorts first. Its source is geometry ambiguous, it adds nothing to mass, CG and inertia, and the totals are flagged incomplete: N files of M different parts – choose the geometry file in Mass settings, or split the parts into BOM lines. The same applies to a part revision inside an assembly. An entered mass still counts (without position).
- TeamCraft suggests the likely whole assembly: the file that references
other files of the revision and is referenced by none of them, else the
file named like the item (number or name). The Mass settings card offers
a one-click Use
NIST mtc crada assembly.jt(the revision must be checked out); the choice is stored like a manual one. It is a suggestion only – nothing is used without the click. - A JT 10 file of the assembly is often the best choice: it is the one
file with every part placed in the assembly frame (the NX assembly
.prthas no geometry of its own). For the NIST box assembly its volume matches the sum of the 36 placed part files to within 1 %. - The file selector lists the candidates grouped by model, the likely assembly first, and marks the file in use as automatic or chosen; Automatic restores the automatic pick.
- The better fix is usually to model the assembly as an assembly: one item per part with the part’s files, and BOM lines with quantities (see The workflow).
- REST, the JSON export and the MCP tool carry
geometryper line androot_geometry:choice(automatic,chosen,ambiguous), the file, the number of other formats, the candidates by model and the likely file. The CSV has the columnsgeometry_fileandgeometry_choice.
Warnings
- open mesh – edges with one triangle only (holes): the volume is not well defined. Fix the export (closed solids only).
- inconsistent orientation – some triangles face inwards.
- non-manifold edges – bodies touching along an edge or duplicated faces; usually still correct.
- inside out – every triangle faced inwards; TeamCraft flipped the mesh.
- no density – no material, or the material is not in the library.
- no position – a mass without CG (entered mass, measured without CG): counted in the total mass, left out of the CG and inertia.
- restricted lines – items of project groups you are not a member of are left out, like in the BOM roll-up.
- The entered mass attribute differs by more than 5 % from the computed mass.
Units and conventions
- The length unit is set per file: millimetres by default (glTF: metres), metres or inches. Results are always SI: kg, m, kg·m².
- The CG and the tensors are in the frame of the geometry files.
- The inertia tensor is given about the CG and about a reference point
you enter (
x,y,zin metres, e.g. the nose tip), in tensor form: the off-diagonal entries are the negative products of inertia,Ixy = −∫ xy dm. The principal moments are listed as well. - Accuracy: a mesh approximates curved surfaces from inside. With an export chord tolerance of 0.01–0.05 mm the error is far below that of handbook densities; an icosphere of 1 280 triangles is 0.9 % light, one of 20 480 triangles 0.05 %.
Mass settings
The Mass settings card on the page holds, per revision: the geometry file with its length unit and number of units shown, the density override, and the measured mass, CG, inertia tensor and a note (scale, data sheet, date). Changing them needs the revision checked out, like every other attribute; Revise carries them over to the next revision.
Material library
Admin → Materials (/materials) lists the densities. Every signed-in
user can read it; administrators add, rename and remove materials. Names
are unique ignoring case. Removing a material does not change any revision;
their masses then report a missing density.
Exports and interfaces
- CSV and JSON from the Export menu of the page (the totals, both tensors and every line with its source and warnings). Like the BOM downloads they follow the project group’s download switch.
- REST:
GET /api/v1/items/{number}/revisions/{code}/mass-properties?rule=&ref=x,y,zandGET /api/v1/materials(REST API). - MCP: the read-only tool
get_mass_properties(MCP server).
Limits
- Placements (transforms per BOM line) are not stored: parts exported in their own frame cannot be combined yet.
- Overlapping bodies in one file are counted twice.
- Point clouds and simulation results have no volume.
- Measured values can only be changed in an in-work revision.
Change requests, change orders, reviews and notifications
Change requests (ECR) → change orders (ECO)
A change request (ECR, ECR-000001) is the step before a change
order: somebody found a problem or has an idea – on the shop floor, in a
simulation, in a test, in purchasing – and asks for a change. Every
signed-in user may raise one, viewers included, and everybody may
comment on it. A change order (ECO, CHG-000001) is the engineering
work that answers it: it carries the new revisions through review to the
release.
anybody signed in CAD editors of the affected project groups / admins
───────────────── ──────────────────────────────────────────────────
Request change… ──▶ New ──▶ In Triage ──▶ Accepted ──▶ change order (ECO): create one with the
(revision page, │ │ in-work revisions, or link an open one
file row, desktop) │ │ ──▶ Deferred ──▶ In Triage (taken up again) │
│ │ ──▶ Rejected │ released
└──────────┴──▶ Withdrawn (by the requester, before the decision) ▼
Implemented
- Raise – Request change… on a revision page, on a file row, in the Requests list, or in the desktop app on a revision tab or a workspace file. Give a title, the problem and the proposed solution, a reason (defect, improvement, cost, manufacturing, supplier, safety, customer, other), a priority and optionally a date it is needed by. The revision and the file name are filled in. Screenshots, mark-ups and PDF reports can be attached (PNG, JPEG, GIF, WebP or PDF, up to 10 MiB each – the type is checked by the content, not the name). The CAD editors of the project group of the item and the watchers of the item are notified; the requester watches the request.
- Discuss – everybody comments in the discussion thread; the requester, the assignee, everybody who commented and the watchers are notified.
- Triage – a CAD editor of an affected project group (or an admin)
starts the triage (and is assigned if nobody is), assigns it to the
engineer who will do the work and decides:
- Accept – the change will be made;
- Defer – not now (reason required); take up again later;
- Reject – no change (reason required). While no item is named, any CAD editor may triage; once the request is decided, an item is added only by a member of its project group (or an admin). A group can keep requesters from deciding their own requests (Review policy, see Read permissions).
- Implement – an accepted request gets its change order: Create change order creates a draft with the in-work revisions of the affected items (items without a revision in work are listed – revise them and add the revision to the draft), or Link an existing open change order. The change order runs through review as usual.
- Implemented – when a linked change order is released, the request becomes implemented in the same transaction, and everybody involved is told.
The requester can withdraw the request while it is New or In Triage. The revision page shows the open requests on its item (with their number on the Request change… button); the dashboard and the desktop worklist list the requests assigned to me and my open requests. Every step is in the audit log and in the status history of the request.
Change orders
A change collects one or more revisions in work, a title, description, priority and reviewers.
- Draft – the author adds revisions (Release via new change… on a revision page) and reviewers – or the draft is created from an accepted change request.
- Submit – the revisions move to In Review; they are frozen.
- Each reviewer approves or rejects with a reason. Reviewers must be independent (see Separation of duties below).
- When everybody approved, all revisions are released together, and the accepted change requests the change implements become implemented. A rejection sends them back to WIP; fix, reopen and resubmit – or cancel the change (takes effect at once).
A change can also retire released data: add an obsolete or supersede line for an item under Affected revisions (On release). When the change is released the revision becomes obsolete in the same step – a supersede line also declares the item superseded by its successor, and its watchers are told. A project group can require this way for obsoleting (Review policy, see Permissions); then the direct Mark obsolete and Supersede buttons answer 409.
Release readiness
A change releases a whole product at once. Before it can be submitted, every revision in it is checked against what it depends on:
| Dependency | Not released and not in this change | Obsolete |
|---|---|---|
| BOM lines | blocks the submit | warning |
| item references (drawing, specification, reference) | blocks the submit | warning |
| CAD file references (found by the file scan) | warning | warning |
The Release readiness card of a draft change lists them. Add puts a single in-work revision into the change. Add all to this change adds all of them, and level by level their own unreleased dependencies, in one step. Dependencies that are in review in another change have to be released there first. Dependencies of a project group you are not a member of are skipped by Add all; a member has to add or release them. The desktop app does the same with Include unreleased dependencies in the Release… dialog (on by default).
Separation of duties
Nobody reviews work they did themselves. TeamCraft refuses a reviewer who
- created the change order (the author),
- submitted it, or prepared it as an administrator for the author (added items or reviewers, edited or submitted the draft – read from the audit log), or
- changed the revisions under review: created a revision, uploaded a file to it, or has a logged check-in, revision, file, BOM (including BOM import and replace) or reference edit on it.
The rule is checked when a reviewer is added (400), when the change is submitted (409, naming the reviewer to remove) and again when a reviewer decides (403), because people can edit a draft after they were added. An administrator can still act for the author, but then cannot also review that change. The rule is always on; a team needs at least one person who did not touch the files.
One user alone cannot release. On a test server or a fresh installation
with a single account, every change stays a draft: the author is never their
own reviewer, and there is no administrator override – a release that one
person approved for themselves would not be a release (#291). Create a second
account (Admin → Users, a CAD editor) to try the review, or keep the change
as a draft until a colleague can review it. The desktop Release… dialog says
so when nobody else in the project group can review, offers the draft
instead, and refuses the own user name before anything is created. A draft
that cannot be submitted is never a dead end: its reviewers can be added and
removed by user name, and it can be cancelled (web, desktop,
DELETE /api/v1/changes/{n}/reviewers/{username},
POST /api/v1/changes/{n}/cancel).
The user who submitted a change is stored in
changes.submitted_by. Changes that were already in review when the rule
was introduced keep working: only the author is refused on those until they
are withdrawn and submitted again.
Signing off a decision
A reviewer may state what their approval means (Meaning of your sign-off,
e.g. approved as design owner; meaning in POST /api/v1/changes/{number}/review).
When the server asks for a fresh sign-in (TEAMCRAFT_APPROVAL_REAUTH, see
Configuration), the review form also asks for
the password – or offers Sign in again for single sign-on accounts – and
a decision without it is refused. The change page shows how each decision
was signed off (password, single sign-on or an API token with the approve
scope) and the stated meaning.
Review rounds and absent reviewers
When a change is withdrawn or reopened after somebody decided, the decisions
of that round are kept (table change_review_rounds) and the next submit
starts a new round. The change page shows the current round in the Reviewers card and the earlier ones under Earlier
rounds.
A reviewer who has not decided yet but no longer can (account deactivated,
role below editor, or not a member of a restricted group of an affected item)
is marked Cannot decide. While the change is in review, the author or an
administrator can replace any pending reviewer by another eligible user; the
other decisions are kept, the substitute is notified and the audit log gets
change.replace_reviewer. The substitute must pass the same checks as a new
reviewer (editor role, four-eyes rule, sees every item).
Suggested reviewers
Next to the reviewer picker of a draft change, TeamCraft suggests up to five people who know the affected items, each with a one-click Add and the reasons in plain text. The suggestions come from the history TeamCraft already keeps – no AI, the same data always gives the same order:
| Source | Example reason | Points |
|---|---|---|
| edits of an earlier revision of an affected item in the last 90 days (check-ins, uploads, BOM and attribute edits) | edited PRT-000005 3 times in the last 90 days | 3 + one per edit (at most 10 per item) |
| a decision on an earlier change with the same items | reviewed ECO-000012 (same items) | 4 (still pending: 2) |
| owner of a dependent: creator or author of the latest revision of an item that uses, documents or references an affected item | owns ASM-000003, which uses PRT-000005 | 2 |
| manager of the project group of an affected item | manager of ROVER | 3 |
| requester of a change request this change implements | raised ECR-000007, implemented by this change | 1 |
Each source counts at most a few times per person, so one busy source
cannot outweigh the others. Only active CAD editors and admins – the users
who may review – are suggested, never the author, never the
administrator who is preparing the change and never someone already
assigned. People who changed the affected revisions know the item best but cannot
review it: they are listed after the others as knows the item, cannot review
without an Add button. The desktop app shows the same suggestions on the tab of a draft
change, the REST API has GET /api/v1/changes/{number}/suggested-reviewers
and AI assistants the MCP tool suggest_reviewers. Suggestions never
assign anybody.
Notifications
The bell lists review requests, decisions, releases, checks needed, change requests and news about watched items, changes and change requests (Watch on any of their pages). Optionally TeamCraft sends them by e-mail (SMTP) and to Mattermost (a direct message from the TeamCraft bot to you, never into a channel). Every user chooses channels and mutes kinds on the account page; delivery runs from an outbox, so a slow mail server never delays a request.
Mattermost messages arrive as a direct message from the TeamCraft bot, with the full title and text; nothing is posted into a channel. Enter your Mattermost username on the account page if it differs from your TeamCraft login. If it is wrong (or your Mattermost account is deactivated), the notification shows Mattermost failed with the reason on the page Notifications, for example Mattermost user “xy” not found – check your Mattermost username. Send a test on the account page checks the setting.
With e-mail switched on, CAD editors also get a digest of the change requests waiting for triage – one e-mail per project group, by default once a day: the requests raised since the last digest that are still New, and the numbers of the older ones that still wait. No new request, no e-mail. The digest also appears in the bell’s list, already marked as read. Mute E-mail digest of new change requests … on the account page if the immediate notifications are enough.
Other systems – CI, ERP, CAM, a team channel – can react to releases, reviews and change requests through event webhooks, which administrators set up.
Simulations (FEA, CFD)
TeamCraft manages the analyses that belong to a design next to the design itself: which part revision was analysed, with which file, what came out – and whether that is still valid after the design changed.
A simulation is an item
Create an item of type Simulation (numbers SIM-000001, …) with New
item, or through the link in the Simulations card of a part. One simulation item is one analysis,
e.g. Bracket – static, LC1–LC3 or Pump housing – modal. It has the usual
lifecycle, revisions, check-out/check-in and change orders. Its files are
the model, the solver deck, selected results, the report and pictures;
TeamCraft recognises the common model and result
formats
(VTK, CalculiX, Nastran, ANSYS, Abaqus, OpenFOAM, Fluent, STAR-CCM+, CGNS,
Exodus II, LS-DYNA, …) but does not parse them.
Fluent, STAR-CCM+ and Simcenter 3D files
- Simcenter 3D (NX CAE):
.sim,.femand.afmare recognised as NX simulation files by theSPLMSSTRcontainer NX writes them in. The Nastran deck Simcenter 3D writes as.dat(SOL,CEND,BEGIN BULK, a$comment naming Nastran or Simcenter) is a Nastran input deck;.f06,.f04,.pchand.op2are Nastran results. A text.femis an OptiStruct/HyperMesh deck, not an NX FE model. - Fluent: case, data and mesh files – legacy (
.cas,.dat,.msh), gzip-compressed (.cas.gz, …, checked by the gzip magic) and HDF5 (.cas.h5,.dat.h5,.msh.h5, checked by the HDF5 signature) – plus the project (.flprj), CFD-Post data (.cdat), journals (.jou), transcripts (.trn) and report files (report-file-*.out). - STAR-CCM+: the
.sim(when its beginning names STAR-CCM+), the solution history (.simh) and macros (.javafiles that name STAR-CCM+ or import its API – other Java sources stay unclassified).
None of the binary files (.sim, .simh, .op2, HDF5, legacy binary data)
is parsed beyond its first bytes. The text files – transcripts, report
files, journals, macros, .f06/.f04/.pch – are read as text for the
search, the assistant and the extraction templates (below).
Based on – what was simulated
The Based on card of a simulation revision lists the revisions it was computed with, each with a role:
| Role | Typical basis |
|---|---|
| Geometry | the part or assembly revision that was analysed |
| Mesh | a mesh / FE model kept as its own item |
| Material | a material specification or data sheet |
| Load case | the load specification or a test report |
| Other | anything else the result depends on |
Add a basis by item number, revision code (empty = latest revision) and, optionally, a file name: TeamCraft then pins the current version of that file, so the simulation records exactly which file version it used. Like BOM lines the basis links belong to the simulation revision: they change only while it is checked out, Revise copies them, and they are frozen when the simulation is released.
New simulation from a part or assembly. On the page of a part or assembly
revision, New simulation based on PRT-000002/A (card Simulations and the More menu) opens the new-item form
prefilled from that revision: the basis
line with a role select (geometry by default), the type fixed to Simulation, the
name Simulation – <name of the basis>, a description with the basis
revision and material, and the project group of the basis (or your first group
when you may not create items there – the form says so). Unit, classification,
material, mass and cost do not apply to a simulation and are hidden; the item
number is generated from the group’s scheme (the hint shows the next one).
Creating adds the basis link in the same transaction: the current version of the
STEP file of the basis revision (else its first CAD file) is pinned, without a
file the revision alone is linked. You land on the basis card of the new
simulation, which is checked out to you. A basis you may not read behaves like
a missing one: the plain form is shown and nothing of it is echoed. Further
bases are added on the Basis card.
The part or assembly shows the other side in its Simulations card: every simulation whose latest revision is based on it, with state, failed results and whether it is up to date.
Out of date
A simulation is out of date when, for one of its bases,
- a newer revision of the basis item was released,
- the basis revision became obsolete, or
- the pinned file has a newer version (or the pinned version was deleted).
The reason is shown next to the basis (PRT-000012/C was released (simulated: B)). A basis in a restricted group that the reader may not see shows no reason (see Read permissions). Releasing or obsoleting a basis also flags the simulation for a check through the normal change propagation: the simulation shows Check needed, its author and watchers are notified, and the change’s impact analysis lists it. Acknowledge the check with a note when the result is still valid (fillet change outside the stressed region), or revise the simulation, re-run it, update the basis and the results and release it – that resolves the check.
Releasing the basis revision itself (the simulation was based on an in-work revision that is now released) only flags the simulation when a pinned file has changed since; without a pinned file TeamCraft cannot tell.
Results
The Results card holds the key numbers of the run: name, value, unit, optional lower and upper limit, load case, solver and solver version. A value passes when lower limit ≤ value ≤ upper limit (limits inclusive, either may be empty) and fails otherwise; values without limits are informative. The card summarises failed and passed values; the part’s Simulations card shows the number of failed values.
Values are edited in the table while the simulation revision is checked out, or imported from a CSV or Excel table:
name,value,unit,limit_low,limit_high,load_case
Max. von Mises stress,182.5,MPa,,250,LC1
Safety factor,1.37,,1.5,,LC1
First eigenfrequency,2.35e2,Hz,200,,modal
Optional columns: solver, solver_version, note. Semicolons, tabs,
decimal commas and German column names (Kennwert, Wert, Einheit,
Untergrenze, Obergrenze, Lastfall) work too; min/max are accepted
as limits. A value with the same name and load case is updated, new ones are
appended; Replace all values removes the values that are not in the file. CSV on the card exports the table in the
same layout.
Solver scripts write their results through the REST API (PUT …/simulation/results replaces the set in one transaction;
see the
REST API).
Solver summary of a file
For solver output a one-line summary appears next to the file on the revision page (and in the vault explorer): solver
and version, how the run ended (converged,
END OF JOB, fatal), the last iteration or time step, and the number of FATAL
and WARNING lines. It is made at check-in from the first and the last 64 KiB
of the file only, so the counts cover those two windows, not the whole file – the
line says so. Supported: Fluent transcripts (.trn) and report files, Nastran
.f06 and decks (SOL), STAR-CCM+ macros (.java) and batch logs (.log/.txt that name STAR-CCM+). The summary is
a hint read from a
user-written file, never a verified result; the MCP tools mark it as untrusted
data. The rules follow public documentation and typical output and are not yet
checked against real files of these programs; a file that does not match shows
no summary. Files stored before the feature have none until they get a new version.
Values from files at check-in
Two bounded ways fill the table without typing – neither reads big result files:
The exchange file. A file named teamcraft-results.json or
teamcraft-results.csv (at most 1 MB) checked in with the simulation
revision sets the values in the same transaction as the check-in, with an
audit entry:
{ "results": [
{ "name": "Pressure drop", "value": 388.25, "unit": "Pa", "limit_max": 500, "load_case": "nominal" },
{ "name": "Max. von Mises stress", "value": 182.5, "unit": "MPa", "limit_min": null, "limit_max": 250,
"solver": "CalculiX", "solver_version": "2.21", "note": "" }
] }
A bare array works too. name and value (a number) are required;
unit, limit_min, limit_max, load_case, solver, solver_version and
note are optional; other keys are refused. The CSV has the columns
name,value,unit,limit_min,limit_max,load_case (see above). A value with the
same name and load case is updated. A malformed file refuses the whole
check-in with the place of the problem (results[1].value must be a number,
Row 3: …, JSON line and column) – nothing is half-written, the revision stays
checked out.
Extraction templates. Managers of a project group (and administrators)
define templates on the group’s page, or through the REST API. A template
finds one number in a small text file (.txt, .csv, .log, .out,
.dat, .rpt, .html, Fluent .trn and .jou, Nastran .f06, .f04 and
.pch, STAR-CCM+ .java, … – the first 2 MB are read, and with pick = last
the last 2 MB of a larger file as well). An HTML report is
not read as markup: the template sees the text TeamCraft extracted from it (see below), one line per table row with the
cells separated by |, so
Maximum stress \| ([-+0-9.eE]+) \| MPa finds the row
Maximum stress | 152.4 | MPa, and a csv template reads such a table
with | as the separator (always for an HTML file, whatever the title and
paragraphs above the table contain; an empty header cell keeps its column).
| Field | Meaning |
|---|---|
name | the result’s name (with load_case: the key of the value) |
file_pattern | file names it applies to, * and ?, case-insensitive, e.g. *.log |
method | regex: the first capture group of pattern on a line is the number; csv: the column csv_column (header name), optionally only the row where csv_key_column = csv_key_value; table: the same for a whitespace-separated table with quoted column names (Fluent’s report-file-*.out) |
pick | last (default) or first match – a solver log reports the final value last. last reads the end of a file larger than 2 MB first (its last 2 MB, from the first whole line on) and falls back to the beginning; a csv/table header always comes from the beginning. first reads the beginning only |
unit, lower_limit, upper_limit, load_case, solver | stored with the value; the limits decide pass or fail |
enabled | switch a template off without deleting it |
Example: Pressure drop:\s*([-+0-9.eE]+)\s*Pa on *.log. Regular
expressions run in linear time (no backtracking) and are limited in size, so a
hostile pattern or file cannot hang the server. The value is shown with its
source (solver.log:412) in the Solver column. For an HTML report the line
number refers to the extracted text – the text read_file_text shows –, not
to the line in the HTML file. A template that does not
match does nothing; templates belong to one group and apply to simulations of
that group only. Values typed in by hand are overwritten by a template or the
exchange file with the same name and load case on the next check-in.
A value found in the end of a large file shows its place counted from the
end (run.trn: line 3 from the end), because the lines before it were not
read.
Fluent report files. A report file (report-file-0.out, written by the
report definitions of a run) looks like this:
"report-def-0-rfile"
"Iteration" "pressure-drop" "flow-time"
("Iteration" "pressure-drop" "flow-time")
1 2.53e+02 0.001
2 2.49e+02 0.002
A table template with csv_column = pressure-drop and pick = last reads
the value of the last row (2.49e+02); with csv_key_column = Iteration and
csv_key_value = 500 it reads the row of iteration 500 instead. STAR-CCM+
exports its monitors and reports as CSV (csv method). For Simcenter 3D /
Nastran a regex template on the .f06 works for numbers that stand on one
line; the tables in the middle of a large .f06 are better written to an
exchange file by a post-script.
Values from a report (assistant)
A CFD or FEA report is usually a PDF or an HTML page. TeamCraft reads the text
of both when they are uploaded, so the assistant can read it with
read_file_text:
- PDF: the text layer, in an isolated process with a time, size and memory limit: at most 32 MB, 100 pages and 200 000 characters. Scanned PDFs have no text; there is no OCR.
- HTML (
.html,.htm– the report generators of many FEA/CFD tools write them): the readable text, in the same isolated process and with the same limits (the first 16 MB of the file, 200 000 characters). A table row becomes one line with the cells separated by|(Equivalent (von Mises) stress | 0.0213 | 152.4 | MPa); numbers, signs, exponents and units stay as written, character references (°, ,°) are decoded. Scripts, styles, comments, embedded (base64) images and hidden elements (hidden,aria-hidden="true", inlinedisplay:none/visibility:hidden) are skipped – hidden text is not part of the report and could carry instructions for the assistant. The charset comes from a byte order mark or<meta charset>, else UTF-8, else Windows-1252. Only the HTML file itself is read: images, style sheets and linked pages (a report with areport_files/folder) are never fetched or followed – attach a sub-page as its own file if its numbers are needed. MHTML (.mht) and ZIP-packed reports are not read. - Text (
.txt,.csv,.tsv,.log,.md, the solver logs, report files and scripts.out,.trn,.jou,.f06,.f04,.pch,.java, …): the text itself, line by line with its decimal numbers (the first 4 MB, 200 000 characters). A larger file keeps its end as well – the last 64 KiB (at most 50 000 characters) after the marker[… middle of the file left out …]– where a solver writes its status and final values.
The Fill with AI… button on the Results card (shown to editors of the group when the server has an assistant and the group has not switched the assistant off) opens a box: What to extract (the values you want, optional – empty means every key number of the report), Limits to use (optional, for example “upper limit 250 MPa for stress”, applied only where it fits a value you named) and the report files to read (PDF, HTML or text, all ticked). Propose values is the request for drafts. Values you asked for that the report does not contain are reported as Not found and are not proposed; numbers are taken as written, never converted. Proposing works without a check-out, accepting needs it.
The quick action Fill result values from the report on the simulation page (drafts must be allowed) – or the MCP
tool propose_result_values – proposes
the key numbers the report states. A proposal is a draft: it names the
file version and quotes the text it was read from (the quote must stand in
the report, so numbers cannot be invented from nothing) and appears under Proposed from reports on the Results card.
A CAD editor of the group accepts it (the revision must be checked out to them, as for any value;
the value then shows the file as its source) or rejects it. The assistant
never writes a final value and never releases anything; every proposal,
acceptance and rejection is audited. Compare every number with the quote
before accepting – the model can misread a table.
Results in 3D
Result files in open formats open in the browser’s 3D viewer like CAD previews – no ParaView needed. View result in 3D on the Results card (or 3D next to the file) shows the outer surface of the mesh coloured by a result field, with the field’s minimum and maximum above the canvas.
| Format | Read |
|---|---|
Legacy VTK (.vtk) | ASCII and BINARY, UNSTRUCTURED_GRID and POLYDATA, file versions up to 5.1 |
VTK XML (.vtu, .vtp) | ascii, inline binary and appended (raw/base64) data, zlib compression, all pieces |
CalculiX (.frd) | ASCII files: nodes, elements and every nodal result block (DISP, STRESS, NDTEMP, …) |
How the preview is made:
- Surface. Volume elements (tetrahedra, hexahedra, wedges, pyramids, quadratic ones by their corner nodes) contribute only the faces no other element shares; shells and polygons are drawn as they are, beams and lines are not. Interior nodes are dropped.
- Fields. Point data is shown as it is: one-component values directly,
vectors as magnitude, six- or nine-component tensors as von Mises
equivalent. Cell data is averaged to the nodes (shown as name (cells)).
Up to eight fields are carried; the viewer offers them in a selector, the
active scalars of a VTK file (or
STRESSfor.frd) first. A CalculiX block that occurs for several increments shows the last increment. Nodes without a value are grey. Units are shown when the file names them – VTK and FRD files usually do not. - Limits. Up to 20 million nodes and cells; surfaces with more than two
million triangles are thinned out evenly (gaps are visible), surfaces with
more than eight million are refused. Collapsed elements (a hexahedron
written as a wedge, …) are matched by their distinct corner nodes. Structured
grids (
.vts,.vtr, image data), time-step collections (.pvd,.vtm), binary.frd, Exodus II and CGNS are not previewed – export the step you want to show as.vtu(ParaView: Save Data, OpenFOAM:foamToVTK, CalculiX:*NODE FILE, OUTPUT=ASCIIor ccx2paraview).
Like all previews, the conversion runs once per file version in a separate process with a time limit and is cached; damaged files show an error message instead of a model.
Large results
Result files can be huge, and TeamCraft does not read them fully. A file above
TEAMCRAFT_PREVIEW_MAX_RESULT_MB (default 64 MB) is stored and downloadable
but gets no 3D preview (the page says so); smaller ones are converted in an
isolated process with a time and memory limit. Numbers are never extracted
from solver output on their own. To get the key figures into the Results
table, type them in, import a CSV/Excel table, or let a script or an agent
fill them in from a report. For pictures, attach rendered images (PNG, JPEG
or WebP, at most 8 MB each): they appear as a small gallery on the Results
card of the revision.
Comparing results
Compare with another revision on the Results card puts the values of two revisions of the simulation side by side – matched by name and load case, with the difference, the relative change and the pass/fail status of both.
Finding simulations by their results
The item list (Result values and Result name in the filter bar, result=
and result_name= in the URL and in GET /api/v1/items) looks at the result
values of the latest revision of simulations:
result | Matches simulations with at least one value that |
|---|---|
failed | is outside its limits |
passed | has limits and is within them |
above_limit | is above its upper limit |
below_limit | is below its lower limit |
result_name is a case-insensitive “contains” on the value name and can be
combined with result (the same value must match both), the type, project,
state and text filters. An unknown result is refused by the REST API.
Only items the user may read are found and counted: restricted groups stay
out of the list and its total.
The dashboard shows a card Simulations to check: simulations the user may read whose latest revision (not obsolete) has a failed value or is out of date (see above), ten at a time, with a link to the search. Bases the user may not see never make a simulation appear out of date.
Limits of version 1
- Result values are not extracted from solver result files (by design:
large results are never parsed fully) – use the exchange file, templates
on small text files, a CSV/Excel table or the REST API; VTK,
VTU/VTP and
.frdfiles are previewed in 3D (see above), the other result formats are stored and recognised, not parsed. - Large result files go through the normal upload (
TEAMCRAFT_MAX_UPLOAD_MB); chunked, resumable uploads and a retention rule for intermediate results are open work. - The desktop app shows simulation items and their files; creating a simulation from a part revision (web: done) and checking out the basis files are planned.
As-built records
Real hardware is rarely built exactly to the design: a bore ends up a little too large and is accepted, a purchased part is replaced by a second-source part, a weld is reworked. An as-built record keeps what one physical unit really is, next to the design it was built to. The background and how other systems solve it are on the research page As-built records.
A unit is an item
An item of type As-built (numbers AB-000001, …) is one physical unit
or one lot. It has the usual lifecycle, revisions, check-out/check-in, files (photos, measurement reports, a 3D scan,
the traveller) and change orders. A
new revision of the as-built item is a new state of the same unit – after
a rework, a repair or a retrofit; the older revision stays for traceability.
On top of that an as-built revision carries
- a build record – the design revision (part, assembly or software) the unit was built to, the serial or lot number, the build date and a note;
- deviations – how the unit departs from its design and what was decided;
- its BOM – the regular BOM of the revision, holding what was really installed.
Like BOM lines, build record and deviations belong to the revision: they change only while it is checked out by you, Revise copies them, and they are frozen at release.
Recording a built unit
On a part, assembly or software revision, the Built units card has Record a built unit: serial or lot number, build date, optionally the item number, name, note and project group of the new as-built item. TeamCraft creates the as-built item (revision A, checked out by you) with its build record and, for an assembly, a copy of the design’s BOM – in one step. Edit that BOM to what was really installed; Compare BOM with the design shows the differences.
An as-built item created with New item starts without a build record; enter it in the Built to card of the revision (item number and revision of the design – empty means its latest revision).
Rules:
- The serial number is unique per design item (letters compared without case). The revisions of one unit share it; another unit with the same serial is refused and named (or called “another unit” when it is in a group you may not read).
- The design revision may be in work, in review or released – prototypes are often built before the release. An obsolete design revision is refused for a new unit or a new build record (“record the unit against a current revision of the design”). A unit that was already built to a revision that later became obsolete keeps its record and may still correct serial, date and note.
- The as-built item may live in another project group than the design. Copying the BOM out of a restricted group needs a manager of that group, as for Save as.
Deviations and their disposition
Each deviation has a kind (dimension, material, component, process,
other), the feature (e.g. Bore Ø12 H7), what was designed and what
was built (text, e.g. 12.000 +0.018/0 → 12.035), a note and the disposition:
| Disposition | Meaning |
|---|---|
| Open | not decided yet – blocks the release |
| Use as is | accepted as built (usually an engineering/MRB decision) |
| Rework | brought back to the design |
| Repair | made usable, but not to the design |
| Scrap | the unit (or the affected part) is not used |
An as-built revision holds at most 1000 deviations.
Release
A change order with an as-built revision cannot be submitted while that revision has no build record or an open deviation. The engineering decision on the deviations is the review of the change order itself.
A project group can ask for more: with A reviewer confirms each “use as is” deviation in its review policy, a reviewer of the change order confirms every use as is concession on its own (Confirm in the deviation table) before their approval counts, and the person who recorded the deviation cannot confirm it. See Read permissions and review policies.
The design side
The Built units card of a part, assembly or software revision lists the units built to any revision of the item – the latest revision of each unit, with serial number, the design revision it was built to, open deviations and a Newer design marker when a later design revision was released after the one the unit was built to. Units in groups you may not read are only counted. A design item that units were built to cannot be deleted.
When you may read the unit but not the design (the design is in a restricted group), the build record shows a “restricted item” placeholder instead of the design’s number, name and revision.
What is not included
- Pack and Go (
…/package) of an as-built revision contains its files, BOM and manifest like any other revision, but not the build record and the deviations; read those withGET …/as-built(REST) orget_revision(MCP) and keep the export next to the ZIP if another system needs them. - Not yet: retrofit campaigns (change propagation to built units), serial numbers on as-built BOM lines, numeric tolerance checks and the import of measurement reports.
REST API and assistants
The endpoints are listed in the REST API
reference. The MCP tool get_revision returns as_built for an as-built
revision and built_units for a design (MCP server).
Desktop app
TeamCraft Desktop (desktop/, Tauri 2) is a rich client for Linux, macOS and
Windows: a navigator tree of the project groups, object tabs (overview,
files, structure, where used, references, graph, changes, history, 3D viewer), a worklist with
check-outs and reviews, and a local working folder:
-
New (
Ctrl/Cmd+N) creates an item together with its files: choose the CAD files first, name and type follow from the first one; -
Check out takes the lock, copies the files into the working folder and makes them writable. For an assembly it also downloads every part of its structure that is missing (latest version, read-only) – only the assembly is locked; parts you changed locally, or whose place is taken by another file, are never overwritten and are listed in the report (Settings → Workspace switches this off);
-
Files / All items / Structure in the navigator head: Files shows the folders and files of the project group as in its working folder (see below); Structure shows, per project group, the assemblies and the parts no BOM uses; an assembly expands into its parts one level at a time with quantity, state, lock owner and ⧉ N for a part used in several assemblies;
-
Get… on a revision copies the item with everything it consists of – its BOM resolved with a revision rule and the parts its CAD files reference – so the assembly opens in the CAD program (see below);
-
the download icon next to Projects in the navigator (Tools → Get Project Groups…) copies the files of every item of chosen project groups into the working folder – for new team members;
-
the folder icon next to it (Tools → Import CAD Folder…) brings an existing CAD folder in: scan first (nothing is uploaded), then one item per part or assembly with its drawings and exports; every file keeps its folder, so the working folder later looks like the original tree. An interrupted import continues where it stopped – an item whose files did not arrive is completed, none is created twice; the app remembers per project group what it imported, so the same folder can also be imported into another group. BOM lines come from the references in the assembly files – for NX also from
.prtfiles (such an assembly is imported as a part item, NX does not tell parts and assemblies apart by the file type). Their quantity is how often the assembly places the part where the file tells it (SOLIDWORKS 2015+, NX, Inventor and STEP assemblies with external part files; instances excluded from the BOM and suppressed Inventor occurrences do not count, a part that is only placed that way gets no BOM line but stays referenced); for other formats (Solid Edge, …) it is 1 with the note check the quantity – set the real quantities with Import BOM… from the CAD program’s BOM export. Help → How to Import a CAD Folder is the step-by-step tutorial (Pack and Go, unique file names, check and release); -
Import BOM… (in the Structure tab and the More menu of a revision you have checked out, or Tools → Import BOM as New Assembly…) reads a BOM exported by the CAD program – CSV, Excel, ODS or a STEP assembly – with the same column recognition as the web import: the preview shows the lines, how many items are new or existing and the notes (for example a quantity that is not a number); Import adds the lines in one step, creating the missing items. Part numbers from the file or New numbers (the group’s numbering scheme), and whether items with the same number are reused, are chosen in the dialog;
-
Quantities from CAD… (next to Import BOM…) compares the BOM with the instances counted in the revision’s CAD files (SOLIDWORKS 2015+, NX, STEP assemblies with external part files): missing lines and lines whose quantity differs – such as the quantity 1 of an assembly imported before the counts existed – are ticked; Apply adds and corrects them in one step (the same plan as Create BOM from CAD references… in the web UI). A quantity the CAD file does not tell never replaces an existing one;
-
the Graph tab draws a navigable reference and BOM graph around the revision: the focused item in the middle, its BOM children and references below (ref), the assemblies that use it above (uses). Every node is a card with the item’s preview image, file name and number. Node colour is the lifecycle state (the legend names them), node size (and the ⚙ badge) the item’s own BOM complexity; the small ×n in the top-left corner of a child is how many instances of it the assembly contains (summed over its BOM lines, ×1 included). Click a node to go to it: the tab becomes that item – a sub-assembly shows its own graph, anything else its overview – and ← Back, always in the same place (left of the tab row), leads back the way you came, one step per click; clicking the assembly you came from is the same step back. A click on the item in the middle opens its own overview.
Ctrl+click (⌘+click on macOS) opens a node in a tab of its own instead. The mouse wheel zooms on the cursor, dragging pans – also when the drag starts on a card, which then does not open – and a double-click on the background fits the graph again; while previews are still loading the view fits itself until you zoom or pan. Large assemblies (more than 8 neighbours on a side) are laid out in rows, and only the first 48 neighbours get a preview and their complexity (one request each), the rest keep a basic card. The graph is drawn by the app itself (SVG with an HTML layer for the cards, no external engine), in the colours of the chosen skin; -
the Workspace tab shows per file where it is, whether it is up to date, modified, outdated, in conflict, new, missing or a name clash, whether it is read-only, and who holds the lock for how long; opening an outdated file or one somebody else has checked out asks first;
-
Check in… uploads the selected files as new versions and releases the lock – based on the versions they were copied from, so a version somebody else checked in meanwhile is never overwritten (see Concurrency) – and makes the files read-only again; files above 32 MiB are sent in resumable chunks – a dropped connection resumes where it stopped (see Large files). The dialog stays open with the progress while the check-in runs, and Cancel stops it at any point until the last byte is sent: before the next STEP export, between two chunks of a large file (the chunks on the server are discarded – a cancelled upload starts over next time) or half-way through the request with the small files. Nothing is stored then, the revision stays checked out to you and your local files are untouched. Once every byte is sent the server finishes the check-in on its own; the dialog says so (too late to cancel);
-
Check in all in the worklist first lists the check-outs with local changes (modified or new files) and offers to check them in first with their files; Only the others checks in just the check-outs without local changes. Check-outs with a conflict always stay checked out – merge them in their Workspace tab. Sign out (and Change server) asks the same before it signs you out: Check in and sign out, Park and sign out or Sign out anyway;
-
Park… (Workspace tab, More ▾, and a button of the check-in dialog) uploads the ticked files as new versions but keeps your check-out – work in progress is on the server even if your computer is lost, and nobody else can change the revision meanwhile. Same conflict check as a check-in; an empty comment is stored as Work in progress (parked, still checked out);
-
when somebody else released your check-out (an administrator, or a manager of the group with a reason), the Workspace tab says Released by X; your local changes are kept and need a new check-out, with the date and the reason – until the revision is checked out again;
-
Dependents to check (worklist and home) shows one line per dependent revision with the number of changes it has to be checked against; the arrow lists them (a click opens the item that changed). On the home page every dependent has Open, Revise and Acknowledge all… (one note for all of them, see Change propagation) and an Acknowledge… per change;
-
Request change… on a revision tab and Request… on a file (Files and Workspace tabs) raise a change request with the item, revision and file name filled in and an optional screenshot or PDF; the worklist lists the requests assigned to you and your open ones;
-
on Windows with Inventor or Solid Edge installed, STEP (workspace) and the check-in option create STEP exports for the 3D preview with that program; the Files tab shows the preview image stored in native files.
-
with a Preferred CAD program set on your account page in the web UI, the Files tab of a revision collapses the native files of other CAD programs (see Files); the app reads the setting at sign-in.
-
Agent (rail, View → Agent,
Ctrl/Cmd+4, or Agent in a revision or change tab) is the app’s one place for AI. It runs on one of two backends, decided by the server’s policy and your choice:- Company AI – the server’s assistant, configured by the administrator (the key stays on the server). It is the Ask TeamCraft conversation of the web UI, inline in the Agent tab: it knows the last revision (and its section, BOM with revision rule), change order or change request tab you opened, offers quick actions that fit it (Draft release notes, Check before release, Brief me for this review …), has the Allow drafting checkbox, Cancel and New conversation, and keeps one conversation on the server that survives restarts.
- Local agent – an AI agent on your computer that you set up under Settings → Agents…, for example Claude Code through its ACP adapter or Gemini CLI. None is set up by default. The app starts the agent in the workspace folder and gives it TeamCraft’s tools with your rights. The conversation streams in the tab, with tool calls, the agent’s plan, permission prompts (Allow once / Reject), Cancel and quick prompts (release notes, release check, change review, worklist triage). Through the app the agent may only read text files in the workspace folder; it gets no write access and no terminal.
When the server offers both, Company AI / Local agent in the Agent toolbar switches between them (each keeps its own conversation); the app remembers the choice per server and user, and starts with the local agent if you have set one up, otherwise with the company AI. If the administrator turned local agents off (
TEAMCRAFT_LOCAL_AGENTS=off), the Agent view runs on the company AI only, the app ends a running agent and refuses to start one; with no company AI configured either, the view says that AI is switched off by the administrator. The app asks the server again when the Agent view opens, every minute and before every message to a local agent, so a changed setting takes effect without a restart. See ACP agents.
Sign-in runs through the browser (single sign-on or password, confirmed on a
TeamCraft page) or with a personal access token. The app keeps the token in
the system keychain (Windows Credential Manager, macOS Keychain, GNOME Keyring
or KWallet on Linux). Where there is none, it uses a file only your user
account can read and says so at the start and in Settings. An address with
plain http:// to another computer gets a warning when you connect: the token
and all data would cross the network unencrypted – use the https:// address
of the server. The app uses the same skins
and 3D viewer as the web interface; the models are converted by the server,
and Loading or the reason a model cannot be shown appears on the canvas of
the 3D viewer tab (see Files). Settings → Appearance: TeamCraft (the
default) follows the light or dark
appearance of the operating system and switches with it, TeamCraft Light
and TeamCraft Dark stay fixed, the distribution skins are dark. Settings → Change password… changes the password of
a local account (it
asks for the current one; wrong ones count like failed sign-ins, and your web
sessions end); single-sign-on accounts do not see it.
The window is built for daily work: object tabs on top, under them the
toolbar of the open object with its one next step filled (see
The toolbar) and the rare tools under More. The navigator shows a state as its symbol (a check:
released, a ring: in review, a half-filled ring: in work, – obsolete, the
name in the tooltip); the search field is Ctrl+F (⌘F).
Moving tabs: drag an object tab to a new place (a bar shows where it
lands; the tab keeps its state, and a plain click still just selects it).
Without a mouse: Ctrl+Shift+PageUp / PageDown move the active tab left or
right, Ctrl+PageUp / PageDown (or Ctrl+Tab / Ctrl+Shift+Tab) switch
to the previous or next tab, and the arrow keys move between the tabs when
the tab strip has the focus. The right-click menu of a tab offers Move to
start, Move to end, Close, Close others and Close tabs to the right.
The toolbar
The toolbar sits right below the tab strip and acts on the active tab. Its buttons are grouped, and they keep their place whatever state the object is in; a button that does not apply right now is greyed out, and its tooltip says why (for example Check out first or Already in change order CHG-000002). The one highlighted button is the most likely next step:
| Revision | Highlighted |
|---|---|
| in work, not checked out | Check out |
| checked out by you, with local changes (or the workspace could not be read) | Check in… |
| checked out by you, no local changes | Release… (when it can be released, otherwise Check in…) |
| in review, obsolete, or checked out by somebody else | nothing |
| released | Revise |
The working folder of your check-out is looked at again whenever the tab is shown or the app window gets the focus back – save in the CAD program, switch back, and Check in… is highlighted.
- revision tab: Lock (Check out, or Check in… and Cancel check-out / Force unlock while checked out) · Work (Edit, Get…, Open folder) · Lifecycle (Release…, Revise) · Collaborate (Request change…, Watch, Agent);
- change tab: Review (Approve change highlighted, Reject – the comment field of Your review goes with it, required for a rejection) while your review is pending · Lifecycle (Submit for review, highlighted on your draft – disabled with the reason as tooltip while the draft has no revision or no reviewer –, Withdraw, Cancel change for a draft or rejected change, at once) · Collaborate. On your draft the Reviewers panel has Add reviewer (user names, any CAD editor or administrator – not you), Remove per reviewer and the suggested reviewers;
- change request tab: Triage (Start triage or Create change order highlighted, Take up again) · Requester (Attach…, Withdraw) · Collaborate.
Release… creates a change order for the revision – with its unreleased dependencies by default – assigns the reviewers you tick (the other CAD editors of the project group) or type under Other reviewers (user names of any CAD editor or administrator) and submits it. Your own user name is refused before anything is created (four-eyes principle). When nobody else in the group can review, the dialog says so and creates a draft instead of submitting – a release always needs a second person, so a server with a single account cannot release (see Separation of duties). If a revision or a reviewer is refused, the half-made draft is cancelled again and nothing is left behind; only a refused submit (for example an unreleased dependency) keeps the complete draft and opens it.
Rare actions are in More ▾ at the end: Pack and Go…, Open in browser, Refresh (F5) and Mark obsolete…. The
menu works with the keyboard:
Enter, Space or ↓ opens it, ↑/↓, Home/End move, Enter runs
the action, Esc closes it and returns to the button; a click outside closes
it too.
The Overview of a revision shows At a glance next to the properties: the preview image stored in an Inventor, Solid Edge, SolidWorks or NX file (with the way into the 3D viewer) and the number of files, BOM lines, where used, references, change orders and open change requests – a click opens the matching sub-tab.
Projects (the rail button below Agent, Ctrl/Cmd+5) is where you
choose the active project group: your groups first, then the others.
CAD editors create a new group there (New – you become its manager; tick Restricted to keep it to its members), and
the page offers Get… (copy
groups into your workspace) and Import CAD folder…. The choice is kept on
this computer.
Home is a file browser of the active group, in three views:
- Files (the view Home starts with): the folders of the group and the
files in them, as in its working folder – folders first, then the files
by name the way a file manager orders them (without regard to case,
Part2beforePart10), each with the state and the lock of its item. The file name comes first and the number and revision after it, so a narrow navigator cuts the number, not the name. A folder opens its own level – a large group is never loaded as a whole, and a folder with more than 500 files offers show … more. The files are those of the latest revision of every item; items without files are listed below the files at the top of the group. The number next to the order is the rows of the whole group. A click on a file opens its item; - All items: every item in one list, the name first (the first 500 by number);
- Structure: the assemblies with their parts.
Files and All items are sorted by name (or by number, type or state – the choice is kept). Change… goes back to Projects. New and Import CAD Folder… start with the active group. With a server older than the Files view, Home shows All items instead.
Home, Projects, Refresh and the dialogs that offer project groups (New, Get Project Groups…, Import CAD Folder…) load the groups and your memberships from the server again, so a group created or joined in the web UI shows without restarting the app.
Keyboard: Ctrl/Cmd+N new item, Ctrl/Cmd+F search, F5 refresh,
Ctrl/Cmd+1/2/3/4/5 Home, Worklist, Changes, Agent, Projects, Ctrl/Cmd+W close the tab,
Ctrl/Cmd + + / - / 0 (or Ctrl + mouse wheel) zoom.
How files appear on your computer
There is one working folder per project group, and inside it the files sit in the same folders as in the original CAD tree – one copy of each file. The revision is information TeamCraft keeps about the file, not a folder level:
Documents/TeamCraft/ ← the workspace (Settings → Workspace)
├── ROVER/ ← project group
│ ├── chassis/
│ │ ├── chassis.asm ← ASM-000001 rev. B, checked out by you (writable)
│ │ └── brackets/
│ │ ├── bracket.prt ← PRT-000001 rev. A (read-only)
│ │ └── bracket.step ← new STEP export next to it
│ └── drawings/
│ └── chassis.drw
├── LANDER/
│ └── …
└── .teamcraft/
├── state.json ← per file: item, revision, file id, version, SHA-256, checked out
└── migration.log ← what the move from the old layout did
The folder of a file is stored with it on the server (Folder on the
revision page, folder in the API). The folder import sets it from the
scanned tree; uploads without a folder put new files at the top of the
group, and a file keeps its folder when new versions arrive. While a
revision is checked out, the revision page can move a file to another
folder.
Read-only rule. Files you have not checked out are read-only on disk (the read-only attribute on Windows, no write permission on Linux and macOS). CAD programs then open them read-only, and nobody edits a file that cannot be checked in. Check out makes the files of the revision writable; Check in uploads the changed ones and makes them read-only again; Cancel check-out does the same without uploading. Files you changed are never made read-only or overwritten by TeamCraft.
Check-out of an assembly. The lock is taken on the assembly only; its
structure is then fetched like Get does under the latest rule, in the same
progress dialog. Per file: missing – downloaded read-only; up to date –
skipped; an unchanged older copy – updated; changed here, checked out by you,
or a different file at the same place – kept untouched and listed (kept – your
local changes, name clash). Cancelling leaves no partial file. Add files. Add files… (Files section and More
▾ of a revision) takes
files from a file picker, checks the revision out first if you have not,
and copies them into its working folder – the originals are never moved
or changed. New files go into the revision’s main folder or a sub-folder of
it. A file with the name of an existing file becomes its next version and
asks first (unticked files are not added). The copies appear as new
files in Check in… – also files with an unrelated name, which TeamCraft
remembers in .teamcraft/added.json until they are checked in – or tick Check in now. The Files section explains why
the button is disabled:
checked out by someone else (add after check-in), in review, or released (frozen: Revise and add the files to the new
revision). Dropping files
onto the window is not supported yet.
Get. Get… on a revision resolves its structure on the server – the
multi-level BOM with the revision rule you choose (latest: the newest
revision of every part, also in work; released: the newest released
revision) plus the items whose files its CAD files reference (an assembly
names its parts, a drawing its model) – and downloads only what is
missing or outdated, compared by SHA-256. Every file is checked against
its SHA-256 after the download and recorded at once, so a Get that was
cancelled or interrupted continues where it stopped – also inside a large file:
the bytes already received stay next to the file (<name>.<sha>.teamcraft-download, ignored by the working folder), the
next
Get asks the server only for the rest and checks the SHA-256 of the whole
file before it puts it in place. A file that changed on the server
meanwhile is downloaded completely again. The status bar shows
the progress with a Cancel button.
Nothing is overwritten.
- A file you changed locally is kept (and listed) when the server has a newer version – conflict in the Workspace tab.
- Two items with a file of the same folder and name (
chassis/bolt.prtin two items) cannot share one place: the second is reported as a name clash and not downloaded. Give one of them another folder on the revision page. - A file in the way that did not come from TeamCraft is reported the same way.
Why not the browser? A browser download saves single files into the Downloads folder: no folder structure, so the CAD program cannot find the parts of an assembly; no record of which version a file is, so changes cannot be checked in safely; and no read-only protection. Browser downloads are fine for one file or for looking at something – CAD work goes through the desktop working folder. Pack and Go (ZIP) is for sending a structure to someone outside TeamCraft: after the save dialog a progress dialog shows how much has arrived, and Cancel stops it – also while the server is still packing. The ZIP is written under a temporary name next to the target and renamed only when it is complete, so a cancelled or failed Pack and Go never leaves a half ZIP behind.
Moving an older workspace. Earlier versions of the app kept one folder
per revision, <group>/<item>/<revision>/ (and before that
<item>/<revision>/). After the sign-in the app finds these folders and
offers to move the files to their places in the new layout. Files whose
place is already taken by a different file, and older revisions of an item
whose newer revision takes the place, stay where they are; files you
changed are moved first. Identical copies are removed, emptied old folders
too, and every step is written to .teamcraft/migration.log.
Install or run it
The installers of every version are on the Releases page (see Download in the README for the file of each system). From source:
cd desktop
cargo run # Linux needs the WebKitGTK development packages
The platform guides list the system packages.
Try TeamCraft on this computer
The downloads with local server (Windows x64 -setup.exe, macOS .dmg,
Linux .AppImage, named TeamCraft-with-local-server_…) also contain the
server, as teamcraft-server, and PostgreSQL’s programs. Their connect screen
offers Try TeamCraft on this computer below the server address:
- The app starts
teamcraft-server local– a private PostgreSQL cluster and the server, both only on127.0.0.1– and waits until it is ready (the first start creates the database). - The server’s address
http://127.0.0.1:<port>becomes the app’s server, and the browser sign-in opens. On the first start the browser shows Create the administrator; afterwards it continues to the confirmation of the app’s sign-in. - While the local server is the configured server, the app starts it again at every launch (the splash says Starting the local TeamCraft server …). It keeps the port, so the address and the token stay the same; if another program took the port meanwhile, the app picks a free one and follows.
The server stops when the app ends – also when it crashes: the app keeps a
pipe to the server’s standard input open, and the server shuts itself and
PostgreSQL down when it closes. Its output goes to local-server.log in the
app’s settings folder; the data is in the per-user data folder shared with
teamcraft local – see Try it on your computer. The
regular installers do not show the button.
Update notice
Once a day at most, the app asks GitHub for the latest TeamCraft release. If it is newer than your version, the status bar shows TeamCraft x.y.z is available – a link to the release page in your browser. The app never downloads or installs anything itself; install the new version as you installed this one. × hides the notice until the next version; Settings → Updates switches the check off (for example on computers without internet access).
Crash reports
When the app crashes – a panic in the app, an uncaught error in its user
interface, or a session that ended without closing normally (for example the
web view crashed, the app hung and was force-quit, or the computer lost
power) – it writes a report into the crash-reports folder of its settings
directory. Before the report is written, the home folder (and with it your
user name), server addresses and sign-in tokens are removed. The report holds
no item data and no files.
At the next start a dialog shows the whole report:
- Report on GitHub… opens a new issue at the TeamCraft repository in your browser, prefilled with the report. You read it, add what you were doing, and submit it yourself; a GitHub account is needed. The full text is also copied to the clipboard, in case the link had to shorten it.
- Copy puts the report on the clipboard, for example for an e-mail.
- Delete report removes it without sharing.
Nothing is ever sent without that choice. Closing the dialog keeps the report for the next start. Don’t ask again (or Settings → Crash reports) stops the question; the reports then stay on the computer only. Background and the alternatives that were considered: Crash reports from the desktop app.
Ask TeamCraft (assistant)
Ask TeamCraft is an optional assistant built into the server. An AI model (Claude, or an OpenAI-compatible or local model, as configured by your administrator) answers questions about your PDM data. It looks things up with the same tools that the MCP server offers to external AI clients, and it uses your rights. It answers on the server, so nothing has to be installed on your computer.
It is meant for the reading and summarising work around a release, not for CAD generation:
| Where | Quick action | What you get |
|---|---|---|
| Revision page | Draft release notes | what changed compared with the previous revision: attributes, files, BOM, open points |
| Revision page | Check before release | a checklist: blocking items (unreleased BOM children and references, check-outs) and warnings |
| Revision page | What breaks if I change this? | the assemblies that use the item (released ones first), open change orders and requests that already touch it, simulations that would go out of date, what to check first |
| Revision page | Are the simulations still valid? | the simulations based on the item: which are out of date and why, failed results, what to re-run |
| Simulation page | Fill result values from the report | reads the report (PDF or text) of the revision and proposes result values – drafts with the file and the quoted text; you accept or reject them on the Results card (drafts must be allowed); the Fill with AI… button on the Results card does the same for the values you list |
| Change order page | Brief me for this review | what changes, the impact on dependents outside the change, risks, questions for the author |
| Dashboard | What needs my attention | an ordered to-do list from your worklist: reviews, impacts, old check-outs, your drafts |
| Everywhere | Ask | any question, e.g. “Which assemblies use PRT-000012, and are they released?” |
Ask also covers change requests and simulations, for example:
- “What problems were reported on PRT-000012?” – open and closed change requests on the item with their discussion.
- “Prepare my request triage” – a summary of the change requests assigned
to you with a suggested next step (like the MCP prompt
triage_requests). - “Which simulations are out of date after yesterday’s release of PRT-000012?” – the simulations based on it and why they no longer match.
- “Summarise the results of SIM-000003 rev B vs A” – changed, added and removed result values with delta and pass/fail.
- “What does the spec of PRT-000012 say about the coating?” – the assistant reads the text that TeamCraft extracted from the attached files for the search (STEP and IGES strings, text, CSV, JSON, XML, KiCad and similar formats, the text layer of PDF reports and the readable text of HTML reports with table rows as lines; not images, scanned PDFs or binary CAD files). It can read only files that you may open.
- “Draft a change request from my notes: …” – with drafts allowed (see below), the assistant raises the request; otherwise it shows what the request should contain.
Open it with the sparkle icon in the top bar (desktop app: the Agent
view on Company AI, Ctrl/Cmd+4). You can add your own words to a quick action, e.g. “focus on the weld
seams”. The answer shows the tools that the assistant used. Item numbers in
the answer link to the item. The assistant remembers the conversation: see
The sidebar.
The sidebar
Ask TeamCraft lives in a sidebar on every page of the web UI. The sparkle icon in the top bar unfolds a drawer
on the right. It stays
open while you move around, Esc closes it, and your browser remembers
whether it was open (a small localStorage entry, nothing is sent to the
server for that). Drag the left edge or use the arrow keys on it to change
its width. On a phone it is a full-screen sheet.
The icon is only there when an administrator has configured the assistant.
In the desktop app the same conversation is the Company AI backend of
the Agent view (Ctrl/Cmd+4; see
ACP agents): a panel in the Agent tab
instead of a drawer, with the same context chip, quick actions, drafts
checkbox and conversation. Its context is the last revision, change order
or change request tab you opened.
It knows the page you are on
Every page tells the drawer where you are. Above the question box a context
chip shows it, for example PRT-000009/A · bom (latest released):
| You are on | The assistant knows |
|---|---|
| Dashboard | that you are on your start page |
| Items list, search | the search text and filters, and the items shown (at most 25) |
| Revision page | item, revision and project group |
| BOM page | item, revision, the revision rule, the items listed |
| Where-used | the item and the assemblies listed |
| BOM or result comparison | the item and the revision compared |
| Change order, change request | its number |
| Project group | the group code |
| Vault explorer | the group or list, the search text, the selected row and the rows shown |
Click × on the chip to ask without sharing the page (add it back undoes that; moving to another page resets it). Quick actions under the chip fit the page: on a revision Draft release notes, Check before release, What breaks if I change this?, Are the simulations still valid?; on a change order Brief me for this review; on the dashboard What needs my attention. Words like “this” or “here” in your question mean the page you are on.
The page context is only a hint. The server looks every identifier up with your rights before the assistant sees it. An item, change or group you may not read is dropped exactly as if it did not exist: it does not appear in the chip, the answer, an error message or the conversation.
One conversation that stays
Your questions and the answers form one conversation that survives
navigation, reloading and signing in again. Under each question the drawer
shows where you asked it (asked on PRT-000009/A · bom). A follow-up such
as “and which of those are released?” works. New conversation deletes it.
- Only the text of your questions and of the answers is stored (with the quick action and the page label) – never the data that the tools returned. A follow-up looks things up again with your rights of that moment, so nothing you lost access to comes back through a tool. (Your own earlier answers are part of the conversation and stay visible to you.)
- Only you can read it. Administrators see in the audit log that a question was asked (who, which quick action, tools, token counts) but never its text, and there is no administrative view of conversations.
- An idle conversation is deleted after 30 days
(
TEAMCRAFT_ASSISTANT_RETENTION_DAYS); deleting a user deletes the conversation. - While an answer is on its way the drawer shows that it is working and offers Cancel; a cancelled question is not stored.
- The answer does not stream in: it appears when it is complete.
What it can and cannot do
- Read-only by default. It can read what you can read and nothing else.
- Drafts only when you allow them. CAD editors can tick Allow drafting change orders, change requests and comments. The assistant can then create a draft change order, add revisions to one of your drafts, raise a change request or comment on one – only when your question asks for it. It never submits, approves, releases or makes anything obsolete, and it never triages, accepts, defers or rejects a change request. Those decisions stay with people. (Viewers raise and discuss change requests themselves in TeamCraft, or through their own MCP client.)
- Opens the 3D preview for you. Ask “show me the bracket from the rover
arm in 3D”: it finds the item (with the MCP tool
open_3d_preview) and shows an Open 3D preview button under its answer, which opens the browser’s 3D viewer of the best 3D file of that revision (STEP first, then meshes and native lightweight models). When several items match, it lists them and asks which one you mean instead of guessing; when the revision has no file the viewer can show, it says so. The button only ever points to TeamCraft’s own viewer page (/files/<id>/preview): the server checks the link before it is shown, and the sidebar checks it again. It appears under the fresh answer only – the stored conversation keeps the text. In the desktop app’s Agent view (company AI) the button opens the revision tab on its 3D viewer with that file. - Check the answer. It is generated by an AI model from the data that the tools return. Numbers, states and quantities come from TeamCraft, but check them before you rely on them.
- Limits. One question may take up to 12 model steps (configurable) and 4 minutes. Ask a narrower question when a limit is reached.
Privacy
The assistant is off unless an administrator configures an AI provider and an API key (see Configuration). Without one no data leaves the server, and the buttons are hidden.
When it is on, your question, the instruction of the quick action, where you are (the identifiers and search text of the page, only what you may read), the earlier messages of your conversation and the results of the tools it calls are sent to the AI provider that your administrator configured. The dialogs name it (provider and host). Which one receives the data depends on the setup:
| Provider setting | Receives the data |
|---|---|
anthropic | the Anthropic API |
openai | OpenAI, or the Azure OpenAI resource of your organisation |
openai-compatible with a hosted service | that service (Mistral, OpenRouter, Groq, …) |
openai-compatible with a local server | only the model server that your administrator runs (Ollama, vLLM, LM Studio). With a server inside the organisation’s network the data does not leave it |
The data includes item names, descriptions, BOM lines, change order and
change request texts (with their comments) and simulation result values.
Files and attachments are not uploaded; only their names and metadata
are. The API key stays on the server. Browsers and the desktop app never
see it, and the server never sends it to another host than the configured
one. The audit log records each run (assistant.run): who asked, which
quick action, the length of the question, the provider and model, the
tools used, the page kind and identifiers, and the token counts. The audit
log never stores the question, the answer or the search text. The
conversation itself (question and answer texts) is stored in the TeamCraft
database, readable only by its owner, and deleted after the retention time
or with New conversation. The terms of the provider (retention, training use) apply
to what it receives: choose the provider and the plan accordingly.
API
GET /api/v1/assistant → {enabled, provider, model, host, actions: [{id, label, needs}]}
POST /api/v1/assistant
{"action": "release_check", "context": {"item": "ASM-000003", "revision": "B"}}
{"question": "Where is PRT-000012 used?", "allow_drafts": false}
Page context and conversation
context accepts the page context the sidebar sends; every field is
optional and an unknown or invalid one is dropped (except item, revision
and change, which answer 400 when malformed):
{ "kind": "bom", // dashboard, items, item, revision, bom, where_used, compare,
// change, changes, request, requests, simulation, project,
// projects, search, vault, notifications, other
"item": "PRT-000009", "revision": "A",
"change": "CHG-000004", "request": "ECR-000002", "project": "ROVER",
"view": "bom", // section or tab: lowercase letters, digits, - and _
"rule": "released", // revision rule of a BOM view
"query": "bracket", "filters": {"state": "released"}, // text typed on the page
"selected": "PRT-000010", // the selected row
"shown": ["PRT-000010", "PRT-000011"] } // rows shown, at most 25 are kept
The identifiers are hints: the server resolves each with the caller’s
rights and drops what the caller may not read, exactly like an identifier
that does not exist. The answer then carries context: {kind, label}, the
resolved page (PRT-000009/A · bom (released)).
When the run called open_3d_preview, the answer carries links: at most
three {action: "open", kind: "preview3d", url, label, item, revision, file},
where url is always the relative path of this server’s 3D viewer page (/files/<uuid>/preview) – the server drops any
other URL. Clients show
them as buttons; the field is left out when there are none, and the stored
thread does not keep it.
With "thread": true the question joins the caller’s conversation: the
earlier messages are quoted to the model, and the question and the answer
are stored (text only). Without it the call is stateless as before.
GET /api/v1/assistant/thread → {messages: [{role, text, action, context_kind, context_label, created_at}], retention_days}
DELETE /api/v1/assistant/thread → {deleted: true} # new conversation
A conversation belongs to the token’s user. These endpoints take bearer
tokens only; the web sidebar uses GET /assistant/thread,
POST /assistant/chat and POST /assistant/thread/reset with the session
cookie (same-origin only), which are not part of the REST API.
action is one of ask (default, needs question), release_notes and
release_check (need context.item, optional context.revision),
impact, simulation_status and result_values (need context.item), review_brief
(needs context.change) and attention. The answer contains
answer.text (markdown), answer.truncated, tools ([{name, ok}]),
steps, model and the token counts. GET /api/v1/me reports
capabilities.assistant. While the assistant is off the POST endpoint answers 404.
When the AI service is overloaded or busy it answers 503.
ACP agents in the desktop app
Ask TeamCraft runs on the server with the administrator’s provider and key. In the desktop app it is one of two backends of the Agent view (Company AI); the other is an agent on your own computer (Local agent). When the server offers both, Company AI / Local agent in the Agent toolbar switches between them. Each keeps its own conversation: the company AI’s is stored and audited on the server, the local agent’s lives in its process on your computer. The app remembers the choice per server and user and starts with the local agent if you have set one up, otherwise with the company AI.
The local agent speaks the Agent Client Protocol (ACP), which connects clients to agents on your computer, for example Claude Code (through its ACP adapter) or Gemini CLI. The desktop app is the ACP client. The server needs no API key for this. The agent uses its own sign-in or key with its own provider.
Your administrator can turn local agents off for the whole server (TEAMCRAFT_LOCAL_AGENTS=off, see
Local agents), so that AI use
goes through the company AI, Ask TeamCraft, instead of AI tools of your
own (data-loss prevention: the organisation’s provider and contract, the
key only on the server, every question in the audit log and within the
assistant’s limits). The server then refuses every MCP client, also one
you set up with your own token, and the desktop app ends a running agent
and refuses to start one – it asks the server again when the Agent view
opens, every minute and before every message, so this needs no restart. The Agent view (Ctrl/Cmd+4) then runs on
the company AI, automatically and without a local option, with a short
note that your organisation provides its AI centrally – or, if no
assistant is configured, says that AI is switched off by the administrator (local agents off, no company AI).
Set it up
-
Settings → Agents… (or Agent in the navigation rail → Set up an agent…). No agent is set up by default, and TeamCraft never installs or starts one on its own.
-
Add an agent. The presets only fill in the fields:
Agent Program Arguments Needs Claude Code npx(Windows:npx.cmd)-y @zed-industries/claude-code-acpNode.js; npx downloads the adapter from npm the first time; sign in with claudeonce or setANTHROPIC_API_KEYGemini CLI gemini--experimental-acpnpm install -g @google/gemini-cli, then sign in once withgeminiAny other program that speaks ACP over stdio works too. Desktop apps often do not see the
PATHof your shell (macOS in particular). If the program is not found, enter its absolute path, for example/opt/homebrew/bin/gemini. Package names change: check the adapter’s own documentation ifnpxreports an unknown package. -
Open Agent (rail, View → Agent,
Ctrl+4; if it shows the company AI, click Local agent in its toolbar), pick the agent and press Start.
Working with it
- The conversation is a tab of its own. Answers stream in as they are written. Tool calls appear in the conversation and in the navigator with their status (waiting, running, done, failed). The agent’s plan is shown in the navigator if the agent shares one.
- Context: each message tells the agent which revision or change order you last had open. The Agent button in a revision or change toolbar opens the agent with that object as context.
- Quick prompts use the server’s MCP prompts: Release notes and Release check for the open revision, Review this change for the open change order, Triage my worklist anywhere.
- Permission requests of the agent show up in the conversation with Allow once and Reject. The desktop app never grants an “always” permission.
- Read-only TeamCraft tools need no prompt. When the agent asks to run one of TeamCraft’s own read-only MCP tools (search, get an item or a BOM, where-used …), the desktop app answers allow once by itself. The tool list marks such a call Allowed automatically (read-only). Nothing is widened by it: the agent calls the tool with your token, and the server answers with your rights only – a restricted group you are not in stays “not found”, as in the web UI. Everything else still asks: TeamCraft’s write tools (draft change orders, comments, result proposals), tools of any other MCP server, the agent’s own tools (files, commands), and every request the app cannot identify for certain (see below). To be asked every time, tick Ask before read-only TeamCraft tools under Settings → Agents…; it applies to a running agent at once.
- Model: if the agent offers a choice of models, the navigator shows a Model picker under the agent. The list is the agent’s own. It depends on the agent’s provider, sign-in and plan, and TeamCraft adds or checks nothing. The model switches between answers, not during one. The desktop app remembers the pick per agent and selects it again at the next start if the agent still offers it. Agents that offer no choice show no picker.
- Cancel in the toolbar stops the running turn. Stop in the navigator ends the agent process. Signing out stops it too.
What the agent can reach
- TeamCraft’s tools with your rights. The desktop app passes the
server’s MCP endpoint (
<server>/api/v1/mcp) to the agent when the session starts, with your desktop sign-in (the personal access token) as theAuthorizationheader, and marks the connection withX-TeamCraft-Client: desktop-agent(so the server can say precisely why it refuses it when local agents are off). The agent can do in TeamCraft exactly what you can do through the MCP tools: read, and draft change orders. It cannot submit, approve or release. An agent without support for MCP over HTTP gets no TeamCraft tools; the navigator says so. - The workspace folder. The agent runs in your workspace folder (its
working directory). Through the desktop app it may only read text
files there (
fs/read_text_file, at most 4 MiB). Paths outside the folder are refused, also when a symbolic link points outside. - No writing and no terminal through TeamCraft. The desktop app does
not offer the ACP capabilities
fs/write_text_fileandterminal. Changes to CAD data should go through check-out and check-in, with versions, locks and an audit trail. An agent that edits files behind the PDM’s back would bypass that. A terminal would give the agent a shell through the desktop app. - But it is your program. The agent process runs with your operating system rights. These limits apply to what it asks the desktop app to do. Its own built-in tools (editing files, running commands) are governed by the agent’s own permission requests, and the desktop app shows those to you. Answer them with care.
Your messages and the data the agent looks up go to the agent’s AI provider under the agent’s own terms. TeamCraft’s audit log shows the MCP tool calls as yours.
Protocol details
The desktop app speaks ACP version 1 (JSON-RPC 2.0, one message per line,
at most 8 MiB per message) and calls initialize, session/new (with the
workspace folder as cwd and TeamCraft as an http entry in
mcpServers), session/prompt and session/cancel. For the model
picker it reads the session’s configOptions (the select option of
category model, set with session/set_config_option) or, from agents
that only send that, the older unstable models field (set with
session/set_model). It handles
session/update (message and thought chunks, tool_call,
tool_call_update, plan, config_option_update), session/request_permission and
fs/read_text_file. It answers every other agent request with “method not
found”. At most 8 file reads run at once and at most 16 permission requests
wait for an answer; further ones are refused or answered with cancelled. Stop closes the agent’s input and ends
the process, so an agent that
npx or cmd started as a child process of its own ends too. If the agent asks for a sign-in first (auth_required),
sign in
once in a terminal with the agent’s own command and start it again. The
code is desktop/src/acp.rs.
Which permission requests are answered without a prompt
(desktop/src/autoallow.rs). At the start of a session the app asks the
server for tools/list with your token and keeps the names marked
annotations.readOnlyHint: true (a name listed twice is left out) for that
session – nothing is hard-coded. A session/request_permission is
answered with its allow_once option only if all of these hold:
- the session got TeamCraft’s MCP server from the app (registered as
teamcraft; an agent without MCP over HTTP may have ateamcraftserver of its own, so then everything asks), and the list loaded; - the request names a tool of exactly that server, in one of the forms
agents use:
mcp__teamcraft__<tool>(Claude Code and its ACP adapter, intoolCall.titleortoolCall._meta.claudeCode.toolName; alsotoolCall._meta.toolName),<tool> (teamcraft MCP Server)orteamcraft__<tool>(Gemini CLI). A bareget_itemnames no server and asks; Gemini CLI usually titles a call with its arguments, so its requests mostly still ask; - every name in the request agrees – one that names another server or another tool makes it ask;
- the tool’s
kindisother(or missing) both in the request and in the tool call the agent announced before, somcp__teamcraft__get_itemas the title of a terminal command or file edit asks; - the tool is in the read-only list, and the request offers
allow_once(allow_alwaysis never chosen).
The app asks the agent’s question and trusts its answer about which tool runs, as with every permission prompt: the agent could call tools without asking at all. What a read-only tool returns is decided by the server.
Manual test tour
A 20-minute walk through every feature on a local server (docker compose up -d db && cargo run,
then http://localhost:8080).
Use two browsers (or a private window) for the two-person steps.
1. Accounts and project groups
- Create the administrator on the login page (first start only).
- Users → New user:
erika(CAD editor) androlf(CAD editor),vera(viewer). - Sign in as erika. Projects → New project group
ROVER– erika is its manager. Addrolfas member. (The tour builds a rover with wheel hubs; any product works the same way – a pump, a gearbox, a conveyor, a machine.) - Account → Appearance (or the menu under your name): System follows the light or dark appearance of your operating system, Light and Dark stay fixed; the distribution skins (Arch, KDE, Ubuntu, …) are dark. The choice is remembered for the login page as well.
2. Items, check-out and check-in
- Items → New item: choose
hub.stepandhub.stlas files – the name hub and the type part are filled in; change the name to Wheel hub, project groupROVER. The item is created with both files; revision A is in work and checked out to erika (lock badge). - Open 3D view for each file. The STEP model is tessellated on the server – try Export STL.
- Vault → My check-outs: the item is listed; select it, Check in a
changed
hub.stepwith a comment. The upload shows a progress bar; try Cancel upload on a large file – nothing changes. - As rolf: the item is read-only while erika holds the lock; after the check-in rolf can Check out. Cancel check-out releases it at once.
3. Versions and rollback
- On the revision page, open the version history of
hub.step, download v1. - Restore v1: it becomes v3 – history is never rewritten.
4. Structure
- Create an assembly Rover and add the hub to its BOM (find number 10, quantity 4). Open the BOM page; switch the revision rules.
- Upload an assembly file that references
hub.step(e.g. a STEP assembly): the references are listed and matched. - Pack and Go (ZIP) on the BOM page.
5. Change and release
- As erika: Release via new change…, add rolf as reviewer, Submit.
- As rolf: the bell shows the review request; Approve. The hub is released; dependents get Check needed.
- Revise the released hub: revision B is in work and checked out.
6. Viewer, API, audit
- As vera: everything is readable, nothing editable.
- Account → API tokens: create one and call
curl -H "Authorization: Bearer <token>" http://localhost:8080/api/v1/me. - As the administrator: Audit log lists every step of this tour.
7. Operations
curl -s localhost:8080/healthz → ok, /readyz → database and vault
status, teamcraft vault check --verify-hashes.
Install with Docker
The quickest way to run TeamCraft on a Linux server: one script sets up the
TeamCraft container and a PostgreSQL container with Docker Compose. Nothing
is built on your machine – the image ghcr.io/rudalabs/teamcraft comes from
GitHub’s container registry for linux/amd64 and linux/arm64.
TeamCraft has no release yet (version 0.x). Until there is one, the installer uses the development image
edge, built every night from themainbranch. Keep backups (see below).
Install
You need Docker Engine with the Compose v2 plugin (docker compose version).
curl -fsSL https://raw.githubusercontent.com/rudalabs/TeamCraft/main/deploy/install.sh | bash
The script asks for sudo where it needs it and then:
-
checks Docker and Compose (it never installs Docker on its own; with
--install-dockerit offers to install the distribution’s packages); -
picks the version – the newest release, or
edgewhile there is none; -
writes the installation to
/opt/teamcraft:File Contents docker-compose.ymlthe two services, replaced on every upgrade .envTEAMCRAFT_VERSIONandTEAMCRAFT_BINDfor Composeteamcraft.envthe server configuration – created once, then yours (mode 600) secrets/db_passwordthe generated database password – never printed -
pulls the images, starts them and waits until
/readyzanswers; -
offers to create the first administrator on the command line.
To read the script first, download it and run it from the file – the same
options work in both cases (… | bash -s -- --user):
curl -fsSLO https://raw.githubusercontent.com/rudalabs/TeamCraft/main/deploy/install.sh
less install.sh
bash install.sh --dry-run # prints every step, changes nothing
bash install.sh
From a checkout, ./deploy/install.sh uses the compose file of the checkout.
| Option | Effect |
|---|---|
--version 0.2.0/edge | image version (default: the newest release; edge installations stay edge) |
--user | install into ~/teamcraft without sudo (you must be in the docker group) |
--dir DIR | install into DIR |
--bind ADDR | published address, default 127.0.0.1:8080; 0.0.0.0:8080 serves the network |
--public-url URL | the address users open, written to teamcraft.env |
--upgrade | upgrade (the default when an installation is found) |
--uninstall [--purge] | remove the containers; --purge also deletes all data – asks first |
--install-docker | install Docker and Compose with apt, dnf, pacman or zypper – asks first |
--yes | unattended: answer yes to every question |
--dry-run | show what would be done |
The first administrator
The installer offers to create it right away and asks for the password. Later, or after an unattended installation:
cd /opt/teamcraft
sudo docker compose exec teamcraft teamcraft user create --username admin --role admin
The Create administrator form on the login page of a local installation (cargo run) is not offered here: inside the
container the server listens on
all interfaces, so it cannot tell whether the page is opened from the same
computer. Alternatively set TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD in
teamcraft.env for the first start (used only while there are no accounts),
or sign in with single sign-on.
Every other teamcraft command works the same way:
sudo docker compose exec teamcraft teamcraft user list, … vault check.
Configuration
Edit teamcraft.env (every variable: Configuration) and
apply it with sudo docker compose up -d. The database connection, the
listen address inside the container and the vault directory are set in
docker-compose.yml and take precedence.
The published port is TEAMCRAFT_BIND in .env. The default
127.0.0.1:8080 reaches only the server itself – put a reverse proxy in
front (below), or publish the port to the network and tell TeamCraft its
address, which every form is checked against:
sudo bash install.sh --bind 0.0.0.0:8080 --public-url http://plm.example.org:8080
Reverse proxy and TLS
For anything beyond a test, serve TeamCraft over HTTPS through a reverse proxy
on the same host, keep the default 127.0.0.1:8080 binding, and set
TEAMCRAFT_PUBLIC_URL=https://plm.example.org (that also turns on Secure
cookies). With Caddy, which gets the certificate itself:
plm.example.org {
reverse_proxy 127.0.0.1:8080
}
With nginx, allow large uploads (client_max_body_size 512m;, matching
TEAMCRAFT_MAX_UPLOAD_MB) and pass Host and X-Forwarded-Proto. TeamCraft sends
Strict-Transport-Security itself when TEAMCRAFT_PUBLIC_URL is https. The cross-site check accepts the Host and
X-Forwarded-Host the proxy passes: let the proxy overwrite X-Forwarded-Host (never pass on the client’s), and
note that a web form post without Origin and Referer is refused.
Caddy sets X-Forwarded-Host itself; with nginx:
proxy_set_header X-Forwarded-Host $host;.
Upgrade
Run the installer again – it downloads the compose file of the new version, pulls the image and restarts; configuration, password and data stay:
curl -fsSL https://raw.githubusercontent.com/rudalabs/TeamCraft/main/deploy/install.sh | bash
# or a specific version: … | bash -s -- --version 0.3.0
Database migrations are applied on start. While TeamCraft is 0.x, read the release notes before an upgrade and take a backup first.
Backup and restore
The data lives in two Docker volumes: teamcraft-server_db (PostgreSQL) and
teamcraft-server_vault (every uploaded file; vault/previews/ is a cache).
Back up both together, plus the installation directory (it holds the
database password). The image has no PostgreSQL client tools, so
teamcraft backup (Operations) does not run inside
the container; dump through the database container instead (sh -c: the
dump is written as root, because /opt/teamcraft belongs to root):
cd /opt/teamcraft
sudo sh -c 'docker compose exec -T db pg_dump -U teamcraft -Fc teamcraft > teamcraft-db.dump'
sudo docker run --rm -v teamcraft-server_vault:/vault:ro -v "$PWD":/backup alpine \
tar -czf /backup/teamcraft-vault.tar.gz -C /vault --exclude=./vault/previews .
Restore into a fresh installation (same or newer version):
cd /opt/teamcraft
sudo docker compose stop teamcraft
sudo docker compose exec -T db pg_restore -U teamcraft -d teamcraft --clean --if-exists --single-transaction < teamcraft-db.dump
sudo docker run --rm -v teamcraft-server_vault:/vault -v "$PWD":/backup alpine \
tar -xzf /backup/teamcraft-vault.tar.gz -C /vault
sudo docker compose start teamcraft
sudo docker compose exec teamcraft teamcraft vault check
The vault is content-addressed and append-only, so incremental file backups
of the volume directory (docker volume inspect teamcraft-server_vault) work
well too.
Image tags
| Tag | Built from |
|---|---|
edge | main, rebuilt every night when it changed |
0.2.0, 0.2 | the release tag v0.2.0 |
latest | the newest release without a suffix such as -rc.1 – also 0.x |
The image is built from the repository’s Dockerfile by GitHub Actions only (.github/workflows/release.yml): a small
distroless runtime (glibc, CA
certificates, time zones, no shell) that runs as an unprivileged user,
with a HEALTHCHECK through teamcraft healthcheck. The compose file also
makes its root file system read-only and drops all capabilities.
Uninstall
curl -fsSL https://raw.githubusercontent.com/rudalabs/TeamCraft/main/deploy/install.sh | bash -s -- --uninstall
This removes the containers and keeps the volumes and /opt/teamcraft, so
running the installer again brings everything back. --uninstall --purge
deletes the volumes and the directory as well, after a confirmation.
Troubleshooting
| Symptom | Check |
|---|---|
| the installer waits and then stops | its output shows docker compose ps and the last log lines |
Forbidden / origin error on every form | TEAMCRAFT_PUBLIC_URL must be exactly the address in the browser |
password authentication failed | secrets/db_password was changed after the first start – the database keeps the first one |
the database volume … already exists | the installation directory was deleted without --purge (or another directory is used): restore its secrets/db_password; the Compose project name teamcraft-server allows one installation per host |
| port 8080 is taken | --bind 127.0.0.1:8081 |
| a STEP file has no or only a partial 3D preview | run the converter by hand: docker compose exec teamcraft teamcraft preview-mesh --format step --input <file> --output /tmp/x.tcm prints the triangle count, partial preview: N of M faces or the reason; see STEP in the 3D preview. Failures are cached per file – after an update that bumps the preview cache version they are retried |
Configuration
TeamCraft is configured with environment variables; all are optional for
a local test with the development database of docker-compose.yml. Every
variable can also be read from a file by appending _FILE
(TEAMCRAFT_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc), which is how
container secrets are passed. For local development TeamCraft also reads an
optional, git-ignored secrets/.env file in the working directory.
Configuration files are read on start, in this order; a variable that is already set (in the environment or by an earlier file) is kept:
| File | When |
|---|---|
the file named by TEAMCRAFT_ENV_FILE | when the variable is set (e.g. a teamcraft.env outside the working directory) |
secrets/.env | when it exists in the working directory (development) |
| the system file | only in package builds: its path is fixed at build time with TEAMCRAFT_SYSTEM_CONFIG (e.g. /etc/teamcraft/teamcraft.env); development builds never read one |
In the Docker installation the variables go into
teamcraft.env; the image sets TEAMCRAFT_BIND_ADDR=0.0.0.0:8080 and
TEAMCRAFT_VAULT_DIR=/var/lib/teamcraft/vault, and docker-compose.yml
sets the database connection with the password from a Docker secret
(TEAMCRAFT_DB_PASSWORD_FILE=/run/secrets/db_password).
Values are validated on start; an invalid value stops the program with a
message naming the variable. A TEAMCRAFT_* variable that TeamCraft does not
know (usually a typo) is ignored but logged as a warning with the nearest
known name (unknown environment variable … is ignored; did you mean …?;
<NAME>_FILE forms are checked by NAME). A test keeps this page and the variables
read by src/config.rs in sync.
Server
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_BIND_ADDR | 127.0.0.1:8080 | address to listen on (0.0.0.0:8080 to serve the network) |
TEAMCRAFT_PUBLIC_URL | http://localhost:<port> | external URL: SSO redirects, CSRF origin check; for https:// also Secure cookies and Strict-Transport-Security |
TEAMCRAFT_INSTANCE_NAME | TeamCraft | default header name, page titles, e-mails; administrators override it under Admin → Branding |
TEAMCRAFT_LOG_FORMAT | text | text or json |
RUST_LOG | info,tower_http=info,sqlx=warn | log filter |
The server (teamcraft serve) logs to standard output; the other commands (migrate, user, vault, backup, …) log
to standard error, so that their
output can be read by scripts.
Database (PostgreSQL 16+)
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_DATABASE_URL | – | postgres://user:password@host:port/db; alternatively the parts below |
TEAMCRAFT_DB_HOST, TEAMCRAFT_DB_PORT, TEAMCRAFT_DB_NAME, TEAMCRAFT_DB_USER | localhost / 5432 / teamcraft / teamcraft | connection parts |
DATABASE_URL | – | fallback for TEAMCRAFT_DATABASE_URL (the usual name in hosting platforms) |
TEAMCRAFT_DB_PASSWORD | – | password (not needed with trust authentication or ~/.pgpass) |
TEAMCRAFT_DB_SSLMODE | prefer | disable, prefer, require, verify-ca, verify-full |
TEAMCRAFT_DB_MAX_CONNECTIONS | 10 | pool size |
Start-up waits up to 20 s for the database, then stops with the reason.
Migrations run automatically (teamcraft serve --no-migrate skips them).
Files
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_VAULT_DIR | ./data/vault | file vault and cached 3D previews – back it up |
TEAMCRAFT_MAX_UPLOAD_MB | 512 | largest file per upload, also for chunked uploads |
TEAMCRAFT_UPLOAD_CHUNK_MB | 16 | largest chunk of a chunked, resumable upload (PATCH /api/v1/uploads/…, PATCH /uploads/… of the web UI), 1–1024 |
Chunked uploads (Large files)
hold one chunk in memory and write it to uploads/ in the vault directory,
so TEAMCRAFT_MAX_UPLOAD_MB can be raised for multi-gigabyte CAD or
simulation files (e.g. 20480) without more memory; a single multipart
request (small files, the web UI’s New item form) still has to fit this
limit as a whole, and a
reverse proxy in front must allow request bodies of one chunk (client_max_body_size ≥ TEAMCRAFT_UPLOAD_CHUNK_MB) –
or of the largest
multipart upload. Make sure the vault’s disk has room for the unfinished
uploads too: each user may hold 16 of them for 24 hours. Storage quotas per
user and for the whole vault are set with the CLI, see
Storage quotas.
Malware scanning
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_CLAMD_ADDR | – | ClamAV daemon that scans every upload: host:port (e.g. clamav:3310) or unix:/run/clamav/clamd.ctl; unset = no scan |
TEAMCRAFT_CLAMD_MAX_MB | 25 | larger files are stored without a scan (logged); match clamd’s StreamMaxLength, 1–4096 |
With a scanner, every file is sent to clamd (INSTREAM) before it is moved
into the vault – uploads, check-ins, chunked uploads, imports. Infected
content is refused (400, the signature name in the message) and never
stored; a chunked upload starts over. When the daemon cannot be reached, the
upload is refused with 503 instead of stored unscanned. ClamAV is a separate
service (e.g. the clamav/clamav image next to TeamCraft); raise its
StreamMaxLength together with TEAMCRAFT_CLAMD_MAX_MB to scan large CAD
files too.
Encrypted backups
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_BACKUP_PASSPHRASE | – | passphrase of teamcraft backup --encrypt and of restoring such a backup (at least 12 characters); without it the CLI prompts on a terminal. Prefer TEAMCRAFT_BACKUP_PASSPHRASE_FILE |
See Backups. Losing the passphrase means losing the backup – keep it apart from the backups themselves.
Data retention
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS | 7 | days after which the old versions of a file set to keep only the latest version are removed (1–3650; 0 = the housekeeping never removes them, only teamcraft vault purge-versions does) |
TEAMCRAFT_AUDIT_RETENTION_DAYS | 0 | days the audit log is kept; 0 = for ever, otherwise 30–36500; the removal is recorded as one audit.purge entry; entries that separation of duties still needs (edits of revisions in work or review, preparing actions of open changes) are kept until those are closed; administrator elevation entries are removed too (the elevation list keeps reason and times) |
TEAMCRAFT_PREVIEW_CACHE_MAX_MB | 0 | size the preview cache (<vault>/previews) is trimmed to, least recently used first; 0 = no limit; previews are made again when needed |
Files keep every version unless someone sets a file to keep only the latest version, so the defaults change nothing for existing installations. See Data retention.
3D preview limits
The 3D preview converts a file in a separate teamcraft preview-mesh process
with a time limit (at most two at a time). A file above the size cap of its
kind is never opened: no conversion starts, the page says it is too large
for a 3D preview, and the refusal is cached (raising the cap takes effect at
once). The process also runs with a memory limit; a file that needs more
fails with a message instead of taking the host down. Large simulation
results are stored as they are, never parsed fully – see
Simulations.
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_PREVIEW_MAX_RESULT_MB | 64 | largest simulation result file (VTK, VTU, VTP, FRD) that is previewed |
TEAMCRAFT_PREVIEW_MAX_MESH_MB | 256 | largest mesh or point cloud (STL, OBJ, PLY, 3MF, glTF, LAS, …) |
TEAMCRAFT_PREVIEW_MAX_STEP_MB | 256 | largest STEP file (tessellation needs a multiple of the file size) |
TEAMCRAFT_PREVIEW_MAX_MEMORY_MB | 1024 | address space of the preview process, 0 = unlimited |
The same process limits protect the text extraction of PDF and HTML reports (teamcraft text-extract: at most 32 MB per
PDF and 100 pages, the first 16 MB
of an HTML file, 60 s, the memory limit below).
The memory limit (RLIMIT_AS) is enforced on Linux; on macOS the system may
ignore it, and on Windows it is not enforced (the size caps and the time
limit still are). Every variable also exists as NAME_FILE.
Sign-in
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_SESSION_TTL_HOURS | 72 | session lifetime |
TEAMCRAFT_COOKIE_SECURE | true for https:// public URLs | force the Secure cookie flag |
TEAMCRAFT_LOCAL_LOGIN | true without SSO | username/password accounts |
TEAMCRAFT_SIGNIN_MAX_FAILURES | 10 | failed password sign-ins per username in 15 minutes before 429 (5× per client address; 0 = off) |
TEAMCRAFT_METRICS_TOKEN | – (endpoint off) | enables GET /metrics (Prometheus text format); the scraper sends this value (at least 16 characters) as Authorization: Bearer …. Request counts and latency, database pool, pending webhook deliveries, e-mails and chat messages, 3D preview queue |
TEAMCRAFT_BOOTSTRAP_ADMIN_USERNAME, TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD | admin / – | create this administrator on start when there are no accounts (public servers) |
TEAMCRAFT_OIDC_* | – | single sign-on, see Users, roles and SSO – also TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS (idle lifetime of SSO users’ new tokens, 0 = off) and the back-channel logout URI |
TEAMCRAFT_APPROVAL_REAUTH | off | a fresh sign-in to sign off review decisions in the browser: release (every approval) or every_decision (approvals and rejections); see below |
Sign-off of review decisions. With TEAMCRAFT_APPROVAL_REAUTH a reviewer
confirms a decision on the change page with the account password, or – for
single sign-on accounts – with Sign in again, which sends them to the
identity provider with prompt=login and max_age=0; the provider’s
auth_time must be at most five minutes old, and the confirmation counts
for 10 minutes. API tokens are not asked: reviewing needs the approve
token scope (write tokens get 403). Every decision records how it was
signed off (session, password, sso or token) and an optional stated
meaning (“approved as design owner”), shown on the change page, in
GET /api/v1/changes/{number} and kept with earlier review rounds. A wrong
password counts like a failed sign-in (user.password_failed, throttled);
a fresh SSO sign-in writes user.reauthenticate.
Failed password sign-ins are counted per username and per client address
for 15 minutes from the first failure; when a counter is full the sign-in
page answers 429 Too Many Requests with Retry-After – even for the right
password – until the 15 minutes have passed. A successful sign-in clears the
username’s counter. The counters live in the memory of each server process (a restart clears them). Behind a reverse
proxy on the same host or an
internal network, the client address is the last X-Forwarded-For entry,
the one the proxy added; a client that talks to the server directly is
counted by its own address. Sign-in, failed sign-in and sign-out are in
the audit log (user.sign_in, user.sign_in_failed, user.sign_out).
Notifications (optional)
| Variable | Meaning |
|---|---|
TEAMCRAFT_SMTP_URL | SMTP server in lettre syntax: smtps://user:password@host:465, smtp://host:587?tls=required, smtp://localhost:25 |
TEAMCRAFT_MAIL_FROM | sender, Name <address> (required with SMTP) |
TEAMCRAFT_REQUEST_DIGEST_HOURS | hours between two e-mail digests of the change requests waiting for triage, per CAD editor and project group (default 24, 0 = off, at most 720); only with SMTP |
TEAMCRAFT_MATTERMOST_URL | base URL of the Mattermost server (https://chat.example.org, also below a path prefix); together with the bot token it switches notifications to direct messages of a bot account |
TEAMCRAFT_MATTERMOST_BOT_TOKEN | access token of the bot account (secret; also as TEAMCRAFT_MATTERMOST_BOT_TOKEN_FILE). Both variables are required together |
TEAMCRAFT_MATTERMOST_WEBHOOK | deprecated: incoming webhook for notifications (ignored when the bot is configured); logs a warning at start-up |
TEAMCRAFT_MATTERMOST_DIRECT | deprecated, webhook only: true: direct messages through the webhook (they appear to come from the webhook’s creator), false (default): mention in the webhook’s channel – notifications about restricted project groups then show only a generic text and the link |
MATTERMOST_ERROR_WEBHOOK | incoming webhook that receives internal server errors (rate limited) |
Mattermost: a bot account for direct messages
Notifications reach Mattermost only as a direct message to the person,
never in a channel. TeamCraft signs in as a bot account and uses the REST
API: it looks the user up by name (GET /api/v4/users/username/…, cached for
ten minutes), opens the direct channel bot ↔ user (POST /api/v4/channels/direct)
and posts (POST /api/v4/posts). In Mattermost the messages come from the bot (shown with the BOT tag), and every
user has one conversation with it. A
direct message can only be read by its recipient, so it carries the full title
and text, also for restricted project groups – unless the
group switched notification content off, then only a generic text and the
link are sent.
Set up the bot once:
- In Mattermost: System Console → Integrations → Integration Management: Enable Bot Account Creation must be on.
- Integrations → Bot Accounts → Add Bot Account: username e.g.
teamcraft, display name TeamCraft. A bot needs no role beyond Member and no channel or team membership: it only has to read user profiles and open direct messages, which every account may do. Do not give it a system role. - Create a token for the bot (Create New Token), keep it in a file that only
the server reads, and set
TEAMCRAFT_MATTERMOST_URLandTEAMCRAFT_MATTERMOST_BOT_TOKEN_FILE. Both are required together. - Check it from Account → Notifications: switch Mattermost on, save and press Send a test (the test notification is delivered like every other).
Users enter their Mattermost username on their account page (empty = their TeamCraft login). If it is unknown or the Mattermost account is deactivated, the chat delivery of that notification is marked failed at once, with the reason (for example Mattermost user “xy” not found – check your Mattermost username) shown under Notifications; it is never posted into a channel instead. The bell and the e-mail are not affected. A rejected token (HTTP 401), a missing permission or a server error is retried like any delivery (five attempts with the usual pause) and then marked failed, with the reason.
The server URL is set by the administrator only. The client follows no
redirects (the token must not travel to another host) and ignores proxy
variables. The error webhook MATTERMOST_ERROR_WEBHOOK stays an ordinary
channel webhook: it is for administrators, not personal.
Deprecated webhook mode. With only TEAMCRAFT_MATTERMOST_WEBHOOK set,
notifications still go out as before (a mention in the webhook’s channel, or
with TEAMCRAFT_MATTERMOST_DIRECT=true a direct message that appears to come
from the person who created the webhook), the server logs a deprecation
warning at start-up and the account page says so. Everybody in the channel
reads the mentions, which is why restricted groups show only a generic text
there. See Upgrading.
The change request digest is an e-mail per project group to its CAD
editors and admins with the requests raised since the previous digest that
are still New, and how many older ones still wait for triage. Nothing is
sent when no new request came in. Every user can mute it on the account page (E-mail digest of new change requests …).
The server remembers per user
and group up to when the digests reached (table request_digests), so a
restart or a second server never sends a period twice; the e-mail goes
through the same outbox, retries and access checks as every notification.
Event webhooks
Administrators register outgoing webhooks on the web page Webhooks or through the API (Event webhooks). These variables limit where they may send to:
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS | – | comma separated host names, IP addresses or *.domain; when set, webhooks can only target these hosts, and these may be internal |
TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE | false | true: every host may resolve to private, loopback or link-local addresses (single-network installations, development) |
TEAMCRAFT_WEBHOOK_RETENTION_DAYS | 30 | sent and failed deliveries are removed after this many days (1–3650) |
By default webhooks reach public addresses only. An on-premises ERP or CI
server is internal, so list it: TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS=erp.corp.example,ci.corp.example.
Assistant (optional)
The built-in assistant (Ask TeamCraft) is off
until an API key is configured, or, for a local model server, until the
provider openai-compatible and its base URL are set. While it is off the
buttons are hidden and /assistant and POST /api/v1/assistant answer
404. Every variable also works as NAME_FILE (the file’s contents).
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_ASSISTANT_PROVIDER | anthropic | wire format: anthropic (Messages API), openai (OpenAI and Azure OpenAI v1, Chat Completions) or openai-compatible (Ollama, vLLM, LM Studio, Mistral, OpenRouter, Groq …) |
TEAMCRAFT_ASSISTANT_BASE_URL | https://api.anthropic.com/v1 / https://api.openai.com/v1 | API base (/messages or /chat/completions is added). Required for openai-compatible, e.g. http://ollama.internal:11434/v1; Azure: https://<resource>.openai.azure.com/openai/v1 |
TEAMCRAFT_ASSISTANT_API_KEY | – | the key. A secret: prefer _FILE, it is never logged. Optional for openai-compatible (local servers) |
TEAMCRAFT_ANTHROPIC_API_KEY | – | older name of TEAMCRAFT_ASSISTANT_API_KEY, still accepted. Setting both with different values is an error |
TEAMCRAFT_ASSISTANT_MODEL | claude-opus-5-5 (anthropic) | model id, or the Azure deployment name. Required for openai and openai-compatible |
TEAMCRAFT_ASSISTANT_MAX_STEPS | 12 | most model calls (tool rounds) per question, 1–50 |
TEAMCRAFT_ASSISTANT_MAX_TOKENS | 16000 | output tokens per model call, 256–200000 |
TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT | 180 | seconds for one HTTP call, 5–3600 (a model on a CPU may need more) |
TEAMCRAFT_ASSISTANT_RUN_TIMEOUT | 240 (at least the request timeout) | seconds for a whole question, at least the request timeout |
TEAMCRAFT_ASSISTANT_MAX_CONCURRENT | 4 | questions running at the same time on the server, 1–64 |
TEAMCRAFT_ASSISTANT_INTERNAL_URLS | – | comma or space separated base URLs of internal / self-hosted model servers (http(s)://host[:port][/path], no credentials). Project groups that switched external_llm off allow the assistant only when TEAMCRAFT_ASSISTANT_BASE_URL is one of them or below one (same scheme, host and port, path prefix at a segment boundary) |
TEAMCRAFT_ASSISTANT_RETENTION_DAYS | 30 | days an idle assistant conversation (one per user, text of questions and answers only) is kept before it is deleted, 1–365 |
With only TEAMCRAFT_ANTHROPIC_API_KEY (and optionally the model and the
steps) everything works as it did before these variables existed. The
startup log shows assistant=<provider>:<model>@<host>, never the key.
Where the questions go, and what is done about the key:
- The base URL comes only from this configuration, never from users, the admin pages or anything a model or tool returns. Internal hosts are allowed on purpose, so a model server inside the network works (the webhook protection against internal addresses is for URLs that users enter and is not used here).
- Redirects are not followed. An answer that redirects is an error, so
the key is never sent to a second host. A user name or password in the
URL, a query and a non-
http(s)scheme are rejected at startup. - A warning is logged at startup when a key would be sent over plain
httpto a host that is not this computer (localhost,127.0.0.0/8,::1). Usehttpsfor anything else. - The server honours
HTTPS_PROXY/NO_PROXY. A firewall with an egress allow-list must let the server reach the API host.
Check the setup with teamcraft assistant check (run it with the
same environment as the server; it needs no database). It sends one small
request with one tool and reports whether the server is reachable, the key
is accepted, the model exists, the model calls the tool and the server
reports token counts. Many local models are served without tool support:
this is how you find out before your users do. A server that reports no
token counts still works, but the token limit of a run cannot count its
calls (the step and time limits still apply, and the audit record says
usage_reported: false). The check never prints the key.
Fixed limits per question: 800 000 tokens in total. Each question is
recorded in the audit log (assistant.run: user, quick action, question
length, provider, model, tools used, token counts, usage_reported, the page
kind and identifiers, whether it was asked in the conversation and
the outcome – cancelled when the client closed the connection before the
answer – never the question or the answer).
Local agents
The desktop app can run an AI agent on the user’s own computer (its Agent view,
see ACP agents),
and users can connect other MCP clients (an assistant set up by hand, the
teamcraft mcp bridge) with a personal access
token. Such agents send the user’s questions and the TeamCraft data they
look up to the agent’s own AI provider, under the user’s own account and
terms.
Company AI instead of shadow AI. For data-loss prevention an
organisation can turn local agents off and offer its own AI centrally:
the provider configured under Assistant – Anthropic,
OpenAI or Azure OpenAI with the company’s contract and key, or a model
server inside the network (Ollama, vLLM …). Users then work with Ask
TeamCraft in the web UI and with the Agent view of the desktop app,
which runs on that assistant (Company AI); the key stays on the server
and never reaches a client. Every question runs with the user’s rights,
is recorded in the audit log (assistant.run, without the question or
the answer) and is bounded by the assistant’s limits (steps, tokens, time,
questions at the same time). Admin → AI (/admin/ai) shows the
setting, the MCP endpoint’s state and the configured provider, model,
host and limits – never the key, only whether one is set.
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_LOCAL_AGENTS | allowed | allowed or off. off: the MCP endpoint is closed for every token and the desktop app’s Agent view runs on the company AI (the assistant), or says that AI is off when none is configured. Any other value stops the server at startup |
Like every variable it also works as TEAMCRAFT_LOCAL_AGENTS_FILE. The
startup log shows local_agents=allowed|off. Changing it needs a restart
of the server; the desktop app picks it up at its next sign-in or window
reload and checks it again before every agent start.
What off does:
POST /api/v1/mcpanswers every call with403and a JSON-RPC error naming this setting, whoever sends it and with whichever token (administrators included): the desktop app’s agent, theteamcraft mcpbridge, an agent the user set up by hand, an older desktop app. Without a valid token the answer stays401. The built-in assistant is not affected: it calls the same tools inside the server, not over this endpoint. Refusals are not written to the audit log (they change nothing).GET /api/v1/mereportscapabilities.local_agents: falseandcapabilities.mcp: false. The desktop app then ends an agent that is still running and refuses to start one (in the app’s Rust code, not only in the user interface). Its Agent view – the app’s one place for AI – runs on the company AI instead: with an assistant configured (TEAMCRAFT_ASSISTANT_*), questions go to the server’s assistant automatically, with no local option shown (the key never reaches the app); without one the view says that AI is switched off by the administrator. Settings → Agents shows the same explanation. A desktop app older than this setting still offers its local agent, but the agent gets no TeamCraft tools: the endpoint refuses it.- The desktop app marks the MCP connection it hands to a local agent with
the header
X-TeamCraft-Client: desktop-agent. It only picks the wording of the refusal; leaving it out does not get a call through.
What off does not do – it is cooperative enforcement, not a
barrier against a determined user: it does not close the REST API (/api/v1/…) to a user’s own personal access token. A
program the user
writes, or another MCP client or agent that wraps the REST API with that
token, still reads and writes with that user’s rights and can hand what it
reads to an AI provider of the user’s choice. Nor can a server stop
anyone from copying what they see into another tool. The complement is scoped personal access
tokens: give an agent a read_only or mcp_only token (see
API reference). A written usage policy, the token list of each user (Account → API
tokens, revocable by administrators) and the audit log (writes are recorded under the user’s name) are the controls for
the rest.
Tests
| Variable | Meaning |
|---|---|
TEAMCRAFT_TEST_DATABASE_URL | server for the integration tests (default: the configuration above); each test creates and drops its own teamcraft_test_* database |
TEAMCRAFT_REQUIRE_DB_TESTS | fail instead of skipping when no database is reachable (CI) |
TEAMCRAFT_REQUIRE_PG_TOOLS | fail instead of skipping the backup/restore test when pg_dump/pg_restore are missing or older than the server (CI) |
TEAMCRAFT_WRITE_SIM_FIXTURES | 1 lets the ignored test write_sim_fixtures regenerate tests/fixtures/sim (developers only) |
MCP stdio bridge (client side)
Only teamcraft mcp, the bridge for AI assistants that run on a user’s
machine, reads these. The server does not (see MCP server).
| Variable | Meaning |
|---|---|
TEAMCRAFT_SERVER | base URL of the TeamCraft server (default http://localhost:8080) |
TEAMCRAFT_TOKEN | the user’s personal access token (or --token-file); it is never printed |
Users, roles and single sign-on
Local accounts
Administrators create accounts under Users or on the command line
(teamcraft user create --username erika --role editor --project PUMP).
Passwords are hashed with Argon2id; sessions and API tokens are stored as
SHA-256 hashes only.
A release needs two people. Nobody reviews their own change order
(four-eyes principle, with no administrator override), so a server with a
single account – a test installation, or the first administrator alone – can
create changes but never release them. Create at least one more CAD editor
(teamcraft user create --username reviewer --role editor) before trying a
release; see Separation of duties.
Roles
| Role | Stored as | May |
|---|---|---|
| Viewer | viewer | read internal project groups and the restricted groups they are a member of |
| CAD editor | editor | read like a viewer; change items of their project groups, create project groups, review |
| Admin | admin | everything, including restricted groups, plus users, all groups, lock overrides |
A project group can be restricted to its members: everybody else – viewers and CAD editors who are not members – gets “not found” for the group and everything in it. Viewers who are members read it. See Read permissions and restricted groups for the full rules, what outsiders see of links into a restricted group, the delay of memberships synchronised from the identity provider and what stays visible on a shared instance.
Single sign-on (OpenID Connect)
Any OIDC provider with the authorization-code flow works (Authentik,
Keycloak, Entra ID, Google, …). Register the redirect URI
<TEAMCRAFT_PUBLIC_URL>/auth/oidc/callback and set:
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_OIDC_ISSUER_URL | – | issuer; enables SSO |
TEAMCRAFT_OIDC_CLIENT_ID / _CLIENT_SECRET | – | client credentials |
TEAMCRAFT_OIDC_DISPLAY_NAME | Single Sign-On | label of the login button |
TEAMCRAFT_OIDC_SCOPES | openid profile email | requested scopes |
TEAMCRAFT_OIDC_GROUPS_CLAIM | groups | claim with the user’s groups |
TEAMCRAFT_OIDC_USERNAME_CLAIM | preferred_username | username of new accounts |
TEAMCRAFT_OIDC_ADMIN_GROUP | teamcraft-admins | members become admins |
TEAMCRAFT_OIDC_EDITOR_GROUP | teamcraft-editors | members become CAD editors |
TEAMCRAFT_OIDC_VIEWER_GROUP | – | if set, required to sign in at all |
TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS | 0 (off) | idle lifetime of SSO users’ tokens |
TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS (1 to 3650) caps the API and desktop
tokens that single sign-on users create from then on: a token without an
expiry, or with a longer one, expires after that many days without use
instead (every use renews it); a shorter fixed expiry stays. A leaver who is
removed only at the identity provider then loses their tokens after that
many idle days. Existing tokens keep their lifetime. The API tokens card of
My account tells SSO users about the limit.
Back-channel logout. Register
<TEAMCRAFT_PUBLIC_URL>/auth/oidc/backchannel-logout as the client’s
back-channel logout URI (OpenID Connect Back-Channel Logout 1.0). When the
provider ends a user’s session – or the user is removed there – it posts a
signed logout token; TeamCraft checks it against the provider’s published
keys (jwks_uri, RS256 or ES256), ends all of that user’s browser sessions,
audits it (user.idp_logout) and notes the time, which the report of SSO
users without sign-in shows (see Leavers removed only at the identity
provider). The token must
name the user (sub); tokens with only a session id (sid) are refused,
and so is a token more than five minutes old. A refused token is answered
with 400 and logged; an unknown subject with 200. Keys the cache does not
know yet are fetched again (key rotation), at most once a minute. API and
desktop tokens are not touched, and the account stays active – deactivate
the user (Offboard and deactivate) for that.
Accounts are created at the first sign-in; the role follows the groups at every sign-in. A project group bound to an IdP group (Projects → group → Edit, administrators) gets its members synchronised at every sign-in; manually added members stay. A synchronised membership stays one when its holder is made a manager (Demote lowers it again), and it cannot be removed in TeamCraft while the user is active (it would return at the next sign-in): remove the user from the IdP group, unlink the group or deactivate the user instead. A deactivated user’s synchronised membership can be removed (see When someone leaves).
Item owners and handing over a leaving user’s work
Every item has an owner, the person responsible for it. It starts as the
creator and is shown in the master data of the item page. The managers of
the item’s project group and administrators change it (Change next to the
owner); the new owner must be an active CAD editor or administrator who may
read the item. Every change is audited (item.owner).
When somebody leaves, an administrator opens the user and uses Hand over work. In one transaction TeamCraft moves to the chosen person:
| What | Moved when |
|---|---|
| item ownerships | always |
| change orders the user authored | draft or rejected |
| change requests assigned to the user | not closed (new, in triage, accepted, deferred) |
| pending reviews of the user | the change order is a draft or in review |
A message after the run gives the counts and the first objects that stayed with
the old user and why (numbers of groups the administrator may not read are
shown as “restricted item”): the receiver cannot read the object (restricted
project group), already reviews the change order, or may not review it under
the four-eyes principle (author, preparer or someone who changed the
revisions). Every moved object gets its own audit entry (item.owner,
change.handover, change_request.handover, change.review_handover) plus
one user.handover entry with the counts. Decisions already made, released
changes and closed requests keep the original names. One run moves at most
1000 objects of each kind; the message says when to run it again.
When someone leaves
Teams with a high turnover (student teams, early-stage startups) lose members every season. Deactivating a user ends their sessions and disables their API and desktop tokens; their check-outs, change orders, reviews and memberships stay.
See what they hold first. Users → user → Everything … still holds
(/users/{id}/holdings, GET /api/v1/users/{id}/holdings, or
teamcraft user holdings --username lena on the server) lists in one place
the user’s check-outs with their age, the change orders they wrote that are
still open (draft, in review, rejected), the reviews that wait for them, the
open change requests assigned to and raised by them, the items they own,
their memberships with the source – flagging the groups in which they are
the only active manager – and their API tokens with last use and expiry.
The report is read-only and filtered by the rights of whoever reads it, not
the leaver’s: a check-out in a group the leaver was removed from is still
listed. Administrators see every group they can read; for a group whose
need-to-know policy hides its content from them the report gives only the
number of check-outs. A manager of a project group opens the same report
from the group’s member list (holds…) and sees only what lies in the
groups they manage, without the tokens. Each list shows at most 200 entries
next to the full count.
Offboard in one step. Users → user → Offboard and deactivate (or
POST /api/v1/users/{id}/offboard, or teamcraft user deactivate --username lena on the server) deactivates the user and, in the same step:
- hands the work over to a successor you choose – items, draft and
rejected change orders, open change requests and pending reviews (as Hand
over work above) – and their manager roles: the successor becomes a
manager of every group the leaver managed (added as a member where
needed), the leaver stops being one (
project.manager_handoverper group); - revokes the API tokens (ticked by default): they are deleted, so
activating the account again does not bring old tokens back (
token.revoke_all); - releases the check-outs (ticked when there are any), each in its own transaction and audited like Release all.
Deactivation, hand-over and token revocation run in one transaction; the
check-outs are released afterwards and the message reports both. If the user
is the only active manager of a group and no successor is named, the step
is refused until you confirm that the group is left without an active manager (administrators can still manage it). An
administrator cannot offboard
themselves, and the last active administrator is kept. Every run is audited (user.offboard, with the choices). On the
command line the options are
--hand-over-to USERNAME, --release-checkouts, --revoke-tokens and
--confirm-no-manager; the command exits with 2 when a check-out could not be
released and is recorded as system in the audit log. The plain Active
checkbox of the profile still deactivates without any of these steps.
Or work through these steps by hand, in this order:
- Keep at least two active administrators. TeamCraft refuses to
deactivate or demote the last one. If nobody with the admin role is left,
whoever has a shell on the server creates one:
teamcraft user create --username rescue --role admin(break glass). - Before they go, the leaver uses Check in all on the dashboard and checks in the local changes of their desktop working folder. Work that was never checked in exists only on their computer. The desktop app lists check-outs with local changes before Check in all and before signing out and offers to check them in; Park… puts work in progress on the server without giving up the check-out, so it survives a lost laptop. After a forced release the former holder’s Workspace tab says who released it; their local files stay and need a new check-out.
- Hand over their work (administrators): Users → user → Hand over work moves item ownerships, draft and rejected change orders, open change requests and pending reviews (see above).
- Release the remaining check-outs. Administrators use Users → user →
Check-outs → Release all. The managers of a project group use Release N
check-outs in the group’s member list – for members who are deactivated
and, under Former members still holding check-outs, for people who are
no longer members. Each release is audited (
revision.checkout_forced_release); one refused release does not stop the others. A lock held by somebody who is still an active member (on leave, or simply gone quiet) a manager releases one revision at a time with Release with reason on the revision page; the reason is audited and the holder is told it. Revisions of a need-to-know group that hides itself from administrators (admin_read = none) are left out for administrators: a manager of that group releases them. - Pending reviews: a change order cannot be submitted while one of its reviewers is deactivated – remove them and add someone else. For a change already in review the author (or an administrator) replaces the pending reviewer (Substitute for), which keeps the other decisions.
- Change orders of a deactivated author: besides administrators, a
manager of every project group the change touches may withdraw, reopen
or cancel it, or take it over (Take over on the change page; audited
as
change.handover). Whoever takes it over is its author and can no longer review it (four-eyes principle). - Group managers: Demote in the member list lowers a manager to a
plain member, also for memberships synchronised from the identity
provider. At least one active manager must remain (only
administrators may demote or remove the last one); deactivated managers
do not count. Administrators see the groups left without an active
manager – or whose managers have neither signed in nor used a token for
90 days – under Groups without an active manager on Project groups
(
GET /api/v1/projects/manager-gaps); the hourly housekeeping notifies them once per group when that happens. - Revoke their tokens on the user page.
- Deactivate the user. For SSO users both are needed: remove them at the identity provider and deactivate them in TeamCraft (SSO users without sign-in on Users finds those who were only removed at the provider, see below). A synchronised membership of a deactivated user can then be removed by the group’s managers (SSO refuses deactivated users, so it cannot come back); an active user’s synchronised membership still can only end at the identity provider.
Leavers removed only at the identity provider
Removing someone at the identity provider does not deactivate them in TeamCraft: their account, memberships, check-outs and tokens stay until an administrator offboards them. Three things help when the IdP is where people leave:
- The report. Users lists, under SSO users without sign-in, every active single sign-on user who has neither signed in nor used an API or desktop token for 90 days and still holds memberships, tokens or check-outs, with the last sign-in, the last activity and when the IdP last logged them out. Each row links to the user (Offboard and deactivate) and to what they hold. Administrators only; shown when SSO is configured.
- Tokens that expire on their own. With
TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS(for example30), new API and desktop tokens of SSO users stop working after that many days without use. - Back-channel logout. Registered at the provider (see Single sign-on),
it ends the leaver’s browser sessions the moment the provider logs them out
or removes them, instead of after
TEAMCRAFT_SESSION_TTL_HOURS.
Automatic deactivation through SCIM is not supported; see the research note Deprovisioning through the identity provider.
The end of a season
When many members leave at once – the end of a term in a student team – the
managers of a group use Season handover (link above the member list,
/projects/{id}/season) instead of going person by person. The page lists
every member with their last activity (sign-in or use of an API or desktop
token), check-outs in the group, manager flag and member until date.
Tick members inactive since a date ticks the deactivated and the inactive
ones and those whose member until has passed; adjust the ticks, then run:
- the new lead becomes a manager;
- the ticked members’ check-outs are released – with a reason (10 to 500 characters), which each holder is told and which is audited; without a reason only the locks of deactivated and former members are released;
- member until is set (or cleared) for the ticked members – only a flag, nobody is removed automatically;
- the ticked members are removed (an IdP-synchronised membership only once its user is deactivated);
- the ticked managers are demoted – last, so a manager who steps down can still run the steps above; at least one active manager must remain unless an administrator runs it.
Every step goes through the same checks as the single action and is audited on its own; a refused step is listed in the result and does not stop the others.
Read permissions and restricted project groups
Every item belongs to a project group (or to none). A group is either
- internal – the default: every signed-in user reads it, its members edit it; or
- restricted – only its members (of any role, viewers included) and administrators see the group and anything in it: items, revisions, files and their versions, previews, BOMs, references, simulations, change orders and change requests about them. For everybody else it does not exist: they get exactly the “not found” a missing object gives.
The background and the alternatives that were considered are in the research note Read permissions for project groups.
Effective rights
| Global role | Member of the group | Internal group | Restricted group |
|---|---|---|---|
| Admin | any or none | read + edit | read + edit |
| CAD editor | yes | read + edit | read + edit |
| CAD editor | no | read | – (not found) |
| Viewer | yes | read | read |
| Viewer | no | read | – (not found) |
Items without a group are readable by everyone and editable by every CAD editor (unchanged). A read-only member of a restricted group is a member with the global viewer role; there is no per-group read/write level.
Restricting a group
Project groups → the group → Details → Visibility: Restrict to members
or Open to all users. Managers of the group and administrators may do
this; the change is recorded in the audit log (project.visibility). A
group can also start restricted (checkbox when it is created, or
"visibility": "restricted" in POST /api/v1/projects). Add viewers as
members to give read-only access. Linking a group to an identity-provider
group (oidc_group) stays with administrators, because it grants read
access.
Need to know: administrator access
By default an administrator reads every restricted group. A restricted group
can limit that (Project groups → the group → Need to know, managers and
administrators; PUT /api/v1/projects/{code}/policy
{"admin_read": "implicit" | "with_reason" | "none"}):
- implicit – today’s behaviour, the default (also when no policy was ever set);
- with a reason – an administrator who is not a member sees the group as
“not found” until they open an elevation with a written reason (10 to
1000 characters;
POST /api/v1/projects/{code}/elevations, or the form on the group page). It lasts 60 minutes, can be ended early (DELETE …/elevations) and is audited (project.elevate, with the reason). The group’s managers get a notification, and the group page lists every elevation with administrator, reason and time; - none – administrators still manage the group (members, policy, the
settings on the group page) but read nothing in it. An administrator cannot
add themselves as a member or make themselves a manager: a manager of the
group does that. There is no elevation under
none.
Every change of the policy is audited (project.policy, with the previous
value) and notifies the other managers. The policy applies to restricted
groups only and needs at least one member who is a manager, so the group can
be recovered; opening the group to all users makes the policy inert.
While a policy is in force, an administrator who is no manager member can
still add or remove other members, tighten the policy and rename the group,
but cannot loosen the policy, open the group to all users, link another
identity-provider group, add themselves or become a manager (403): those
would undo the policy in one call. What an administrator can still do is
limited by design: add another user (a manager sees it in the audit log),
see that the group exists (code and name), and change operator-level things (database, backups, the command line)
outside the application. A group
linked to an identity-provider group before the policy was set keeps that
link, so an administrator in that IdP group is a member. Webhooks cannot be
scoped to, or redirected for, a hidden group; their deliveries are not shown.
The elevation history keeps the username when an account is deleted;
deleting the group deletes its history (the audit log keeps the records). The rule
is part of the actor’s hidden groups (access::hidden_of_user), so every
guard, the audit feed, change orders, notifications and the assistant honour
it without a check of their own. This is step 1 of
#151; the conditions on
membership (step 2), the interface switches and the read log (steps 3 and 4)
and the access review (step 5) follow below.
Conditions on membership (need to know, step 2)
Membership can be made not enough: Need to know: conditions on membership
on the group page (managers and administrators; PUT /api/v1/projects/{code}/policy with the fields below) adds conditions every
reader must meet as well. A reader who does not meet them is treated as a
non-member – the group, its items, files and changes answer “not found” –
and that holds for administrators too (they still manage the group).
Nothing is set by default.
- Required claims (
"required_claims": [{"claim": "site", "values": ["main-office", "partner-workshop"]}, {"claim": "nda", "values": ["true"]}]; on the page one per line,site = main-office, partner-workshop): every condition must hold, each with one of its values, in the claims of the user’s last single sign-on (ID token and userinfo; compared without case; a list claim such asgroupsneeds one of its entries). A change at the identity provider applies at the next sign-in. TeamCraft keeps only the claims that some group’s conditions name (users.idp_claims), so a user signs in again after a new claim condition is set. Local accounts have no claims and never meet a claim condition. - Allowed networks (
"allowed_networks": ["10.20.0.0/16", "2001:db8::/32"]): the client address must be in one of the ranges – the same address as the sign-in throttle (the peer, or the entry the reverse proxy added toX-Forwarded-For; see Reverse proxy and TLS). A request whose address is unknown is outside. - Maximum sign-in age (
"max_signin_age_hours": 12,0removes it): the group is visible only for so many hours after signing in – the start of the web session, and for tokens, the desktop app and notifications the user’s last sign-in. A person removed at the identity provider (or from the group’s IdP group) therefore loses the group at the latest then, even with a session that is still running (F7 in the threat model) – or at once, when the provider sends a back-channel logout.
Claims and the sign-in age are part of the SQL that builds a user’s hidden
groups (access::hidden_of_user), so they apply to every channel, to change
orders and to notification recipients; the network and the session’s own age
are checked per request by the sign-in extractors and again at every step of
an assistant run. A member refused by a condition is audited once per session
or token (project.access_refused, with the reason and the address). The
access review shows the conditions and, per member, whether they met them at
their last sign-in; the evidence CSV carries both.
Setting conditions that would hide the group from yourself (your address
outside the ranges, a claim your last sign-in did not have, a sign-in older
than the age) is refused (409), so a manager does not lock the group away by
mistake. Adding a condition, a narrower range, fewer accepted values or a
shorter age is tightening, open to managers and administrators; anything else
is loosening, for the managers while administrators are limited by the
administrator policy. Every change is audited (project.conditions, old and
new) and tells the other managers.
Interface switches and the read log (need to know, steps 3 and 4)
A restricted group can also close channels through which its data could
leave the web UI, and can log who reads it (Project groups → the group →
Need to know: interfaces and read log, managers and administrators;
PUT /api/v1/projects/{code}/policy). Everything is allowed and nothing is
logged by default; the switches build on a restricted group and are inert
when the group is opened.
| Switch | Denied: the group looks non-existent to … |
|---|---|
api_tokens | REST requests, MCP and the desktop app that sign in with a personal access token (the browser session still works) |
mcp | MCP tools (also through teamcraft mcp, which uses a token) |
assistant | the built-in assistant, including the page context of a question |
webhooks | outgoing webhooks: no webhook receives events of the group, scoped or not; an event that mentions the group in a change order is sent without the change |
packages | Pack and Go and BOM downloads (CSV, Excel): the root item’s group answers 403, items of such groups inside a structure are left out like restricted items of non-members |
notification_content | e-mail and chat notifications (not the in-app list): a notification about the group, or naming an item or change order of it, is sent with only a generic text (“new |
external_llm | the built-in assistant, unless the configured provider’s base URL is on the administrator’s list of internal / self-hosted URLs (TEAMCRAFT_ASSISTANT_INTERNAL_URLS, see Configuration); with assistant on and external_llm off the group is usable only with an internal model server |
desktop_sync | the desktop app’s Get (working set GET /api/v1/items/{number}/revisions/{code}/workset): the root item’s group answers “not found”, items of such groups inside a structure are left out. The rest of the desktop app follows api_tokens |
{"interfaces": {"mcp": false, "webhooks": false}, "read_log": true, "log_retention_days": 365} – every field is optional. Switching an
interface off or the log on is open to managers and administrators; allowing
an interface again, switching the log off or changing the retention is, while
administrators are limited by the administrator policy above, for the
managers (like loosening that policy). Every change is audited (project.interfaces, old and new values) and notifies
the other managers.
The in-app notification list and the notification e-mails of other groups
are not affected by notification_content.
Read and download log. With read_log on, the group writes access_log
entries (separate from the audit log): who (user name kept as text), when,
the channel, the kind, the object (number or file name, kept as text), what
read it (the tool, for tool reads) and the client address (with the same
reverse-proxy rule as the sign-in throttle).
| Channel | Reads |
|---|---|
web | item and revision pages, file downloads, Pack and Go and BOM exports in the browser |
api | the same over the REST API and the desktop app (personal access token) |
mcp | read tools of an MCP client that name an item (get_item, get_revision, get_bom, read_file_text, …) |
assistant | the same read tools called by the built-in assistant |
desktop | the desktop app’s Get (the working set; its file downloads follow as api downloads) |
Kinds: view (an item or revision page, the same data over the API, or a
read tool), download (a file or file version), package (Pack and Go),
export (a BOM CSV or Excel file), workset (a desktop Get). A tool read is
one view of the item the tool was asked for, with the tool name as its
detail (read_file_text: spec.txt names the file); searches, lists, change
orders, write tools and failed tools log nothing, and the assistant’s own
comparison calls (the taint check) are not logged. Repeated reads of the same
object by the same user through the same channel within five minutes are one
entry, so a resumed or ranged download is logged once. A read that cannot be
logged does not happen (a tool answers with an error). Groups that do not log
write nothing. A Pack and Go, BOM export or desktop Get writes one entry for
every logging group inside the structure, not only for the root item’s group;
a refused one writes nothing. The managers and administrators read the log,
but only while they may read the group (an administrator shut out by
admin_read gets “not found”; the labels are item content). The group page
shows the newest 50 entries; The whole log, with filters
(/projects/{id}/access-log) filters by user, kind, channel, object (part of
the number, file name or tool), and a date range, 100 entries a page. The
REST API pages the same way: GET /api/v1/projects/{code}/access-log?user=&kind=&channel=&object=&since=&until=&limit=
answers {"entries": […], "next": id}; pass next as before= for the
following page (null on the last). Times are RFC 3339 or YYYY-MM-DD (a
date until includes that day). Not logged: thumbnails and 3D previews.
Retention and purge. log_retention_days (1 to 36500, empty = keep) is
applied by teamcraft access-log purge (run it from cron or a timer):
without options each group’s own retention, --project CODE limits it to one
group, --older-than-days N deletes everything older than that instead.
Every run is audited (access_log.purge, with the count). The database
backup contains the log.
Access review
Managers of a restricted group and administrators open Access review on the
group page (/projects/{id}/access-review). It lists every member with the
account role, the role in the group, the source (added by hand or synchronised
from the identity provider), whether the account is active, the last sign-in (flagged when there was none for 90 days)
and the date the membership began,
then the administrator policy (with the number of active administrators who are
not members) and the administrator elevations with their reasons. I reviewed
the access confirms the review with an optional note: the confirmation is
dated, audited (project.access_review), tells the other managers and stores
the member list as it was, so the question “who had access at the time” is
answered later (GET /api/v1/projects/{code}/access-review/{id}). The evidence
report is a CSV (one row per member, elevation and confirmed review, formula-safe)
or the same data as JSON (GET /api/v1/projects/{code}/access-review, with
?format=csv for the CSV). The page is for restricted groups only; elevations
are listed up to the newest 100 and reviews up to the newest 50 (the audit log
keeps all).
Review schedule. Review the access every (days) (under Interfaces and
read log, {"review_interval_days": 90}, 1 to 3650, 0 removes it) makes
the review periodic: the group is review due when no review was
confirmed within the interval (a group never reviewed is due at once). The
flag shows on the group list (for the group’s managers and administrators),
on the group page and on the review page, which also names the next due date;
the report and the CSV carry the schedule. The hourly housekeeping reminds
the group’s managers (an access_alert notification, which cannot be muted;
the active administrators when the group has no manager) once when the review
becomes due and again after each further interval without one; every
reminder is audited (project.review_reminder). A shorter interval is
tightening, a longer one or none is loosening (managers only while
administrators are limited by the policy).
Moving an item out of a restricted group, or copying it with Save As into another group, needs a manager of the source group or an administrator – both declassify the item.
Review policy per project group
By default every reviewer of a change order must approve. A manager of a
project group (or an administrator) can set a policy on the group page (Review policy) or with
PUT /api/v1/projects/{code}/review-policy
({"min_approvals": 2, "required_reviewers": ["carl"], "role_group": "QA", "obsolete_requires_change": false};
GET shows it to anybody who sees the group):
- Minimum approvals – this many approvals release the change; empty means all reviewers. A single rejection always rejects the change.
- Required reviewers – people who must be reviewers of every change that touches the group and approve it.
- Role group – another project group (e.g.
QA); at least one approval must come from one of its members.
A change that touches several groups must meet the policy of each: the
strictest minimum applies (a group without a value wants all reviewers), the
required people and role groups add up. submit refuses a change that cannot
meet them (409, naming what is missing: too few reviewers, a required person
who is not a reviewer, no reviewer from the role group; a required person
who may not review it, e.g. its author, gets a message of its own); the outcome
of the review uses the same policy when a reviewer decides, so reviewers who
have not decided yet stay pending once the change is released. The policy is
read at decision time, not frozen at submit, but only the parts the assigned
reviewers can still meet count (reviewers change in Draft only), so a policy
edit after submit cannot strand a change in review. Deactivated people and
people below the editor role do not count. Items without a project group count
like a group without a policy: all reviewers must approve. A role group the
reader cannot see is never named: the policy shows it as hidden and the submit
message says “a required role group”. Changes are audited (project.review_policy, with the previous values). Step 2 of
#152.
Obsolete and supersede only through a change order
Obsoleting a released revision is final and flags its dependents, and a
supersession tells designers not to use an item any more. By default one CAD
editor of the group can do both at once. The same form (Review policy on the
group page) has a checkbox, obsolete_requires_change in the API (PUT /api/v1/projects/{code}/review-policy; omitted:
unchanged, default off),
that moves both through review:
- With the setting on, Mark obsolete and Supersede on items of the group answer 409 for everybody, administrators included. Other groups are not affected.
- A change order can carry an obsolete or supersede line next to its
release lines (change page, On release;
POST /api/v1/changes/{number}/itemswith{"item": …, "action": "obsolete"}or{"item": …, "action": "supersede", "successor": "PRT-000012"}; the revision defaults to the item’s latest). It needs a released revision that is in no other open change; a supersede line needs a visible successor item, not the item itself and no circle (400/409). The lines are allowed in every group, the setting only forbids the direct way. - The policy of the group (minimum approvals, required reviewers, role group)
applies to the change; nothing happens before the last approval. Then, in the
same transaction as the release, each revision becomes obsolete (impacts on
dependents,
revision.obsoletedwebhook and notification as for a direct obsolete) and a supersede line also records superseded by. If a line cannot be applied any more (the revision was obsoleted in the meantime, the supersession would close a circle) the approval fails with 409 and the whole release stays undone. Rejecting or withdrawing the change changes nothing on the released revisions. - Supersede notifies the watchers of the superseded item, whether declared directly (with the setting off) or through a change order. Watchers who may not see the successor get the text without its number; the change page shows such a successor as restricted.
Step 3 of #152.
Requesters do not decide, concessions are confirmed
Two more checkboxes of the same form, both off by default (requester_cannot_decide and
concessions_need_review in PUT /api/v1/projects/{code}/review-policy; omitted: unchanged):
- Requesters do not decide their own change requests. The person who raised a
change request on items of the group cannot accept, reject or defer it (403,
administrators included); another CAD editor of the group does. The request page
and
GET /api/v1/change-requests/{number}showcan.decide = falsefor them. - A reviewer confirms each “use as is” deviation. For an as-built revision of
the group in a change order, every deviation with the disposition use as is is
a concession that a reviewer of that change confirms on its own (Confirm in the
deviation table,
POST /api/v1/as-built-deviations/{id}/confirm) before any approval counts – approving earlier answers 409 and names the deviations. The person who recorded the deviation cannot confirm it (four-eyes). Changing a deviation clears its confirmation, and Revise copies deviations unconfirmed. The audit log recordsas_built.concession_confirm.
Step 9 of #152.
What outsiders see of links
A visible item can point to a restricted one, and the other way round:
| Link | What a non-member sees |
|---|---|
| BOM line, item reference, simulation basis to a restricted item | a placeholder row “Restricted item”: find number, quantity, reference kind and basis role stay; number, name, revision, link and the notes (they describe the hidden item) are removed, and the line is never expanded. A hidden simulation basis never says whether it is out of date (that would tell when the hidden item is released or its file checked in). Placeholders are listed after the visible rows of their kind, so their position tells nothing about the hidden number. Editing such a line keeps its stored notes. When a non-member removes or edits it, the history names the real item for members (and is redacted for everybody else) |
| Mass and cost roll-ups | leave restricted lines out; the BOM says “mass and cost exclude N restricted lines” (restricted_lines, rollup_incomplete in the API) |
| Where-used, referenced-by, simulations based on an item, impact analysis | restricted entries are left out and counted: “+N … in groups you cannot see” |
| CAD file-name matches (file references, suggestions, working sets) | matches in restricted groups are left out without a count; the name shows as not in TeamCraft |
| Similar parts (shape search) | restricted items are left out without a count, also when their file content is identical |
| Release readiness of a change | a restricted unreleased dependency still blocks the release and shows as “restricted dependency not released – ask a member of its group or an admin” |
| Pack and Go, desktop Get | restricted items contribute no files; the manifest and the report only count them |
| Change propagation (impacts) | a check on a visible item caused by a restricted release says “a restricted item this depends on was released”; the releasing change order is named only to readers who may see it (a change that also holds restricted items is left out). The same applies to the answer of “Acknowledge all” |
| Audit history | an entry about a link to a restricted item reads “… changed a link to a restricted item”; an entry whose text names restricted items or a change order the reader cannot see (the revisions of a submitted, rejected or released change, flagged dependents, a request’s items, a linked change) reads “… (the details name restricted items)” without details. Flagged dependents are listed per group, so the entries about visible dependents stay readable |
| Readiness of a change | the depending revision of a hidden item shows as “restricted item”; another open change the reader cannot see shows as “another open change order” (also in the error when an item is already part of one). Restricted dependencies follow the visible ones of their revision, and rows of hidden depending revisions come last – also in the error of a refused submit – so their place tells nothing about a hidden number |
| Change request items, change order items | a restricted affected item is a placeholder row without its note, listed last |
| Save As out of a restricted group | the copy’s history entry (“Saved … as a new item”) is redacted for readers who may not see the source; the copied file versions say “Copied from a restricted item” |
| Vault explorer folder of a restricted group | “Folder not found”, exactly like an unknown folder id |
Change orders and change requests
They have no group of their own. A change order is visible to administrators, its author, its reviewers and to everybody who can see every affected item (a draft without items: author, reviewers, admins). A change request is visible to administrators, the requester, the assignee and to everybody who can see every affected item (a request without items: everybody). Inside a visible change or request, rows of items the reader cannot see are placeholders.
Reviewers and assignees must be able to see every affected item; TeamCraft refuses others – also when an item is added later to a change with reviewers or to a request with an assignee. Submitting, reviewing and reopening a change needs the same – an author or reviewer who lost membership can still withdraw or cancel, but not approve blind. Adding a reviewer, assigning a request and the reviewer suggestions need it as well: whether somebody can see a hidden item would tell who belongs to its group, so an author who cannot see every item gets no suggestions and cannot add reviewers. A change cannot be submitted while one of its reviewers is deactivated.
Only the author of a change order and administrators edit, submit,
withdraw, reopen or cancel it. When the author is deactivated, a manager
of every project group the change touches may also withdraw, reopen or
cancel it, or take it over (it becomes theirs; audited as
change.handover). A change without items, or with an item outside any
group, has no such manager.
Notifications, e-mail, chat, webhooks, assistant
- Notifications are created only for recipients who may read their subject, evaluated per recipient. A text that also names other items or a change order (the revisions of a change, the change that released an item or implemented a request) is replaced by a shorter text without them for recipients who may not see all of them.
- Notifications are checked again when they are read: after a group was
restricted or a membership removed, the older notifications about it
disappear from the list and the unread count, and pending e-mails and
chat posts about it are not sent (marked
skipped). They come back with the access. Pending e-mails and chat posts to a user deactivated since are not sent either. The groups of the items a notification names are stored with it (migration 0026), so it stays hidden from non-members after such an item is deleted. - With the deprecated webhook mode (
TEAMCRAFT_MATTERMOST_DIRECT=false, posts in a shared channel), notifications about restricted groups show only “new TeamCraft notification” and the link – decided when the post is sent, so a group restricted after the notification was queued counts. E-mail and Mattermost direct messages of the bot go to one recipient and carry the full text. - A webhook sees its own group and the internal groups; a webhook for all
groups receives no events of restricted groups. Scope a webhook to
the group to get them (see Event webhooks). The
revision.releasedevent of a visible revision released by a change that also holds restricted items comes without thechangeobject. A queued delivery whose group is restricted before it is sent is not sent (statusfailed, “Not sent: a project group of the event was restricted after it was queued”). - The MCP server and the built-in assistant use the user’s rights, so they see the same as the user. The assistant sends what a member reads to the configured model provider. It reads the user’s rights again before every tool call, so a membership removed during a run applies at once.
- Text other users typed (request comments, names, notes) reaches the
assistant and may try to make it copy restricted content into a comment
or a draft that those users can read. Once a run has read something that
a reader outside the user’s restricted groups would not get (that reader
is also somebody else: what the user sees only as author, reviewer,
requester, assignee or admin counts, and so does their worklist – also
for a user who is in no restricted group), the page the question was
asked on is such an item or change order, or a
draft the assistant created or extended holds such content, the
assistant’s text-writing tools (
create_change_draft,create_change_request,comment_change_request) refuse for the rest of the run. External MCP clients act for the user without this check – the same caution applies as to any agent working with the user’s rights.
When changes take effect
Memberships and roles are loaded on every request (web, API tokens,
desktop, MCP): removing a member takes effect on the next request. Nothing
is deleted – watches and favorites of the lost items are hidden and come
back with the access, check-outs stay (the removal message says how many).
A manager of the group (or an admin) cancels them on the revision page or
with POST /api/v1/items/{number}/revisions/{code}/cancel-checkout:
managers may release the check-outs of users who are no longer members of
the group or who are deactivated. An active member’s check-out a manager
releases only with a reason (10 to 500 characters): Release with reason
on the revision page, or {"reason": "…"} as the body of the request. The
reason is stored in the audit log (revision.checkout_forced_release) and
told to the holder in their notification; without one the release is refused
(403). Other members cannot release it at all. Release N check-outs in the member list
releases all of one person’s check-outs in the group at once (each audited
on its own); administrators release everything a user holds from the user
page. Season handover (/projects/{id}/season) does the same for many
members at once, with one reason for all of them, and also sets member
until, removes and demotes – each step with the rules of the single action.
See When someone leaves and
The end of a season.
Memberships synchronised from the identity provider change only at the user’s next SSO login; a user removed from the IdP group keeps access until then, and indefinitely with an API token. Revoke the user’s tokens or deactivate the user as well. Such a membership stays synchronised when its holder is made a manager (Demote lowers it again), and TeamCraft refuses to remove it while the user is active (it would come back at the next SSO login): remove the user from the IdP group, or have an administrator unlink the group or deactivate the user. Once the user is deactivated, a manager or administrator can remove it: SSO refuses deactivated users, so it cannot come back. A group needs at least one active manager; deactivated managers do not count when a manager leaves, is removed or is demoted (administrators may still do so). A manually added member who is also in the linked IdP group comes back (synchronised) at their next SSO login after being removed.
Files already copied to a desktop working folder stay on that computer; the desktop app marks such revisions as “No longer accessible” and never syncs them again.
Upgrading
Migrations 0024 to 0028 run on start and need no action: 0024 adds the visibility (every existing group stays internal, so nothing changes until a group is restricted), 0025 to 0028 add and back-fill what audit entries and notifications name. Take a backup first, as before every upgrade. Behaviour that changes for everybody, also without a restricted group:
- Placeholder rows (“Restricted item”) exist only once a group is restricted; for internal groups BOMs, references and notes look as before.
- An item of a restricted group that is listed on a change request cannot be deleted until it is removed from the request. Deleting an item of an internal group removes it from its requests and records that in their history.
- A webhook for all groups stops receiving events of a group once it is restricted – scope a webhook to the group before restricting it if a receiver needs them.
- Before restricting a group that shares the default numbering scheme, give it its own scheme (see below), or the shared counter keeps telling how many items it creates.
What stays visible
These are inherent to a shared instance and documented on purpose:
- Item numbers, project group codes and change/request numbers are unique across the instance: choosing a taken one says so (“already in use”, the BOM import says “not available”).
- The shared default number counter shows how many items other groups
create. Give restricted groups their own scheme, e.g.
{PROJECT}-{TYPE}-{SEQ:4}. A restricted group with a scheme of its own counts in a private counter: another group whose scheme expands to the same text (also one saved before the restriction, or for a later year or month) counts on its own, its numbering preview never shows the restricted group’s numbers, and no scheme is refused because of a restricted group. The private counter starts where the shared one stood when the group was restricted (numbers everybody could see then) and is merged back when the group is opened again. A generated number that is already taken by an item the user may see is skipped; one taken by a hidden item stops the creation (“already in use; change the scheme or enter a number”) instead of skipping on, so another group’s scheme tells of one existing number at a time, like typing a number in does – never how many there are, and it does not use up the restricted group’s numbers. (The built-in shared scheme keeps skipping: its counter is past every generated number, so it only meets typed-in numbers.) - A BOM line that would close a cycle is refused even if the cycle runs through restricted assemblies (“would create a circular BOM structure”): the structure must stay a tree for everybody.
- Change and request numbers are one sequence for all groups.
- Where-used and referenced-by counts show that restricted items use an item – its owner needs to know before making it obsolete or deleting it.
- Audit entries written before this feature (migration 0024) are shown as stored; their numbers were visible to everyone at the time. Migrations 0025 and 0026 mark the older impact, change and request entries with the items and changes they name (also items listed in the details that have left the change or request since), so these are redacted like new ones. Older notifications get their subject (item, change, request) from their link (migration 0027) and the items and change orders their text names from the numbers in it (migration 0028, e.g. the released source of an impact notice), and are hidden like new ones; only those whose subject and named objects have been deleted since stay as they are.
- Response times and caches: the preview meshes and shape signatures are stored per file content (SHA-256) for the whole instance. Somebody who uploads a file with exactly the same bytes as one in a restricted group can tell from how fast its preview and its similar-parts card are ready that the content exists already – they hold the file themselves, and no hidden item is named.
- Audit entries and notifications remember the groups of the items they name when they are written (migration 0026). They stay redacted for non-members of those groups after the item is deleted or moved out of the group. An item of a restricted group that is an affected item of a change request cannot be deleted (remove it from the request first), so deleting it never makes a hidden request visible; a member who cannot see that request learns that one exists. Items of internal groups hide no request and are deleted with their request rows; each such request records the removal (with the item’s note) in its history and tells its people, without telling the deleter about requests they cannot see.
- Response times of hidden and missing objects are not equalised.
- Backups and the command line contain everything (operators).
/admin/auditomits and redacts entries about groups hidden from the administrator by a need-to-know policy.
Event webhooks
Webhooks connect TeamCraft to the rest of the toolchain: when a revision is
released, export the BOM to the ERP, start the CAM job, post to the
channel. An administrator registers a URL for a set of events; TeamCraft
then sends each event as a signed JSON POST. The format is generic – any
CI server, ERP connector, automation tool or small script can receive it.
Webhooks are managed on the page Webhooks (administrators only, next to Users) or through the REST API. Creating, changing, deleting, rotating the secret, sending a test and retrying are recorded in the audit log (with the receiver’s host, never the full URL or the secret).
Events
| Event | Sent when | Objects in the payload |
|---|---|---|
revision.released | a change is released – one event per released revision | revision, change |
revision.obsoleted | a released revision is made obsolete | revision |
change.submitted | a change is submitted for review | change, revisions |
change.approved | a reviewer approves (review.change_status tells whether it released) | change, review, revisions |
change.rejected | a reviewer rejects – the change is rejected | change, review, revisions |
change.released | the last approval releases the change | change, revisions |
change_request.created | a change request is raised | change_request, items |
change_request.decided | a change request is accepted, rejected or deferred | change_request, decision |
simulation.result_added | a result value is added, or a result table is imported | revision, result or import |
ping | Send test (cannot be subscribed) | webhook, message |
A webhook can be limited to one project group: it then receives only events
about items of that group (for a change: any of its revisions; for a change
request: any affected item). The revisions list of a change event then
holds only that group’s revisions.
Restricted project groups: a webhook is treated as a
reader that sees its own group and the internal groups. A webhook for all
groups receives no events of restricted groups, and a webhook limited to
one group receives no events that touch another restricted group (a change
with revisions of two restricted groups goes to neither). To forward the
events of a restricted group, limit a webhook to that group. The forms warn
when restricted groups exist. revision.released of a revision the webhook
may see, released by a change that also holds restricted items, comes
without the change object. A queued delivery whose project group is
restricted before it is sent is not sent: it is marked failed with “Not
sent: a project group of the event was restricted after it was queued”.
Payload
Every payload has the same envelope plus the objects of the event. Objects
carry their identifiers, a web_url for people and an api_url for the
REST API. File contents and secrets are never part of
a payload; fetch files through the API with a token of a dedicated user.
{
"event": "revision.released",
"id": "0b7c3c4e-6a55-4b86-9c1e-3a5d2b8f1e07",
"occurred_at": "2026-11-05T09:41:12.315Z",
"actor": { "username": "rolf" },
"revision": {
"id": "5f0e…", "item_id": "a1c2…", "item_number": "PRT-000042", "item_type": "part",
"code": "B", "label": "PRT-000042/B", "name": "Bracket", "state": "released", "project": "ROVER",
"web_url": "https://plm.example.org/revisions/5f0e…",
"api_url": "https://plm.example.org/api/v1/items/PRT-000042/revisions/B"
},
"change": {
"id": "c9d1…", "number": "CHG-000007", "title": "Thicker flange", "priority": "normal",
"status": "released", "author": "Erika Muster",
"web_url": "https://plm.example.org/changes/c9d1…",
"api_url": "https://plm.example.org/api/v1/changes/CHG-000007"
}
}
id identifies the event (the same for every webhook that receives it).
The URLs are built from TEAMCRAFT_PUBLIC_URL when the request is sent. A
typical ERP connector reads revision.api_url, appends /bom?rule=released
and imports the structure.
Request
POST <your URL>
Content-Type: application/json
User-Agent: TeamCraft-Webhooks/<version>
X-TeamCraft-Event: revision.released
X-TeamCraft-Delivery: 3e1f2a9c-… (the same for every retry of this delivery)
X-TeamCraft-Signature: sha256=<hex HMAC-SHA256 of the body, keyed with the secret>
Answer with any 2xx status within 10 seconds – queue longer work (a CAM
job) instead of doing it in the request. The response body is ignored and
not stored. Redirects are not followed: configure the final URL.
Checking the signature
The secret (tc_whsec_…) is shown once when the webhook is created or the
secret is rotated; afterwards only a hint (tc_whsec_…1a2b) is visible.
Compute HMAC-SHA256 over the raw request body with the secret as key (its UTF-8 bytes, prefix included) and compare
it with the header in
constant time. Reject requests whose signature does not match.
Shell (e.g. a CI step that received the body in $BODY):
expected="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$TEAMCRAFT_WEBHOOK_SECRET" | sed 's/^.*= //')"
# `[ a = b ]` stops at the first differing byte; comparing hashes of both
# values leaks nothing usable about the expected signature.
[ "$(printf '%s' "$expected" | sha256sum)" = "$(printf '%s' "$SIGNATURE_HEADER" | sha256sum)" ] \
|| { echo "bad signature" >&2; exit 1; }
A shell variable cannot hold every body byte for byte (command substitution drops trailing newlines, NUL bytes are lost); for anything beyond a quick check, verify the raw body in a real program.
Python:
import hashlib, hmac
def verified(secret: str, body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
# bytes, not str: compare_digest raises TypeError for non-ASCII text
return hmac.compare_digest(expected.encode(), header.strip().encode())
Rust (crates hmac, sha2 and hex):
#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
fn verified(secret: &str, body: &[u8], header: &str) -> bool {
let Some(hex_sig) = header.strip_prefix("sha256=") else { return false };
let Ok(sig) = hex::decode(hex_sig) else { return false };
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).expect("any key length");
mac.update(body);
mac.verify_slice(&sig).is_ok() // constant time
}
}
Replays: remember the X-TeamCraft-Delivery ids you processed and skip
repeats; the payload’s occurred_at lets you drop very old requests.
Retries and delivery order
A delivery that does not get a 2xx answer (error status, timeout, refused
connection) is retried after about 1, 2, 4, 8, 16, 32 and 64 minutes – each
delay with up to 25 % jitter – and marked failed after 8 attempts (roughly two hours). The webhook’s page lists the
latest deliveries with
status, attempts, HTTP status and the last error, shows each payload, and
has Retry (a fresh set of attempts, tried at once) and Send test.
Deliveries are written in the same database transaction as the event
itself – an action that fails or is rolled back sends nothing, and a
committed one is never lost, even if the server restarts before sending.
With several TeamCraft servers on one database each delivery is claimed by
exactly one of them (FOR UPDATE SKIP LOCKED with a lease of two minutes;
a sender that crashes mid-request is retried after the lease). Deliveries
can arrive out of order and, after a crash, a lost response or a Retry
while an attempt is still running, more than once – use occurred_at and
the delivery id.
Switching a webhook off gives up its waiting deliveries; deleting it removes
its history. Sent and failed deliveries are removed after
TEAMCRAFT_WEBHOOK_RETENTION_DAYS (default 30).
Where webhooks may send to
Only http:// and https:// URLs are accepted, without a user name or
password in the URL (the signature authenticates TeamCraft; a token of the
receiver may go into the query string). To keep webhooks from being used to
reach internal services (server-side request forgery), TeamCraft checks
every address it connects to, at connection time and after DNS resolution:
- Default: public addresses only. Loopback (
127.0.0.0/8,::1), private (10/8,172.16/12,192.168/16,fc00::/7), shared (100.64/10), link-local (169.254/16including cloud metadata endpoints,fe80::/10) and similar ranges are refused, also when they are written as IPv4-mapped, NAT64 or 6to4 IPv6 addresses. TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS(recommended for on-premises installations): only the listed hosts can be targeted, and they may resolve to internal addresses – e.g.erp.corp.example,*.ci.corp.example.TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE=true: every host may be internal. Use it only when nothing sensitive is reachable from the server.
The unspecified address, broadcast and multicast are always refused. Proxy
variables (HTTPS_PROXY, …) are ignored for webhooks so the check applies
to the real target. See Configuration.
How the secret is stored
The server needs the secret itself to sign, so – unlike passwords and API
tokens, which are stored as hashes – it is kept in the webhooks.secret
column of the database. It is never shown again in the web UI or returned by
the API, and it is not written to logs or the audit trail. Anyone with read
access to the database or to a backup can sign payloads, so
protect both like the vault. Rotate the secret on the webhook’s page after a
suspected leak; the new secret applies at once, also to deliveries that are
still waiting.
Branding: your own logos
Administrators can replace the two TeamCraft logos under Admin → Branding
(/admin/branding):
| Slot | Where it appears | Suggested size |
|---|---|---|
| Wide logo | top left of the web header, the sign-in page, the title bar of the desktop app | about 5:1, for example 600 × 120 px, transparent background |
| Square logo | bottom left of the web page (footer), the browser tab icon, the navigation rail of the desktop app | 1:1, for example 256 × 256 px |
Without an upload the built-in TeamCraft logos stay. Use the built-in logo removes an upload again.
Rules for the files
- PNG, JPEG, WebP or SVG – the type is decided from the content, not from the file name.
- At most 512 KiB; bitmaps at most 4096 pixels per side. A PNG, JPEG or WebP file whose pixel size cannot be read is refused.
- SVG files must not contain scripts, event handlers, animations,
foreignObject, entity declarations (<!ENTITY …>) or links to other files:href,xlink:href,srcand CSSurl(…)may point only into the file itself (#id) or carry an embeddeddata:image/…. Namespace declarations and metadata, as Inkscape writes them, are fine. Illustrator files saved with Preserve Illustrator Editing Capabilities carry entity declarations and are refused; use File → Export → Export As… → SVG instead, or save from Inkscape as Plain SVG. SVG logos are shown only as images and served with a sandboxingContent-Security-Policy. - The web header shows one wide logo for all skins (light and dark). A logo with a transparent background and colours that read on both is best.
What else to know
- The logos are stored in the database, so a backup contains them and a restore brings them back.
- Every upload and reset is written to the audit log (
branding.set,branding.reset). - The web pages show a new logo at once. The desktop app loads the logos when it signs in or starts; sign out and in again to see a change.
- The image routes
GET /branding/wideandGET /branding/squareneed no sign-in (the sign-in page shows the logo); they answer 404 while the built-in logo is in use. The REST API reports the state and the content types atGET /api/v1/branding; its image routes are cachedprivate.
Header name and subtitle
Under Admin → Branding → Header name you can rename what the top left of the web header and the desktop title bar shows, today TeamCraft PLM:
- Display name – up to 40 characters, plain text on one line (no
<or>). Leave it empty to use the default:TEAMCRAFT_INSTANCE_NAME, else TeamCraft. - Subtitle – up to 20 characters, default
PLM; empty shows none.
The name appears in the web header (when no wide logo is set; the logo wins),
page titles, the sign-in page, notification e-mails and chat messages, and the
desktop title bar. Changes are effective at once in the web UI and written to
the audit log (branding.update); the desktop app reads them when it signs in.
The REST API has GET /api/v1/branding (display_name, subtitle) and
PUT /api/v1/branding/name (administrators).
Renaming the instance never renames the software. The footer, the desktop About dialog and Settings, the
version, the licence and no-warranty
notices, /api/v1/version, the CLI, the desktop user agent and the MCP
serverInfo always say TeamCraft, so support and the MPL-2.0 notices stay
clear about which software runs.
Upgrading and rolling back
TeamCraft upgrades itself: the server applies every missing database
migration when it starts (teamcraft migrate does the same without
starting the server; teamcraft serve --no-migrate skips it). There is no
downgrade migration. The way back is a restore of a backup taken before the
upgrade. Take that backup every time – while TeamCraft is 0.x any release
may contain breaking changes (Contributing → Releases and
versions).
Before the upgrade
-
Read the release notes of the GitHub Release you upgrade to, and the breaking changes below.
-
Check PostgreSQL. The server refuses to start on a PostgreSQL older than 16 (it names the version it found). Upgrade the database server first if needed (
pg_upgrade, or dump and restore). -
Stop the server so that the backup is one point in time.
-
Back up database and vault and keep the directory until the new version has proven itself:
teamcraft backup /srv/backups/before-0.3.0 # db.dump + vault/objects + manifest.jsonThe Docker installation backs up through the database container instead (Install with Docker → Backup and restore). Details, requirements (
pg_dumpin the server’s major version or newer) and what the backup verifies: Operations → Backups. -
Optional:
teamcraft vault check --verify-hashes– a backup stops at a corrupt or missing blob anyway, this finds it earlier.
Upgrade
| Installation | How |
|---|---|
| Docker installation | run the installer again (… | bash, or -s -- --version X.Y.Z); see Install with Docker |
| Binary | replace the teamcraft binary and start it |
| From source | git pull, cargo run --release |
On start the server
- waits for PostgreSQL, checks its version, then runs the migrations in order. Every migration runs in its own transaction: if one fails, it is rolled back completely, nothing of it stays (no half-added column, no half-converted rows), the server does not start, and the log names the migration. A database that is several versions behind applies all missing migrations one after the other, in one start; a migration that is missing below the newest applied one (as 0021 on a database that already has 0022) is applied too.
- does not touch rows of tables that are only extended: new columns get a default, new tables start empty.
- afterwards runs the background scans of the current version (for example reading the export names of STEP files, migration 0036). They fill in derived data and can be interrupted and repeated; the web interface is usable meanwhile.
If a migration fails, fix the cause named in the log (disk full, a missing permission, a database edited by hand) and start again – the migrations that succeeded are not repeated. If you cannot fix it, roll back.
Refusals that protect the data
- A changed migration is refused. The checksum of every applied migration is stored. If the text of an applied migration differs from the stored checksum, start-up stops with “was previously applied but has been modified”. Only five migrations that were edited before the first release (0002, 0003, 0005, 0011, 0012; comments and seed data only) are accepted with the new text. Never edit a migration of a database you keep.
- A half-applied migration is refused (
success = falsein_sqlx_migrations, which normal operation never leaves behind). - An older server refuses a newer database. If the database has migrations the binary does not know, start-up stops with “migration N was previously applied but is missing in the resolved migrations”. Starting the old binary again after an upgrade therefore does not work: restore the backup first.
After the upgrade
curl -s http://localhost:8080/healthzand/readyzanswerok(teamcraft healthcheckdoes the same for a container).- Sign in, open an item with files and a BOM, open a change order.
- Optional:
teamcraft vault check– every file the database refers to is in the vault. - Read the log of the first minutes: the start-up scans (file formats, CAD file references) log how many files they handled, or a warning if they failed; they run in the background and do not stop the server.
Keep the backup for a while: the upgrade is only reversible by restore while the database has not been written to by users – everything entered after the upgrade is lost by a rollback (the vault keeps the new files, which is harmless: it is append-only and content-addressed).
Rolling back
A rollback is a restore of the backup into a fresh, empty database, followed by starting the old version:
# as the PostgreSQL superuser
createdb -O teamcraft teamcraft_old
# the server is stopped; point TEAMCRAFT_DATABASE_URL at teamcraft_old, then
teamcraft restore /srv/backups/before-0.3.0
# start the old binary or image
- The restored database has exactly the migrations of the backup. The old version starts on it. Starting the new version on it again applies the newer migrations again (tested: a backup taken before the newest migrations is restored and migrated without loss).
teamcraft restoreverifies the manifest, the checksum ofdb.dumpand every vault object first; a damaged backup is refused before the database is touched, andpg_restoreruns in one transaction, so a failing restore leaves the database unchanged.- Restoring over the already upgraded database needs
--force. It replaces the tables contained in the backup but not tables of the newer version. When such a table depends on a restored one (a foreign key), the restore fails as a whole and changes nothing; where it does not fail, the newer tables stay while the migration table says they were never created, and the next upgrade stops with “relation already exists”. Use a fresh database. - Sessions and API tokens are part of the database: they are as old as the backup.
- Configuration and secrets are not part of the backup. Keep the configuration of the old version next to the backup.
Mattermost: switch from the webhook to a bot
Mattermost notifications are now direct messages of a bot account (TEAMCRAFT_MATTERMOST_URL and
TEAMCRAFT_MATTERMOST_BOT_TOKEN, see
Configuration).
An installation with only TEAMCRAFT_MATTERMOST_WEBHOOK keeps working, but
logs a deprecation warning at start-up and the account page says so; the
webhook mode will be removed in a later version. To switch:
- Create the bot account and its token in Mattermost.
- Set
TEAMCRAFT_MATTERMOST_URLandTEAMCRAFT_MATTERMOST_BOT_TOKEN(or…_FILE); removeTEAMCRAFT_MATTERMOST_WEBHOOKandTEAMCRAFT_MATTERMOST_DIRECT. The bot wins when both are set. - Restart and send a test notification from Account → Notifications.
Differences users notice: nothing is posted into the channel any more, the
messages come from the TeamCraft bot, and notifications about restricted
project groups carry their full text (unless the group switched notification
content off). A wrong Mattermost username now shows as a failed delivery
with a reason instead of a post that vanishes. No database change is needed.
MATTERMOST_ERROR_WEBHOOK is not affected.
Breaking changes per version
What changed in each version is in
CHANGELOG.md
and the notes of its GitHub Release. What the database upgrade itself does,
per released version (a migration is additive unless it says otherwise; the
last row is main, not yet released):
| Upgrade | Migrations | What changes |
|---|---|---|
| v0.1.0 → v0.2.0 | 0032 – 0035 | New tables for the assistant conversation (0032), result templates and a source column (0033), result proposals (0034) and request digests (0035). 0035 replaces the CHECK constraint of notification kinds with a longer list (adds request_digest). No data is converted or removed. |
| v0.2.0 → v0.2.5 | 0036 – 0039 | file_versions.export_name and export_name_scanned (default FALSE; the start-up scan reads the STEP header of the stored files once) (0036); new tables for idempotency keys of item creation (0037), custom logos (0038) and as-built records with their deviations (0039). 0039 replaces the CHECK constraint of item types with a longer list (adds as_built). No data is converted or removed. |
| v0.2.5 → v0.2.6 | 0040 – 0061 | New tables and columns for review policies, review rounds, need-to-know policies with elevations, interface switches and the read log, access reviews, token scopes, file retention, item owners, solver summaries, branding names, storage quotas, 3D notes and change order line actions. Data is filled in, not removed: 0040 replaces the CHECK constraint of result template methods (adds table); 0041 sets changes.submitted_by to the author of submitted changes; 0042 turns the new four-eyes check off for changes already in review; 0044 keeps elevations of deleted users (copies the username, user_id may be NULL); 0045 lets upload sessions exist without a revision; 0050 sets items.owner_id to the creator; 0059 validates the solver summary CHECK of 0049; 0060 adds users.preferred_cad (default empty = show all); 0061 adds the tables materials, revision_mass and file_mass_settings for the mass properties. |
| v0.2.6 → v0.2.7 | none | No migration: nothing changes in the database. New settings: TEAMCRAFT_MATTERMOST_URL and TEAMCRAFT_MATTERMOST_BOT_TOKEN (optional, see below). |
v0.2.7 → main (unreleased) | 0062 – 0071 | New columns for the review rules (0062), sign-off of review decisions (0063: sessions.reauth_at, change_reviews.sign_off_method, …), instance counts of CAD references (0064, 0065), access review intervals (0067), membership conditions (0068), manager gap alerts (0069), member until (0070: project_members.expires_at) and the identity provider’s back-channel logout (0071: users.idp_logout_at). Data is converted, not lost: 0064 and 0065 delete the stored references of the current NX part, SOLIDWORKS assembly and referencing STEP file versions and mark them unscanned, so the start-up scan reads them again with their instance counts; 0066 replaces the CHECK constraints of the read log’s channel (adds mcp, assistant, desktop) and kind (adds workset). New optional settings: TEAMCRAFT_APPROVAL_REAUTH, TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS. |
An older published release first needs the steps of the releases between: a database of the first v0.1.0 tag (0001 – 0014, 0016 – 0020, 0022) gets 0021 and 0023 – 0031 first, then the rows above. Of these, the read-permission migrations 0024 – 0028 change behaviour for everybody: see Read permissions → Upgrading.
The proof behind these statements is in tests/migrations.rs (an empty
database, every migration one at a time, a v0.1.0 database with data) and
tests/upgrade_restore.rs (a seeded database one and several migrations
behind head, a migration that fails and rolls back, an older server on a newer
database, a backup from before the newest migrations restored and migrated, a
damaged backup refused). When a release adds a migration that converts or
removes data, its row in the table above says so before the release.
Operations
| Endpoint / command | Purpose |
|---|---|
GET /healthz | liveness: the process serves HTTP (ok) |
GET /readyz | readiness: database reachable and vault writable (JSON, 503 otherwise) |
teamcraft healthcheck [--addr host:port] | exit code of /healthz, for container health checks |
teamcraft vault check [--verify-hashes] | every stored file present (and unchanged) |
teamcraft migrate | apply migrations without starting the server |
teamcraft local [--port N] [--data-dir DIR] [--pg-bin DIR] | a test installation on this computer: a private PostgreSQL cluster and the server, both on 127.0.0.1, data in the per-user data folder – not for production (Try it on your computer) |
teamcraft assistant check | test the assistant setup: reachable, key, model, tool calls (no database needed, never prints the key) |
teamcraft rescan-references [--format id] | read the CAD file references of the stored files again (default: SOLIDWORKS assemblies and drawings), e.g. after an update that reads a format better |
teamcraft access-log purge [--project CODE] [--older-than-days N] | delete read log entries of restricted groups older than the group’s retention (or than N days), audited |
teamcraft vault signatures [--convert] [--jobs N] | compute the missing shape signatures of the similar part search from the cached 3D previews (--convert: also files without a preview yet), N (1–2, default 2) at a time in the isolated preview process |
teamcraft backup <dir> | write database and vault backup (pg_dump) |
teamcraft restore <dir> [--force] | verify and restore a backup (pg_restore) |
teamcraft mcp --server URL | stdio bridge for AI assistants to POST /api/v1/mcp (MCP) |
Backups
teamcraft backup <dir> writes one backup directory; <dir> must not exist
or be empty:
| Path | Content |
|---|---|
db.dump | pg_dump --format=custom of the configured database |
vault/objects/… | the content-addressed blobs (the previews/ cache is not included) |
manifest.json | Unix timestamp, TeamCraft version, object count, SHA-256 of db.dump |
The PostgreSQL client tools (pg_dump, pg_restore) must be installed on
the machine that runs the command, in the server’s major version or newer (Debian/Ubuntu: postgresql-client-17;
Windows: the bin folder of the
PostgreSQL installation on PATH). The database password is handed to the
child process in PGPASSWORD only. The database is dumped first and the
vault afterwards; blobs are append-only, so every file that the dump
references is included. Every blob is checked against its SHA-256 while it
is copied, and every file content the database refers to must be in the
vault: a corrupt or a missing blob stops the backup (run teamcraft vault check --verify-hashes to find it). Stop the server for a consistent point
in time. A failed backup removes what it wrote – the directory, if it
created it – and the manifest is written last, so an interrupted backup is
never taken for a complete one.
Restore with the server stopped:
sudo -u teamcraft teamcraft restore <dir> # database must be empty
sudo -u teamcraft teamcraft restore <dir> --force # replace an existing database
The command first verifies the manifest, the SHA-256 of db.dump, the
object count and the SHA-256 of every vault object, then runs
pg_restore --clean --if-exists --no-owner --single-transaction against the configured database
and copies the vault back without overwriting blobs that are already
present. Start the server afterwards; it applies newer migrations.
- The target database must exist; TeamCraft does not create it (
createdb -O teamcraft teamcraftas the PostgreSQL superuser). pg_restoreruns in a single transaction: if it fails, the database is unchanged, and the vault too (it is copied only after the database).- Large objects of the database travel with the dump. The
previews/cache is neither backed up nor touched by a restore; previews are made again. - A backup of an older version restores into an empty database; the next start migrates it (tested with a v0.1.0 database).
--forcereplaces the objects contained in the backup; other objects in the database stay, so a fresh, empty database is preferable. To roll back an upgrade, restore into a fresh database and start the old version; the next start of the new version migrates it again. Over the upgraded database, the restore fails when a table of the newer version depends on one it would replace; where it succeeds, tables the newer version added remain and the next upgrade may stop with “relation already exists”. See Upgrading.- The database password comes from the configuration –
TEAMCRAFT_DB_PASSWORD(or_FILE) or the password inTEAMCRAFT_DATABASE_URL, where special characters are percent-encoded (p%40ssforp@ss). - Sessions and API tokens are part of the database: after a restore, everyone stays signed in and tokens keep working.
- Configuration and secrets (
.env, the systemteamcraft.env, TLS keys) are not part of the backup; back them up separately.
Encrypted backups
teamcraft backup <dir> --encrypt encrypts the dump and every vault object
with a key derived from a passphrase (TEAMCRAFT_BACKUP_PASSPHRASE or
_FILE, otherwise prompted for twice; at least 12 characters): Argon2id (64 MiB, 3 passes) for the key,
XChaCha20-Poly1305 in 64 KiB authenticated
chunks for the files. Object names and the manifest stay plain (the names are
the SHA-256 of the plain content); the manifest records the salt, the cost
and a key check. The plain dump exists only between pg_dump and its
encryption, inside <dir>.
teamcraft restore <dir> notices an encrypted backup and asks for the
passphrase (or takes it from the configuration). A wrong passphrase is
reported before anything is changed; every file is authenticated and
checked against its SHA-256 while it is decrypted, so a changed or truncated
file stops the restore. The dump is decrypted to a temporary file in the
vault’s tmp/ for pg_restore and removed afterwards. Without the
passphrase the backup cannot be restored – store it apart from the backups.
The container image has no PostgreSQL client tools; the Docker installation backs up through the database container instead – see Install with Docker → Backup and restore.
Performance
- Pages run their database queries concurrently; the revision page needs one round trip per independent query group.
- 3D conversions run in separate processes (at most two at a time) with a time limit, so a pathological CAD file never blocks the server; results and failures are cached.
- Uploads are streamed to disk and hashed on the fly; text extraction for the search runs on a blocking thread.
Security
Strict Content-Security-Policy (script-src 'self', no inline scripts or
styles), SameSite=Lax cookies plus an Origin check on every form, API
tokens only as bearer tokens (never cookies), path-traversal-safe static
files, content-addressed downloads with X-Content-Type-Options: nosniff.
Sizing
TeamCraft is measured against a large synthetic vault (see Scale check): about 100,000 items with 200,000 revisions, one million BOM lines in structures ten levels deep, 200,000 files, 20,000 changes and two million audit entries – about 1.6 GB in PostgreSQL. On a GitHub-hosted runner (4 CPUs, 16 GB RAM) nearly every page and API call of the check answers in less than 200 ms, most in a few dozen milliseconds. The slow ones do inherently large work: a search text that names a CAD program (“solidworks”) matches three quarters of the vault (about 1.6 s), and the impact analysis of a change with 1600 widely used parts walks tens of thousands of dependencies (about 220 ms).
- PostgreSQL 16 or newer. For a vault of that size, give PostgreSQL
about a quarter of the RAM as
shared_buffers(e.g.1GB), seteffective_cache_sizeto about half of the RAM and, on SSDs,random_page_cost = 1.1; the defaults (128 MB, random I/O priced for spinning disks) make the planner prefer full table scans. - The
pg_trgmextension (part of PostgreSQL, “trusted”, so the owner of the database may create it) backs the substring search over item numbers, names, attribute values and file names with trigram indexes. Migrations 0023 and 0031 create it when they can; otherwise the search still works but reads the tables – a few hundred milliseconds per search at 100,000 items. If the database role may not create extensions, an administrator can runCREATE EXTENSION pg_trgm;in the TeamCraft database and then theCREATE INDEX … gin_trgm_opsstatements ofmigrations/0023_performance_indexes.sqlandmigrations/0031_attribute_search_index.sql. - JIT is off and every statement gets a custom plan in TeamCraft’s
sessions (
jit=offandplan_cache_mode=force_custom_plan, sent as startup options of every connection; the server’s settings for other clients stay as they are): its queries are short, and on a large vault the JIT compilation took longer (100–400 ms) than the queries themselves; a generic plan of a prepared statement ignores how many ids an= ANY($1)list holds. Behind a connection pooler that drops theoptionsstartup parameter (e.g. PgBouncer withignore_startup_parameters = options) the server’s own settings apply instead. - Indexes of new migrations are built with plain
CREATE INDEXat start-up: that blocks writes to the table for the time it takes (seconds at the size above, reads go on) and happens before the server accepts requests. - Large results are bounded in the web UI: the BOM page and the BOM comparison list the first 2000 lines (the totals and the CSV/Excel downloads cover all of them), the “Where used” card 200 uses, the impact analysis of a change page the 500 nearest dependents. The where-used reports of the API and MCP stop at 2000 rows; the expanded BOM refuses structures beyond 100,000 lines or 5000 assemblies.
Scale check
tests/scale.rs is an ignored test – a tool for developers, not a feature.
It creates (once) a database teamcraft_scale on the test server, fills it
with the synthetic vault above using set-based SQL (tests/scale/seed.sql, a few minutes), and times the hot paths
through
the real router: item search, item and revision pages, the multi-level BOM
of the largest assembly, where-used of a standard part used in every
assembly, BOM comparison, dashboard and worklist of a busy user, vault
explorer, notifications, a change with 2000 affected items and its release
readiness, the MCP tools search_items, get_bom and where_used, and
the REST lists with paging. Each step runs once to warm up and then five
times (p50 and max); one more run per step collects the
EXPLAIN (ANALYZE, BUFFERS) plan of every statement through auto_explain.
The GitHub Actions workflow Scale check (.github/workflows/scale.yml)
runs it on PostgreSQL 18 – by hand (Actions → Scale check → Run workflow,
or gh workflow run scale.yml --ref <branch>) and weekly, never on pull
requests – and uploads the report as the artifact scale-report:
report.md (the timing table: queries per request, slowest statement,
sequential scans on big tables), plans.md (every plan) and results.json.
How to run it locally is described in CONTRIBUTING.md.
Exporting and protecting the audit log
Administrators export the audit log with the filters of Admin → Audit log
(Export CSV / Export JSON, or GET /api/v1/audit?entity_type=&username=&action=&format=json|csv).
One export holds at most 100 000 entries, newest first (x-truncated: true
when more matched – narrow the filter); entries about items an
administrator’s need-to-know policy hides are left out or redacted, as on
the page. Every export is itself recorded (audit.export).
TeamCraft only ever inserts into audit_log (the optional retention below
deletes old entries and records that it did). To make sure nobody can
rewrite it through the database either, run the server with a database role
that may not change existing rows, and keep the table owner for migrations:
-- as the owner of the database, after the migrations ran
REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM teamcraft_app;
With this grant TEAMCRAFT_AUDIT_RETENTION_DAYS must stay unset (the
purge needs DELETE). Together with regular backups that keep older
copies, this protects the record; a hash chain over the entries is not
part of TeamCraft.
Data retention
The server’s hourly housekeeping also reminds the managers of due access
reviews and tells the administrators once about each project group that has
lost its last active manager (see users).
It removes what is no longer needed. Nothing
the database still refers to is ever removed. Removals of product data (old
file versions) and of audit entries are recorded in the audit log; caches,
expired sessions, staged uploads and unreferenced blobs (also vault gc --apply) are removed without an entry.
| What | Rule |
|---|---|
| Sessions, OIDC states, stale desktop codes | removed when expired |
| Staged BOM imports and unfinished chunked uploads | removed when expired, with their vault blobs unless something refers to them |
| Webhook deliveries, idle assistant conversations | TEAMCRAFT_WEBHOOK_RETENTION_DAYS, TEAMCRAFT_ASSISTANT_RETENTION_DAYS |
| Audit log | TEAMCRAFT_AUDIT_RETENTION_DAYS (default: for ever); one audit.purge entry per removal |
| Preview cache | previews of blobs the database no longer refers to, files of old cache versions and leftovers; TEAMCRAFT_PREVIEW_CACHE_MAX_MB trims the rest |
| Old versions of files set to keep only the latest version | after TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS (default 7), see below |
Keep only the latest version (large result files)
Every upload of a file name is a new version and the vault keeps all of them (default, retention = all). For files
that change with every run and are
large – simulation results, measurement series – that adds up. Whoever holds
the check-out of a revision can switch a file to keep only the latest
version (⋯ → Keep only the latest version on the revision page, or
PATCH /api/v1/files/{id} with {"retention": "latest"}; back with Keep all
versions / "all"; recorded as file.retention). Nothing is removed at
that moment.
The housekeeping then removes a version once a newer version has replaced it
for at least TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS days, so a mistaken
upload can still be rolled back within the grace period. It never removes
- the newest version of a file,
- anything of a revision that is not in work – revisions under review, released and obsolete revisions keep every version,
- files that keep all versions.
Each removal is one file.purge_versions audit entry per file (versions,
hashes, bytes) in the transaction of the removal. The vault blobs that
nothing refers to afterwards are removed from disk; content that another file
still uses stays.
teamcraft vault purge-versions # dry run: list what would go
teamcraft vault purge-versions --apply # remove it
teamcraft vault purge-versions --grace-days 1 # another grace period
The command uses TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS (or 7 when that is
0) unless --grace-days is given, and works while the server runs.
Orphaned vault content
teamcraft vault gc lists the blobs in the vault that no file, version,
attachment, staged import or upload session names and that were not written
within the grace period (--grace-hours, default 24, at least 1); --apply
removes them. Run it after deleting files, or after purge-versions left
content behind that was younger than an hour. Back up before the first
--apply.
Storage quotas
Optional limits keep one user – or all of them together – from filling the disk. Both count the sizes of the stored file versions (identical content stored twice counts twice; versions removed by the retention free their share). Without a quota (the default) nothing is limited.
teamcraft user quota --username max # usage and quota of one account
teamcraft user quota --username max --mb 20480 # 20 GiB for max
teamcraft user quota --username max --mb none # no limit
teamcraft vault quota # usage and quota of the vault
teamcraft vault quota --mb 2097152 # 2 TiB in total
An upload, check-in, import or new item that would go beyond a quota is
refused with 413 and stores nothing; a chunked upload is refused when it
starts, before any bytes are sent. Lowering a quota never removes anything –
the user just cannot store more. Changes are recorded as user.quota and
vault.quota in the audit log.
Metrics for Prometheus
Set TEAMCRAFT_METRICS_TOKEN (at least 16 characters, also as _FILE) and
scrape GET /metrics with that value as bearer token. Without the variable
the endpoint answers 404. Example scrape configuration:
scrape_configs:
- job_name: teamcraft
authorization: { credentials_file: /etc/prometheus/teamcraft-token }
static_configs: [{ targets: ["plm.example.org"] }]
| Metric | Meaning |
|---|---|
teamcraft_http_requests_total{class} | answered requests by status class (2xx … 5xx) since the start |
teamcraft_http_request_duration_seconds | latency histogram: time until the response headers are ready; the streaming of a body (file downloads) is not included |
teamcraft_db_pool_connections, teamcraft_db_pool_idle_connections | database pool |
teamcraft_webhook_deliveries_pending | webhook outbox backlog |
teamcraft_notification_emails_pending, teamcraft_notification_chat_pending | notification outbox backlogs |
teamcraft_preview_conversions_running, teamcraft_preview_conversions_waiting | 3D preview queue: conversions running now (at most 2 per server) and conversions waiting for a free slot |
teamcraft_build_info{version} | the running version |
/metrics lies outside /api/v1 and accepts only the metrics token, no
personal access token or session. Its answers carry Cache-Control: no-store.
If a backlog cannot be counted (database unreachable), its gauge shows NaN
and the server logs a warning.
Several replicas: scrape every instance. Request counters, latency, the
database pool and the preview queue belong to the instance and can be summed
with sum(). The outbox gauges (teamcraft_webhook_deliveries_pending,
teamcraft_notification_*_pending) count rows in the shared database, so every
instance reports the same number: aggregate them with max(), never sum().
Useful alerts: a rising teamcraft_webhook_deliveries_pending or
teamcraft_notification_*_pending (a receiver or the mail server is down),
teamcraft_db_pool_idle_connections at 0 for minutes,
teamcraft_preview_conversions_waiting above 0 for many minutes (previews pile
up behind slow conversions), and a share of 5xx above 1 % of the requests.
Support policy
This page says which versions of TeamCraft receive fixes, what a version number promises, and where to report a problem. It applies to the server, the web UI, the REST API, the MCP server, the command line and the desktop app.
Status. TeamCraft has no stable release yet. It is a work in progress and
comes without warranty. Until the first stable release, the rules below for
0.x apply, and security fixes go into main only, as described in
SECURITY.md.
Version numbers
TeamCraft uses Semantic Versioning: MAJOR.MINOR.PATCH.
- Before 1.0 (
0.x), any release may contain breaking changes, including changes to the configuration, the REST API, the database schema and the command line. Each release note says what the upgrade needs. - From 1.0 on:
- a patch release (
1.0.1) fixes bugs and security problems and does not change behaviour that users rely on; - a minor release (
1.1.0) adds features and stays compatible: an upgrade within the major version does not need manual changes beyond the release notes; - a major release (
2.0.0) may break compatibility. Its release notes list every breaking change and the steps for the upgrade.
- a patch release (
- A pre-release (
1.0.0-rc.1) is for testing. It gets no fixes beyond the next candidate or the final release.
Database migrations are part of the upgrade. A release never changes an existing migration; a new migration is added instead. Back up the database and the vault before any upgrade, as described in Operations.
Which versions receive fixes
| Version | Receives |
|---|---|
Latest minor (1.x) | all fixes: bugs, security problems |
| Previous minor | security fixes, for six months after the next minor |
| Older minors | none; upgrade to a supported minor |
0.x (before 1.0) | only main |
From 1.0 on, when a new minor version is released, the previous minor version keeps receiving security fixes for six months after that release. After the six months, it is no longer supported. A patch release is made for the latest minor version only; a fix for an older minor is made only when it is a security fix.
Fixes are published as new releases, not as edits to old ones. The release notes and the security advisory name the versions that are affected.
Reporting a bug
- Bugs and feature requests that are not security problems: open an issue
at github.com/rudalabs/TeamCraft/issues.
Include the version (the image tag or the release, or the commit from
git rev-parse --short HEAD), the platform, the steps to reproduce, what you expected and what happened. Remove passwords, tokens and other secrets from logs before posting them. - Security problems: do not open a public issue. Report them privately as described in SECURITY.md through the GitHub security advisory form. The policy there sets out what is in scope and how a fix and its disclosure date are agreed.
- Questions about installation or operation: check this documentation first, the configuration reference and the operations guide. Then open an issue.
Before you report a bug, check the release notes of the version you run. The problem may be fixed in a newer release of your supported version.
What is not promised
- Response times. TeamCraft is maintained by volunteers.
- Support for a version that is no longer in the table above.
- Fixes for deployments that ignore the documentation, for example a server that is exposed without TLS.
Desktop app and server versions
The desktop app checks the server’s version on the sign-in screen and when its main window opens, and shows a warning once per server when the two do not fit:
| App | Server that works |
|---|---|
0.x.y | the same 0.x (any patch version) |
1.a.y and later | the same major version and a minor version a or newer |
Patch versions never matter. The message does not block the sign-in; it tells the user whether to upgrade the server or to install the matching app.
Supported file formats
TeamCraft accepts files of any type. The formats below are recognised:
labelled with program and kind, searchable by program, delivered with their
registered content type. 3D marks the formats the preview shows; refs the formats scanned for referenced files.
The complete, current
list is on every server at /formats and GET /api/v1/formats
(source: src/cadformat.rs).
Mechanical CAD (native)
| Program | Extensions |
|---|---|
| SolidWorks | .sldprt .sldasm (refs) .slddrw (refs), templates .prtdot .asmdot .drwdot .slddrt, .sldlfp .sldblk |
| Siemens NX | .prt (parts, assemblies and drawings, refs), .sim .fem .afm |
| Siemens Solid Edge | .par .psm .asm (refs) .pwd .dft (refs) |
| Autodesk Inventor | .ipt .iam (refs) .idw (refs) .ipn .ipj |
| Autodesk Fusion | .f3d .f3z (refs) .f2d |
| AutoCAD | .dwg .dxf .dwt |
| PTC Creo | .prt .asm (refs) .drw (refs) .frm .lay .sec .mfg .neu .pvz .pvs; iterations name.prt.7 become versions |
| CATIA V5 | .CATPart (3D) .CATProduct (3D) .CATDrawing (refs) .cgr .CATAnalysis .CATProcess .CATMaterial .3dxml |
| Ansys SpaceClaim / Discovery | .scdoc |
| FreeCAD, OpenSCAD | .FCStd, .scad |
| SolidCraft, FieldCraft | .sc .solidcraft, .fieldcraft – see FieldCraft and SolidCraft |
| Rhino, Alias | .3dm, .wire |
SketchUp models (.skp) of SketchUp 2021 and later are a ZIP archive behind
a UTF-16 header; src/mesh/sketchup.rs reads its model.dat, a tree of
tag–length–value records (vertices, edges, faces with their loops, component
definitions, instances with a 13-value transform, layers), triangulates every
face with an earcut port (src/mesh/earcut.rs, holes and loops touching
themselves included), places nested components and groups (up to 32 levels,
mirrored ones with reversed triangles) and converts inches to millimetres.
Hidden faces and instances, hidden layers and image entities are left out.
Files of SketchUp 3 to 2020 (one MFC object stream) are read by the Rust
port of OpenSKP (MIT), vendored unchanged in vendor/openskp, and placed and
triangulated the same way. The thumbnail is rendered from the 3D preview.
NX parts (NX 1980 and later) are read through their stream container (SPLMSSTR, src/nxpart.rs): the referenced
component files come only from
its reference streams (not from the save history), the preview image from
/Root/images/preview, and the 3D preview of parts from
/Root/FastLoad/JT: a JT 9 file whose finest level of detail (topologically compressed tri-strip sets,
src/mesh/jt.rs) is refined
along its stored surface normals up to 3 × 3 (src/mesh/pn.rs, at most
about 500 000 triangles) and shown in millimetres. Assemblies carry no
such stream: their 3D preview is put together from the previews of their
part files (src/nxasm.rs reads the components and placements from
/Root/FastLoad/Structure, src/services/assemblies.rs places the parts).
Components are found by file name, like file references – in the
assembly’s revision first, otherwise in the latest revision of another
item – and sub-assemblies are followed up to 8 levels deep; components
that are not in TeamCraft are left out, and so are components on a layer
the assembly’s layer settings hide (read from /Root/UG_PART/UG_PART), as
in NX. Drawings have no 3D preview.
Stand-alone .jt files (Teamcenter visualisation, exports of NX, CATIA,
Creo):
- JT 10 (
src/mesh/jt10.rs) carries parts and assembly in one file. The scene graph (an LZMA-compressed segment) gives every shape occurrence with its transform, the units of the model and the nodes NX marks hidden (section and trace-line geometry). Each shape’s finest level of detail is decoded with the JT 9 topology coder plus the newer integer codecs, its hash checked, refined along the stored normals within the same budget as NX parts and placed per instance. - JT 9 with topologically compressed triangle LODs, as NX writes them:
an assembly (several shapes, or a placed one) is put together through its
scene graph like JT 10 – the JT 9.5 field widths (
I16versions,F32transforms), zlib instead of LZMA, metres unless the file names its units; a single part goes through the reader of NX parts as before. - Other versions report their version. Not read: files that keep their parts
in other
.jtfiles (“shattered”), colours, PMI and the B-rep segments.
SOLIDWORKS files from 2015 on are read through their section container (src/swpart.rs). The 3D preview of a part is
the display tessellation
SOLIDWORKS stores in Contents/DisplayLists (src/mesh/sw.rs):
- each face is stored as triangle strips with a unit surface normal per vertex;
- the per-face vertices are welded on a 1 µm grid;
- the result is refined along the normals like NX parts and shown in millimetres.
An assembly is put together from the previews of its part files. The
placements come from the component tree swXmlContents/COMPINSTANCETREE
(swpart::placements), using the configuration that was active when the
file was saved, with sub-assemblies resolved. Suppressed, hidden and
virtual components are left out, and components are found by the file
name of their stored path. Files saved before 2015 (OLE compound files) and
drawings have no 3D preview.
CATIA V5 parts (.CATPart, container V5_CFV2) are previewed from the
visualisation data CATIA saves with them (src/mesh/catia.rs). That data is
the container CATCGRCont, a V5_CFV2 document nested in the file; its
scene graph holds one uncompressed mesh per body. The reader takes:
- the vertices in millimetres, stored with prefix sharing;
- the exact surface normals of the curved faces;
- the triangle lists, strips and fans of each B-rep face.
The mesh is refined along the stored normals like an NX part. A part saved without visualisation data has no 3D preview, nor do drawings. Details are in Research: 3D preview of CATIA V5 parts.
CATIA V5 products (.CATProduct) are put together from their parts
(src/catprod.rs, services/assemblies.rs). A product names no files.
The object stream of its container CATProdCont holds per instance:
- the part number and the instance name (
_InstanceName); - a link to the UUID of the referenced document’s
CATProdCont, which every CATIA file lists in the container table at its start; - the placement (
_Position): X, Y and Z axis and origin in millimetres.
A component is the CATIA file of the product’s revision (or a file found by
name) with that UUID, otherwise <part number>.CATPart or .CATProduct.
Sub-products are entered, hidden components are not recognised yet. Details
are in
Research: 3D preview of CATIA V5 products.
Rhino models (.3dm, versions 1 to 8) are read as openNURBS archives (src/mesh/rhino.rs): nested chunks, the object,
layer, block-definition
and settings tables. The 3D preview shows what Rhino stored for display –
mesh objects, the render meshes of polysurfaces (one per face, welded at
1 µm), the mesh cache of extrusions (Rhino 6 and later) and the quantised
meshes of Rhino 1 files – and refines SubD objects, which store only their
control net, by Catmull-Clark subdivision (crease edges and corners kept,
up to about 200 000 quads per SubD) onto the limit surface. Block
instances place the objects of their definition with the stored 4 × 4
transform (nested up to 8 levels). Hidden objects, objects on hidden layers
and block-definition objects themselves are left out; coordinates are
converted from the model unit to millimetres. Polysurfaces, extrusions and
NURBS surfaces saved without render meshes, curves, points, annotations,
linked blocks and colours are not shown; such a model keeps the preview
bitmap Rhino stored as its thumbnail.
Fusion designs (.f3d) are ZIP archives whose entries are mostly
compressed with Zstandard (read by src/fusion.rs with the pure-Rust
ruzstd, sizes capped and checked against the stored CRC-32). Their 3D
preview is the display scene Fusion stores for fast opening
(OGS.BlobFolder/OGS/DefaultScene/world and stream_mesh_NNN,
src/mesh/f3d.rs): the triangles of every body face, welded per body,
refined along their stored normals like NX parts and converted from
centimetres to millimetres. Sketches, edges, dimensions and work geometry
are left out. The thumbnail is rendered from the 3D preview; a file
without a display scene shows Previews/big.png, the image Fusion
stored. The exact B-rep (ShapeManager .smb) is not read. See
Research: 3D preview of Fusion designs.
Inventor parts and assemblies are OLE compound files whose data lives in
zlib- or Zstandard-compressed segments below RSeStorage (src/invpart.rs;
Zstandard in newer files, told apart by the magic bytes). The 3D
preview of a part is the display tessellation in PmGraphicsSegment
(src/mesh/inventor.rs):
- each B-rep face is an indexed triangle list (centimetres) with a unit surface normal per vertex;
- the faces of solid bodies are taken (all faces of a part without a solid), welded on their exact coordinates and refined along the normals like NX parts, and shown in millimetres.
An assembly is put together from the previews of its part and
sub-assembly files (src/invplace.rs): the referenced files and the
visibility of each occurrence come from the stream UFRxDoc, the
placements from AmRxSegment, including the poses a parent gives the
children of flexible sub-assemblies. Hidden and suppressed components are
left out, and components are found by the file name of their stored path.
Not covered: design views other than the saved one, weld beads, drawings
and presentations.
Creo parts (.prt.N, src/mesh/creo/) store no display tessellation. The
3D preview is tessellated from the B-rep in the VisibGeom section: the
table of contents and the LZW-compressed sections (container.rs), the
self-describing tagged format with its compressed numbers (tagged.rs),
planes, cylinders, cones and tori with their contours and the edges sampled
in both faces’ parameter spaces (brep.rs), interpolating bicubic splines,
rolling-ball fillets and ruled surfaces (freeform.rs), ear clipping of
every face region in the parameter plane with every edge evaluated once on
its most exact face, welding and PN refinement along the analytic normals
(tess.rs). Lengths are converted to millimetres with the unit of the
UnitSystemDef section (mm, in, …). Turned parts without a plane in
the identity frame get their face parametrisations from a seeded face (see
Research: 3D preview of Creo parts).
Creo assemblies (.asm.N, src/mesh/creo/asm.rs) are put together from
their parts like NX, SOLIDWORKS, Inventor and CATIA assemblies: the section
NeuAsmSld lists the direct components – model name, part or
sub-assembly, the images of the component’s axes and its origin in the
assembly’s unit – and each is found as <name>.prt / <name>.asm (the
iteration suffix is not part of the stored name). Family-table instances
show their generic; drawings keep their stored JPEG (see
Research: 3D preview of Creo assemblies).
SOLIDWORKS files from 2015 on are no OLE files any more but a sequence of
Deflate-compressed sections (src/swpart.rs; verified with SOLIDWORKS 2018
files). TeamCraft inflates only the sections it needs, with a hard size
limit and a CRC check: the preview image from PreviewPNG, the component
files of an assembly from its component tree (swXmlContents/COMPINSTANCETREE: direct components, without virtual
components and without the old names of renamed parts that the rest of the
file still contains). Files without a component tree (drawings) use the
names in the document manager’s section (SwDocContentMgr/SwDocContentMgrInfo);
this was not verified with a real drawing. SOLIDWORKS 2014 and older files
are OLE files and are read as before. Assemblies stored with an older
TeamCraft keep the references found then until the next check-in – or run
teamcraft rescan-references once (see Operations).
Instance counts. For SOLIDWORKS 2015+ assemblies (component tree,
instances with exclude from BOM not counted), NX assemblies (placed
components, hidden layers left out) and STEP assemblies with external part
files (NEXT_ASSEMBLY_USAGE_OCCURRENCE per child whose definition is linked
to a DOCUMENT_FILE – through APPLIED_DOCUMENT_REFERENCE,
PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS or
DOCUMENT_PRODUCT_EQUIVALENCE; sub-assemblies inside the file count with
their own occurrences) and Inventor assemblies (the occurrences of each
referenced file from the file descriptor table in UFRxDoc, suppressed
occurrences left out – a file with only suppressed ones counts 0, see
the research note)
the scan also stores how often each referenced file is
placed (file_references.instances, instances in GET …/references); the
desktop folder import uses it as the BOM quantity, Quantities from CAD…
corrects existing lines with it. Other formats – Solid Edge, Creo, CATIA, … –
store no count (null): their references are found by name only. The current
versions of SOLIDWORKS, NX and referencing STEP files stored before this are
read again once at the next start; for Inventor assemblies stored before run
teamcraft rescan-references --format inventor-assembly once (Quantities
from CAD… reads the counts from the file and works without it).
.prt and .asm are told apart by content (Creo’s #UGC: header, Solid
Edge’s OLE container); .sc must be a SolidCraft ZIP or JSON design.
BIM / architecture
| Program | Extensions |
|---|---|
| Autodesk Revit | .rvt .rfa .rte .rft |
| Autodesk Navisworks | .nwd .nwc .nwf |
| Bentley MicroStation | .dgn |
| Graphisoft Archicad | .pln .pla |
| SketchUp | .skp |
| IFC (open BIM) | .ifc .ifcxml .ifczip |
3D / DCC / animation
| Program | Extensions |
|---|---|
| Blender | .blend |
| Autodesk Maya | .ma .mb |
| Autodesk 3ds Max | .max, .3ds (3D) |
| Cinema 4D, Modo, Houdini | .c4d, .lxo, .hip .hipnc .hiplc |
Neutral exchange
| Format | Extensions |
|---|---|
| STEP (ISO 10303) | .step .stp .p21 (3D, refs) |
| IGES | .iges .igs |
| Parasolid | .x_t .xmt_txt .x_b .xmt_bin |
| ACIS | .sat .sab |
| JT (ISO 14306), PLM XML | .jt (3D, refs), .plmxml |
| OpenCascade BREP, VDA-FS | .brep .brp, .vda |
| 3D PDF (U3D / PRC) | .pdf – recognised by its embedded 3D stream |
Meshes, visualisation, 3D printing
| Format | Extensions |
|---|---|
| STL, OBJ, PLY, OFF | .stl .obj .ply .off (3D) |
| 3MF, AMF | .3mf .amf (3D) |
| glTF | .gltf .glb (3D) |
| FBX, COLLADA, USD, Alembic | .fbx, .dae, .usd .usda .usdc .usdz, .abc |
| VRML, X3D | .wrl .vrml, .x3d |
Point clouds and scans
| Format | Extensions |
|---|---|
| ASTM E57 | .e57 |
| LAS / LAZ | .las (3D), .laz |
| PTS, PTX, XYZ, PCD | .pts (3D) .ptx .xyz (3D) .pcd |
Electronics, simulation, manufacturing, documents
KiCad (.kicad_pcb .kicad_sch .kicad_pro .kicad_sym .kicad_mod,
refs), Altium, Eagle, Gerber and Excellon; OpenRocket, RockSim, RASAero,
motor curves (.eng .rse); ANSYS Workbench, Abaqus, COMSOL; G-code;
PDF, office documents, CSV, images and archives.
Simulation models and results (FEA, CFD)
Recognised and versioned (see Simulations). Model = setup or solver deck, result = solver
output (kind Simulation
result). Legacy VTK (.vtk, unstructured grid and polydata), VTK XML
unstructured grids and polydata (.vtu, .vtp) and CalculiX .frd
(ASCII) are shown in the 3D viewer, coloured by their result fields; the
other result formats are stored, not parsed.
| Program / format | Extensions |
|---|---|
| ANSYS | .wbpj (Workbench), .db (model), .rst .rth (results) |
| Abaqus | .cae .inp (model), .odb .sta .msg (results) |
| Nastran | .bdf .nas .dat (input deck), .op2 .f06 .f04 .pch (results) |
| OptiStruct | .fem (text input deck) |
| CalculiX | .inp (Abaqus syntax), .frd (results) |
| LS-DYNA | .k (keyword deck), d3plot, d3plot01, … .d3plot (results) |
| COMSOL | .mph |
| NX / Simcenter | .sim .fem .afm |
| OpenFOAM | controlDict (marks the case), .foam (ParaView marker file) |
| Fluent | .cas .cas.gz .cas.h5 (case), .dat .dat.gz .dat.h5 .cdat (data / results), .msh .msh.gz .msh.h5 (mesh), .flprj (project), .jou (journal), .trn (transcript), report-file-*.out (report file) |
| STAR-CCM+ | .sim, .simh (solution history), .java (macro) |
| VTK / ParaView | .vtk (legacy), .vtu .vtp .vts .vtr .vtm (XML), .pvd (time-step collection) |
| CGNS, Exodus II, Gmsh | .cgns; .exo .e; .msh |
Extensions that other programs use too are decided by the file content:
.sim,.femand.afmstarting with NX’sSPLMSSTRcontainer are NX simulation files. Any other.simis a STAR-CCM+ simulation when the beginning of the file names STAR-CCM+, otherwise an NX simulation file (older NX releases have no container signature). A text.femis an OptiStruct/HyperMesh deck, another binary.femand a non-NX.afm(Adobe font metrics) stay unclassified. The STAR-CCM+ marker is a heuristic; it was not verified with files of every STAR-CCM+ version..datcounts with Fluent’s section syntax ((0 "…"),(1 "…")) as Fluent data, and as a Nastran deck when it is text with a line starting withSOL,CEND,BEGIN BULKorINIT MASTER, or a$comment naming Nastran or Simcenter (Simcenter 3D writes its decks as.dat); all other.datfiles stay unclassified..mshis a Fluent mesh with Fluent’s sections ((0,(1,(2,(10) and a Gmsh mesh with$MeshFormat; otherwise, and by name alone, it stays unclassified..cas.h5,.dat.h5and.msh.h5need the HDF5 signature (at offset 0 or 512),.cas.gz,.dat.gzand.msh.gzthe gzip magic. The HDF5 files are never opened as HDF5..outis a Fluent report file when its first line is a quoted title and the second a header of quoted column names (by name alone:report-file-*.out);.javais a STAR-CCM+ macro only when its beginning names STAR-CCM+ or importsstar.common;.op2must be binary,.f04,.pch,.jouand.trnmust be text..eis Exodus II only as netCDF or HDF5 file;.dbis not ANSYS when it is an SQLite database, an OLE file (Thumbs.db) or text;.rsttext files are reStructuredText;.msgand.stamust be text (Outlook mails are OLE files);.odbZIP files are OpenDocument databases;.bdffiles starting withSTARTFONTare bitmap fonts;.kmust be text.
Result files are never treated as CAD files referenced by assemblies.
STEP in the 3D preview
STEP files (AP203, AP214, AP242; .step, .stp, .p21) are previewed from
one of two sources:
- B-rep –
CLOSED_SHELL/OPEN_SHELLofADVANCED_FACEs with planes, cylinders, cones, spheres, tori, B-spline and Bézier surfaces (also rational), extrusions and revolutions, tessellated with the truck kernel. TeamCraft’s own Part 21 reader hands the kernel only the geometry and topology it understands, in a normalised form: PMI, presentation, product and unit data – where AP242 exporters write empty lists, escaped quotes or binary values that the kernel’s parser rejects – never reach it. - Tessellated geometry of AP242 (
TRIANGULATED_FACE,COMPLEX_TRIANGULATED_FACE,TRIANGULATED_SURFACE_SET,COMPLEX_TRIANGULATED_SURFACE_SETwith theirCOORDINATES_LIST) when there is no B-rep or it is incomplete – no kernel needed. Tessellated PMI (dimensions and notes drawn as triangles, used by an…ANNOTATION…OCCURRENCE) is not shown.
Each shell is tessellated on its own; when the kernel fails on a face – an unsupported surface type, broken trimming, or a panic inside the kernel – the faces are tessellated one by one and only the bad ones are missing. The viewer then says partial preview: N of M faces.
Not applied: length units (the viewer shows model units as in the file) and
assembly placements (MAPPED_ITEM, CONTEXT_DEPENDENT_SHAPE_REPRESENTATION
– every shell appears in its own coordinates). STEP files above 256 MB are
not previewed; the conversion has a time limit of 180 s and the usual
triangle limit.
Why not every format is previewed
Most native formats are proprietary and undocumented; reading their geometry would need the vendors’ kernels. TeamCraft stays pure Rust and previews the open formats. For native CAD files keep a STEP export next to them – it is previewed, searchable and scanned for references.
REST API
The JSON API lives under /api/v1 and authenticates with personal access
tokens (Authorization: Bearer tc_pat_…), created on the account page
with an optional expiry. Cookies are never accepted by the API.
GET /api/v1 on a running server lists every endpoint.
TOKEN=… # from Account → API tokens
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/me
curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8080/api/v1/items?q=bracket&system=solidworks"
Token scopes
A token has a scope that narrows what its user may do with it; it never
widens the user’s rights. Choose it on the account page, with
POST /api/v1/tokens ({name, expires_in_days?, scope?}) or
teamcraft user create-token --scope.
| Scope | May |
|---|---|
full | read, write and approve: everything the user may do (default; every older token and the desktop token) |
write | read and write, but not approve: POST /changes/{n}/review, POST /change-requests/{n}/decide and POST /items/{n}/revisions/{r}/obsolete answer 403 |
read_only | GET/HEAD requests and the read-only MCP tools |
mcp_only | only POST /api/v1/mcp (all tools) |
A refused request answers 403 and changes nothing. A write token cannot
create a full token. GET /api/v1/me shows the token_scope. A token
with an idle lifetime (the desktop app’s) expires after that many days
without use. With TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS, POST /api/v1/tokens
gives a single sign-on user a token with that idle lifetime when
expires_in_days is missing or longer (the answer’s expires_at and
idle_days show it).
Read permissions
Every answer contains only what the token’s user may read (see Read permissions and restricted groups):
- An item, revision, file, file version, change order, change request,
attachment or project group the user may not read answers 404 with the
same message as an unknown one (
{"error": {"code": "not_found", …}}), never 403. Lists and theirtotalleave such objects out. - Rows that link a visible object to a hidden item are placeholders:
"restricted": true, the identity fields (number, name, type, revision, state) empty and the ids the nil UUID; structural fields (find number, quantity, kind of a reference, role of a simulation basis) stay. Notes are emptied (they describe the hidden item), a hidden simulation basis hasout_of_date: null, and placeholders come after the visible rows of their kind. This applies to BOM lines and trees, item references, simulation bases, change order and change request items and readiness blockers and warnings; impacts get"source_restricted": truewith the source removed. The releasing change order of an impact (change_id,change_number) is left out, and another open change in readiness (other_change) reads “another open change order”, when the user may not see that change. - Creating an item whose generated number is already held by an item the user cannot see answers 409 (“already in use; change the scheme or enter a number”) instead of skipping to the next number.
- Expanded BOMs (
?expand=true) carryrestricted_linesandrollup_incomplete; a working set carriesrestricted_lines; the Pack and Gomanifest.jsoncarriesrestricted_lines. - Lists that leave entries out say how many:
where-used,/items/{number}/simulationsand/changes/{number}/impactreturn the usual array plus the headerx-hidden-count; the revision answer hashidden_changes, references havehidden_incoming, a change request hashidden_changes. CAD file-name matches (file_referrers) in groups the user cannot see are left out without a count. GET /api/v1/melists the user’s own memberships inprojectsand, inrestricted_projects, those of them that are restricted. It never names groups the user is not a member of.auth_sourceislocaloroidc; only local accounts have a password to change.preferred_cadis the user’s preferred CAD program (a format family such asnxorsolidworks,""= all alike; set on the account page). File lists are never filtered by it – each file’sformat_info.familynames its family (nullfor neutral formats), and clients group by it.capabilitiessays what the server offers:assistant(Ask TeamCraft is configured),mcpandlocal_agents(bothfalsewhen the administrator setTEAMCRAFT_LOCAL_AGENTS=off:POST /api/v1/mcpthen refuses every token and clients offer no local agent).GET /api/v1/projectslists only the groups the user may see; projects carryvisibility(internalorrestricted).POST /api/v1/projectstakes an optionalvisibility;PUT /api/v1/projects/{code}/visibility{"visibility": "restricted"}restricts a group (managers of the group and admins).GET/PUT /api/v1/projects/{code}/policy({"admin_read": "implicit"|"with_reason"|"none"}, managers and admins) read and set how administrators read a restricted group;POST /api/v1/projects/{code}/elevations{"reason"}(admins, policywith_reason) opens 60 minutes of access,DELETEon it ends it.;GET /api/v1/projects/{code}/access-review(?format=csv) is the access report of a restricted group,POSTconfirms the review{"note"},GET …/access-review/{id}is the member list at that review (managers and admins). See Read permissions.
Creating items, safely repeated
For a simulation, POST /items also takes basis: {item, revision?, role?}
(role geometry by default, mesh, material, load_case, other): the item
is created with the basis link in one transaction, name and description
default from the basis and the current version of its STEP (else first CAD) file
is pinned. A hidden or unknown basis is 404, an invalid role 400, basis on
another item type or together with idempotency_key 400.
POST /items takes {item_type, name, project, number?, unit?, description?, material?, mass_kg?, unit_cost?, classification?, attributes?} and answers 201 with the new revision A. A client
that lost the answer (timeout,
crash) cannot tell whether the item exists. With the optional idempotency_key (1 to 255 printable ASCII characters
without spaces,
chosen by the client) the request can be repeated safely:
| Request | Answer |
|---|---|
| first request with a key | 201, the item is created |
| same key, same user, same fields again | 200 with the original item (current state) and the header Idempotent-Replay: true; nothing is created |
| same key, same user, any field different | 409; nothing is created |
| simultaneous identical requests | exactly one 201, all others 200 with that item |
| same key from another user | independent: keys belong to the user |
| invalid key | 400 |
The key is stored in the transaction that creates the item, so a refused request (403, 409, 400 …) leaves no key behind and may be repeated with the same key once it is valid. Without a key every request creates an item. The desktop’s Import CAD Folder… sends a stable key per source item (group, folder and item), so a run after a lost answer finds the items it created.
The check-out / check-in cycle
API=http://localhost:8080/api/v1; H="Authorization: Bearer $TOKEN"
curl -s -X POST -H "$H" $API/items/PRT-000001/revisions/A/checkout
curl -s -H "$H" -o hub.step $API/files/<file id>/content
# … edit …
curl -s -X POST -H "$H" -F file=@hub.step -F comment="wall 2 → 2.5 mm" \
$API/items/PRT-000001/revisions/A/checkin # stores and releases in one step
curl -s -X POST -H "$H" $API/items/PRT-000001/revisions/A/cancel-checkout
# a group manager releasing an active member's lock names a reason (10–500 characters)
curl -s -X POST -H "$H" -H "Content-Type: application/json" \
-d '{"reason": "Lena is away until March; Tom finishes the bracket."}' \
$API/items/PRT-000001/revisions/A/cancel-checkout
Concurrency: base versions and file status
A check-in or upload can name the version each file is based on in the
multipart field bases, a JSON object from file name to the version number (0 = a new file that must not exist yet)
or the SHA-256 of the content the
client checked out. If a file moved on since (e.g. after an administrator
broke the lock and someone else checked in), the request is refused with 409 Conflict and nothing is stored;
identical content is never a
conflict, and files without a base are stored as before. Several uploads of
one name in a request (Creo iterations housing.prt.6, housing.prt.7)
share the base: the first is checked against it, the others build on it.
curl -s -X POST -H "$H" -F file=@hub.step -F 'bases={"hub.step": 3}' \
$API/items/PRT-000001/revisions/A/checkin
# 409 {"error": {"code": "conflict", "message": "hub.step changed on the server since you got it: …"}}
GET …/revisions/{code}/file-status returns every file with its current
version, sha256, uploader and the lock (locked_by, locked_by_name,
locked_at, locked_by_me, locked_by_other). When somebody else released
your check-out of the revision and nobody has checked it out since,
released says so (by_name, at, reason and the message Released by
X; your local changes are kept and need a new check-out.); otherwise it is
null. POST the same path with the client’s files to get the sync state of
each local copy:
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d '{"files": [{"filename": "hub.step", "sha256": "9f86d0…", "base_version": 3}]}' \
$API/items/PRT-000001/revisions/A/file-status
state is up_to_date, modified (the server still has the base),
outdated (the local content is an older version – local_version says
which), conflict (changed locally and on the server since the base), new
(only local) or missing (only on the server). Without a base, unknown local
content counts as modified.
Large files: chunked, resumable uploads
Files of several GB go up in chunks that survive broken connections (similar to the tus protocol; see Large files):
SIZE=$(stat -c %s results.op2); SHA=$(sha256sum results.op2 | cut -d' ' -f1)
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d "{\"item\": \"PRT-000001\", \"revision\": \"A\", \"filename\": \"results.op2\",
\"size\": $SIZE, \"sha256\": \"$SHA\", \"base\": 3}" $API/uploads
# 201 {"id": "…", "offset": 0, "size": …, "chunk_bytes": 16777216, "location": "/api/v1/uploads/…", …}
split -b 16M -d results.op2 part.; OFFSET=0
for p in part.*; do
curl -s -X PATCH -H "$H" -H "Upload-Offset: $OFFSET" --data-binary @$p $API/uploads/$ID
OFFSET=$((OFFSET + $(stat -c %s $p)))
done
curl -sI -H "$H" $API/uploads/$ID # Upload-Offset / Upload-Length: where to resume
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d '{"comment": "run 7", "check_in": true}' $API/uploads/$ID/finish
| Request | Meaning |
|---|---|
POST /uploads | {item, revision, filename, size, sha256?, content_type?, base?, folder?} → 201 with the session (folder = place in the project group, see below; omitted = keep the file’s folder); 413 above TEAMCRAFT_MAX_UPLOAD_MB, 409 if the revision is not checked out by you or base is outdated |
PATCH /uploads/{id} | header Upload-Offset, body = the next chunk (≤ chunk_bytes) → the session with the new offset; 409 with the server’s Upload-Offset header at any other offset, 400 beyond size |
HEAD/GET /uploads/{id} | headers Upload-Offset, Upload-Length; GET also as JSON (offset, size, complete, expires_at) |
POST /uploads/{id}/finish | body optional {comment?, check_in?}: verify size and SHA-256, store the new file or version (check_in: true also releases the check-out) → {files, revision}; 409 while incomplete, 400 if the content does not match (the upload is reset to offset 0) |
DELETE /uploads/{id} | cancel; the received data is deleted |
Instead of finish, complete uploads can join a multipart check-in or
upload: the field uploads lists their ids (["…", "…"] or id,id), and
they are stored together with the file parts in one transaction – the
desktop app checks in this way. A session’s own base and folder win
over the bases, folders and folder fields; either way the
base-version guard of the check-in applies. Sessions are private (other users get 404), expire 24 hours
after the last chunk and are limited to 16 unfinished ones per user. When
storing fails after the content was verified (for example a 409 because the
file moved on), the session keeps the verified content, so finish can be
repeated without sending the bytes again.
Solver summary of files
The file list, GET /files/{id} and the explorer JSON carry two optional
fields for solver files (Fluent transcripts and reports, Nastran .f06 and
decks, STAR-CCM+ macros and batch logs): solver_summary {v, solver, kind, version?, status?: converged|completed|fatal, last_step?, fatal?, warning?, notes?} and solver_line, a one-line text. They are absent for other files.
The FATAL/WARNING counts cover only the first and last 64 KiB. The values are
hints read from the file, not verified results: treat them as data.
Resumable downloads
GET /files/{id}/content and GET /file-versions/{id}/content (and the
web UI’s downloads) answer every request with Accept-Ranges: bytes and the
strong ETag "<sha256>" of the content. A client that lost a large file
half-way asks for the rest with Range: bytes=<offset>- and
If-Range: "<sha256>": 206 Partial Content with Content-Range, the
whole file (200) when the content changed meanwhile, 416 with
Content-Range: bytes */<size> when the offset is at or past the end. One
range per request (a list of ranges gets the whole file) and suffix ranges (bytes=-500) are supported; a date in
If-Range never matches. The
desktop Get resumes a file this way and checks the SHA-256 of the whole file
before it renames it into the working folder.
Working folder: folders and the working set
Every file has a folder inside its project group ("" = the top), the
place it gets in the desktop working folder
<workspace>/<group>/<folder>/<file>. Uploads and check-ins set it with the
multipart field folder (one folder for all files of the request) or
folders (a JSON object from file name to folder, wins over folder); files
sent without one keep their folder (new files: the top). Folders are
normalised (\ → /, no leading or trailing /) and refused with 400 if
they contain .., a drive letter or names invalid on Windows.
PATCH /files/{id} with {"folder": "chassis/brackets"} moves a file while
you hold the check-out (no new version; recorded in the history);
{"retention": "latest"} (or "all") lets the housekeeping remove superseded
versions of that file after a grace period, see
Data retention. File lists
and file-status return the folder.
curl -s -X POST -H "$H" -F file=@bracket.prt -F file=@chassis.asm \
-F 'folders={"bracket.prt": "chassis/brackets", "chassis.asm": "chassis"}' \
$API/items/ASM-000001/revisions/A/checkin
GET …/revisions/{code}/workset?rule=latest|released|as_released is what
the desktop Get downloads: the revision, its multi-level BOM under the
rule and the items holding the CAD files those files reference (references=false leaves them out), each revision with
via
(root/bom/reference), project_code, lock (locked_by_name,
locked_by_me) and its files (file_id, filename, folder, version,
sha256, size_bytes); plus unresolved (items without a revision for the
rule), missing_references (names not in the vault), file_count and
total_bytes.
The desktop Structure view of the navigator reads one level at a time:
GET /projects/{code}/structure?limit=&offset= lists the top-level items of a
group (no BOM line of the group uses them) and
GET …/revisions/{code}/structure?rule=latest|released&limit= the BOM lines of
one revision. Both answer {nodes, total}; a node has item_number,
item_type, name, the revision the rule resolves to (code, state, both
null when there is none, e.g. not released yet), checked_out_by_name,
used_in (assemblies that use it), bom_lines (more than 0 = expandable) and,
for BOM lines, find_number and quantity. A child in a restricted group the
caller cannot read is a placeholder (restricted: true, no identity); hidden
assemblies are not counted in used_in.
The desktop file browser (Home, Files) reads one folder of a group at a
time: GET /projects/{code}/files?folder=&limit=&offset= answers
{folder, folders, entries, total} for the folder (chassis/brackets; empty
= the top of the group; normalised like the folder of an upload). folders
are the folders directly below it (name, path to ask for, files = files
in it at any depth). entries are its files by name, each with the item it
belongs to: file_id, filename, size_bytes, format, format_label,
format_kind (part, assembly, drawing, …; null if unknown),
item_number, item_type, and name, code, state and
checked_out_by_name of the item’s latest revision – the browser shows the
files of the latest revision of every item. At the top of the group the items
without files follow the files (file_id and filename are null), so
every item of the group is in the browser. total counts the entries of the
folder; limit is at most 500.
POST /worklist/checkin-all (body optional: {"revisions": [id, …]}) checks
in all of my check-outs – or the listed ones – without new files, one
transaction and one audit record per revision. The answer lists every
revision with ok and error, plus checked_in, failed and a summary.
Dependents to check (impacts)
GET /worklist lists the open impacts flat (impacts) and grouped by the
dependent revision (impact_groups): each group has the dependent (item_number, item_name, revision_id,
revision_code,
revision_state, project_id, project_code), latest_at and its
impacts, ordered by source item number. Groups are ordered by their newest
impact.
POST /impacts/{id}/acknowledge ({"note": "…"}) resolves one impact;
POST /items/{number}/revisions/{code}/impacts/acknowledge
({"note": "…", "impacts": [id, …]}) resolves all open impacts on that
dependent revision – or only the listed ones, the ones the user saw – with
one note, in one transaction, with an audit record per impact. Both need a
CAD editor of the dependent’s project group (or an admin) and a note.
The list must not be empty and may only name impacts on that revision (400 otherwise). Listed impacts that were
resolved in the meantime are
skipped; the answer
has acknowledged (the resolved impacts) and skipped. If nothing is left
to acknowledge the answer is 409 Conflict. Acknowledging stays a human
decision: the MCP server only reads impacts.
Where-used over several levels
GET /items/{number}/where-used lists the parent revisions whose BOM uses
the item. With ?levels=N (1–25) it walks up further – parents of the
parents – and every row also carries level (1 = direct parent) and
child_number (the item used on that line). Each item is reported on the
lowest level it is reached on. Both reports stop at 2000 rows.
Custom attributes and classification
Administrators define attributes once; values are set per revision by key,
as text, number, true/false or one of the choices (null or ""
removes a value, keys not sent keep theirs):
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
-d '{"key": "length_mm", "data_type": "number", "label": "Length", "unit": "mm"}' $API/attributes
curl -s -X PATCH -H "$H" -H 'Content-Type: application/json' \
-d '{"attributes": {"length_mm": 120.5, "vendor": null}}' $API/items/PRT-000001/revisions/A
curl -s -X PATCH -H "$H" -H 'Content-Type: application/json' \
-d '{"classification": "purchased"}' $API/items/PRT-000001
curl -s -H "$H" "$API/items?class=purchased&attr=length_mm&value=100..200"
# simulations with a failed value whose name contains "stress"
curl -s -H "$H" "$API/items?type=simulation&result=failed&result_name=stress"
value matches text and choices as a part (case-insensitive), yes/no
attributes with yes/no, numbers with 12, >=10, <=20 or 10..20; an
empty value finds every item that has the attribute. Revisions and the
item search return attributes (object by key) and classification.
Alternates, supersession and replace in assemblies
| Request | Body / answer |
|---|---|
GET /items/{number}/interchange | alternates (with two_way), alternate_for (one-way), successor (or null), predecessors |
POST /items/{number}/alternates | {item, note?, two_way? = true} → 201 with the interchange data |
DELETE /items/{number}/alternates/{alternate} | 204; both directions when you may edit the alternate’s group |
PUT /items/{number}/supersession | {item, note?} – superseded by item (replaces an earlier successor; 409 for a circle) |
DELETE /items/{number}/supersession | 204 |
GET /items/{number}/replace?with=NUMBER | preview: rows (one per BOM line of the latest parent revisions, with action), replaced, hidden |
POST /items/{number}/replace | {with} – applies the rows with action = replace in one transaction; 409 if there are none |
action is replace, checked_out (by somebody else), no_permission,
in_review, needs_revision (released) or cycle. Linked items in groups
the caller cannot read are left out; a successor there is a placeholder with
restricted: true. The preview and the replacement carry X-Hidden-Count.
Main resources
| Resource | Endpoints |
|---|---|
| Items | GET/POST /items (class=, attr= + value=, result= + result_name= filters), GET /items/duplicates?name=&type= (likely duplicates by name, before creating), POST /items/bulk ({"action": "revise"|"obsolete", "items": [number]}: the latest revision of up to 200 items, each in its own transaction, outcome per item), GET/PATCH /items/{number} (classification, unit, project), where-used (levels=1…25 for parents of parents), interchange, alternates, supersession, replace, history, impacts, watch |
| Attributes | GET /attributes (all=true: also inactive), POST /attributes, PATCH /attributes/{key} (admins); values in attributes of a revision |
| Revisions | GET/PATCH /items/{number}/revisions/{code|latest}, checkout, checkin, cancel-checkout, file-status, revise, obsolete |
| Worklist | GET /worklist (check-outs, reviews, impacts and impact_groups, statistics), POST /worklist/checkin-all |
| Users | GET /users/{id}/holdings – what a user still holds (check-outs, open change orders, pending reviews, change requests, owned items, memberships, tokens), as the caller may see it: administrators, and group managers for the groups they manage; POST /users/{id}/offboard {hand_over_to?: username, release_checkouts?, revoke_tokens?, confirm_no_manager?} – administrators deactivate a leaver in one step (see When someone leaves) |
| Files | POST …/files (multipart, folder/folders), GET/PATCH /files/{id} (metadata, folder), /content, /versions, /restore, /mesh, /thumbnail, …/workset, GET/POST /file-versions/{id}/annotations + DELETE /annotations/{id} (3D notes) |
| Uploads | POST /uploads, PATCH/HEAD/GET/DELETE /uploads/{id}, POST /uploads/{id}/finish – chunked, resumable uploads of large files |
| BOM | GET/POST …/bom (rule=latest|released|as_released, expand=true), PATCH/DELETE /bom-lines/{id}, …/package (Pack and Go ZIP); the expanded tree has project_id per line (null for restricted placeholders) |
| BOM import | POST /bom-imports (new assembly), POST …/bom/import (into a checked-out revision); multipart file = CSV/Excel table or STEP assembly, dry_run=true returns the plan (with attribute_columns, unused_columns, per item classification and attributes) |
| BOM from CAD references | GET …/bom/from-references (plan: children with instance-count quantities, status new/already_in_bom/differs/circular, not_in_teamcraft, hidden), POST …/bom/from-references {children, update_quantities?} (revision checked out by the caller; one transaction; added/updated/skipped/refused) |
| References | GET/POST …/references, DELETE /references/{id}, GET /impacts, POST /impacts/{id}/acknowledge, POST …/impacts/acknowledge |
| Change requests | GET/POST /change-requests, GET/PATCH /change-requests/{number}, comments, items, triage, assign, decide, reopen, withdraw, create-change, link-change, attachments, watch |
| Simulations | GET/POST /projects/{code}/result-templates, PUT/DELETE /result-templates/{id}, GET …/simulation, GET/POST …/simulation/bases, DELETE /simulation-bases/{id}, GET/POST/PUT …/simulation/results, …/results/import, PATCH/DELETE /simulation-results/{id}, …/simulation/compare, GET /items/{number}/simulations |
| As-built | GET …/as-built, PUT …/as-built/record, GET/POST …/as-built/deviations, PATCH/DELETE /as-built-deviations/{id}, GET /items/{number}/built-units, POST …/revisions/{code}/built-units |
| Changes | GET/POST /changes, items ({item, revision?, action?: release|obsolete|supersede, successor?}), reviewers (POST {username}; DELETE /changes/{n}/reviewers/{username} on a draft), suggested-reviewers, readiness, dependencies (add all unreleased), submit, review, withdraw, cancel (a draft or rejected change; its revisions are free again), impact |
| Projects | GET/POST /projects, GET /projects/manager-gaps (admins), GET/POST /projects/{code}/members, GET/PUT /projects/{code}/numbering (?scheme= previews) |
| Notifications | GET /notifications, POST /notifications/read, settings, watches |
| Personal | GET /favorites, POST/DELETE /items/{number}/favorite, GET/POST /searches, DELETE /searches/{id} |
| Audit log | GET /audit?entity_type=&username=&action=&format=json|csv – administrators; the filtered log as a download (at most 100 000 entries, x-truncated), recorded as audit.export; see Operations |
| Save As, compare | POST …/revisions/{code}/save-as {number?, name?, project?, files?, bom?}, GET …/revisions/{code}/bom/compare?with={code} |
| Similar parts | GET …/revisions/{code}/similar (limit, ignore_scale, file, wait) – parts of similar 3D shape, see below |
| Mass properties | GET …/revisions/{code}/mass-properties?rule=&ref=x,y,z – mass, CG, inertia about the CG and the reference point (m), source per line (geometry_ambiguous when a revision has 3D files of several parts and none is chosen; geometry lists the candidates); GET /materials – the material library. See Mass properties |
| Formats | GET /formats – the recognised CAD programs and formats |
| Password | POST /me/password {current_password, new_password} – change the own password (204; throttled; web sessions end, tokens stay) |
| Branding | GET /branding – replaced logos {wide, square} (version token or null) and {wide_type, square_type} (content type); GET /branding/{wide|square} – the image (404 = built-in logo) |
| MCP | POST /mcp – Model Context Protocol for AI assistants, see MCP server |
| Assistant | GET/POST /assistant – the built-in assistant (optional), context = the page the caller is on, thread: true = ask in the caller’s conversation; GET/DELETE /assistant/thread – the conversation. See Ask TeamCraft |
The BOM comparison is also available as an Excel workbook of the same lines (same with query):
GET …/revisions/{code}/bom/compare.xlsx?with={code}.
Both compare the direct lines; depth=all compares every level, with the
children resolved by rule=latest|released|as_released (default
as_released when one of the revisions is released, else latest; each
line then has
its level and the resolved revision on both sides). The expanded BOM
itself downloads as Excel workbook: GET …/revisions/{code}/bom.xlsx?rule=.
Similar parts
GET /items/{number}/revisions/{code|latest}/similar compares the shape of
the revision’s 3D model (its STEP file, else its first mesh, or file=name)
with the latest revisions of all other items – see
Finding similar parts for the method.
| Parameter | Default | Meaning |
|---|---|---|
limit | 10 | most hits (1–50) |
ignore_scale | false | compare proportions and shape only (a scaled copy scores 100) |
file | – | file name of the 3D model to compare by |
wait | 30 | seconds to wait (at most 200) when the shape signature is still computed |
{
"item": "PRT-000012", "revision": "A",
"status": "ready",
"source": { "file_id": "…", "filename": "bracket.step", "sha256": "…", "format": "step" },
"signature": { "extents": [120.0, 40.0, 8.0], "area": 12160.0, "volume": 38400.0, "closed": true, "triangles": 1234 },
"ignore_scale": false,
"candidates": [{
"item_number": "PRT-000007", "revision_code": "B", "name": "Bracket", "state": "released",
"revision_id": "…", "file_id": "…", "filename": "bracket-old.stl", "identical": false,
"score": 91, "shape": 94, "size": 97, "extents": [118.0, 40.0, 8.0],
"explanation": "extents 118 × 40 × 8 vs 120 × 40 × 8, shape distribution 94 % similar, volume 37760 vs 38400",
"thumbnail_file_id": null
}]
}
status is ready, pending (call again shortly), no_shape_file (no STEP
or mesh file) or failed (message says why, e.g. the model could not be
read). volume is null when the mesh is not watertight.
Suggested reviewers
GET /changes/{number}/suggested-reviewers?limit=5 (1–20) answers who
should review this? from the history of the affected items:
{"change": "ECO-000004", "suggestions": [{"user_id", "username", "display_name", "role", "score", "reasons": ["edited PRT-000005 3 times in the last 90 days", "manager of ROVER"]}]}, highest score first, ties by
name. Only active CAD editors and admins are suggested; the author and the
assigned reviewers never are. Users who changed the affected revisions come
last with "cannot_review": true (four-eyes): show them, do not add them. It only reads – add a suggestion with
POST /changes/{number}/reviewers. See
Suggested reviewers for the
sources and points.
Review decisions and their sign-off
POST /changes/{number}/review with {"decision": "approve"|"reject", "comment": "…", "meaning": "…"} needs a token with the approve scope.
Each entry of reviews and earlier_rounds carries sign_off_method
(session, password, sso, token; null while pending) and
sign_off_meaning. In the browser, TEAMCRAFT_APPROVAL_REAUTH asks for the
password or a fresh single sign-on (see
Configuration).
Review rounds and absent reviewers
GET /changes/{number} returns review_round, earlier_rounds (reviews of
the rounds before) and, per entry of reviews, absent (deactivated,
not_editor, cannot_see_items or null; set only for a pending reviewer
who cannot decide). POST /changes/{number}/reviewers/{old_username}/replace
with {"username": "<substitute>"} replaces a pending reviewer while the
change is in review (author or admin; 409 when it is not in review or the
reviewer has decided).
Change requests (ECR)
Every signed-in user – viewers included – raises change requests and
comments on them; CAD editors of the project groups of the affected items (and admins) triage and decide.
GET /change-requests/{number} returns the
request with its items, comments, attachments, linked changes,
history and can (the actions the caller may take). A request names at
most 100 affected items (an item named twice is listed once; more distinct items are refused with 400
before any item is looked up). See
Change requests for the flow.
curl -s -X POST -H "$H" -H "Content-Type: application/json" $API/change-requests -d '{
"title": "Weld seam cracks", "reason": "manufacturing", "priority": "high",
"description": "Seen on 3 of 10 parts. Proposal: 4 mm fillet.", "due_date": "2026-12-01",
"items": [{"item": "PRT-000001", "revision": "A", "note": "File: bracket.step"}]}'
curl -s -X POST -H "$H" -F file=@crack.png $API/change-requests/ECR-000001/attachments
curl -s -X POST -H "$H" $API/change-requests/ECR-000001/triage # new → in triage
curl -s -X POST -H "$H" -H "Content-Type: application/json" \
$API/change-requests/ECR-000001/decide -d '{"decision": "accept"}' # or reject / defer + comment
curl -s -X POST -H "$H" $API/change-requests/ECR-000001/create-change # draft CHG with the in-work revisions
create-change answers with the new change, the revisions it added and
the affected items it skipped (no revision in work, already in another
open change, no access). link-change ({"change": "CHG-000004"}) links an
existing open change order instead. When a linked change order is released,
the request becomes implemented. Lists filter with
?status=open|all|new|in_triage|accepted|rejected|deferred|implemented|withdrawn,
scope=mine|assigned and item={number}; GET /worklist contains
requests_assigned and my_requests, GET …/revisions/{code} the
open_requests of the item.
Simulations
A simulation revision (item type simulation) links the revisions it was
computed with and stores its key result values (see
Simulations). Writes need the simulation revision
checked out by the caller.
| Endpoint | Body / result |
|---|---|
GET …/revisions/{code}/simulation | {revision, out_of_date, summary, bases, results}; every basis has out_of_date (reason or null), every result status |
GET/POST …/revisions/{code}/simulation/bases | {item, revision?, file? | file_version_id?, role: geometry|mesh|material|load_case|other, note?} – default: latest revision |
DELETE /simulation-bases/{id} | removes a basis link |
GET/POST …/revisions/{code}/simulation/results | {name, value, unit?, lower_limit?, upper_limit?, load_case?, solver?, solver_version?, note?} (limit_low/limit_high too) |
PUT …/revisions/{code}/simulation/results | {results: [...], replace?: true} – the result set of a solver run in one transaction; same name and load case = update |
POST …/revisions/{code}/simulation/results/import | multipart file (CSV/TSV/Excel name,value,unit,limit_low,limit_high,load_case), replace=true optional |
PATCH/DELETE /simulation-results/{id} | change any field (null clears a limit) or remove |
GET …/revisions/{code}/simulation/compare?with={code} | {before, after, summary, diffs} – matched by name and load case, with delta and delta_percent |
GET /items/{number}/simulations | simulations whose latest revision is based on the item, with out_of_date and open_impact_id |
GET/POST /projects/{code}/result-templates | extraction templates of a group (read: whoever sees the group; write: its managers and admins): {name, file_pattern, method: regex|csv|table, pattern | csv_column [csv_key_column, csv_key_value], pick?: first|last, unit?, lower_limit?, upper_limit?, load_case?, solver?, enabled?} |
PUT/DELETE /result-templates/{id} | replace (same fields) or remove a template |
GET …/revisions/{code}/simulation/proposals?status= | values the assistant proposed from reports (pending default, accepted, rejected, all): {proposals: [{name, value, unit, limits, load_case, source_file, source_version, quote, status, proposed_by_name}]} |
POST /simulation-proposals/{id}/accept, …/reject | CAD editors of the group; accepting makes it a result value (same name and load case = update) and needs the revision checked out |
A solver script after a run:
curl -s -X POST -H "$H" $API/items/SIM-000003/revisions/B/checkout
curl -s -X PUT -H "$H" -H 'Content-Type: application/json' \
-d '{"results": [{"name": "Max. von Mises stress", "value": 182.5, "unit": "MPa",
"upper_limit": 250, "load_case": "LC1", "solver": "CalculiX", "solver_version": "2.21"}]}' \
$API/items/SIM-000003/revisions/B/simulation/results
curl -s -X POST -H "$H" -F file=@job.frd $API/items/SIM-000003/revisions/B/checkin
As-built records
An as-built revision (item type as_built, numbers AB-…) records one
physical unit: the design revision it was built to, serial number, build
date, deviations and – as its regular BOM – what was really installed (see
As-built records). Writes need the as-built revision
checked out by the caller; a revision checked out by somebody else answers
409, a viewer or a non-member of the group 403, and a revision or
deviation the caller may not read 404.
| Endpoint | Body / result |
|---|---|
GET …/revisions/{code}/as-built | {revision, record, summary, deviations}; record is null until entered, has newer_design (text or null) and restricted: true with a placeholder when the design is hidden |
PUT …/revisions/{code}/as-built/record | {design, design_revision?, serial_number?, built_on?: YYYY-MM-DD, note?} – default: latest design revision; 409 when the serial number is taken for that design item, 400 for an obsolete design revision |
GET/POST …/revisions/{code}/as-built/deviations | {kind: dimension|material|component|process|other, feature, designed?, built?, disposition?: open|use_as_is|rework|repair|scrap, note?} → 201; at most 1000 per revision |
PATCH /as-built-deviations/{id} | any of the deviation fields, e.g. {"disposition": "use_as_is"}; the others are kept |
DELETE /as-built-deviations/{id} | removes a deviation → 204 |
POST /as-built-deviations/{id}/confirm | a reviewer of the change order in review confirms a “use as is” deviation (not the person who recorded it); needed before approving when the group’s review policy has concessions_need_review |
GET /items/{number}/built-units | units built to any revision of the design item (latest revision of each unit, at most 500); hidden units are counted in X-Hidden-Count |
POST /items/{number}/revisions/{code}/built-units | {serial_number?, built_on?, number?, name?, note?, project?, with_bom?: true} → 201 with the new as-built revision (A, checked out by the caller) |
A change order with an as-built revision cannot be submitted (409) while
that revision has no build record or an open deviation.
Webhooks (administrators)
Outgoing event webhooks – payloads, signature and retries are described in
Event webhooks. Every endpoint needs an
administrator’s token; secrets are returned only by POST /webhooks and
rotate-secret.
| Endpoint | Body / result |
|---|---|
GET /webhooks | {webhooks, events} – every webhook with secret_hint, pending, failed_recently, last_status |
POST /webhooks | {name, url, events: [...], project?, active?} → 201 {webhook, secret} |
GET/PATCH/DELETE /webhooks/{id} | PATCH changes the given fields; "project": null = all project groups |
POST /webhooks/{id}/rotate-secret | {webhook, secret} – the new secret applies at once |
POST /webhooks/{id}/test | sends a ping now and returns the delivery (status, last_status_code, last_error) |
GET /webhooks/{id}/deliveries?limit= | latest deliveries (default 50, at most 200) with payload, status, attempts, next_attempt_at |
POST /webhook-deliveries/{id}/retry | schedules a failed delivery again with fresh attempts and tries it at once |
curl -s -X POST -H "$H" -H "Content-Type: application/json" $API/webhooks -d '{
"name": "ERP BOM export", "url": "https://erp.example.org/teamcraft/events",
"events": ["revision.released", "change.released"], "project": "ROVER"}'
Errors are JSON: {"error": {"code": "conflict", "message": "…"}} with the
matching HTTP status (400, 401, 403, 404, 409, 413, 500).
MCP server (AI assistants)
TeamCraft has a built-in Model Context Protocol server, so an AI assistant such as Claude Code, Claude Desktop or any other MCP client can work with your PDM data: find items, explain structures and where-used, summarise what changed between revisions, check a BOM before a release, prepare change orders, summarise and raise change requests, and check simulations and their results.
The assistant works with your rights: it signs in with one of your personal access tokens and every tool calls the same business logic as the REST API and the web interface. It can read what you can read, it can draft change orders if you are a CAD editor, and – like everyone signed in – raise change requests and comment on them. It cannot submit, approve, release or make anything obsolete, and it cannot triage, accept, defer or reject change requests. Those decisions stay with people.
The same tools also power the optional server-side assistant, Ask TeamCraft. It needs no client, only an AI provider (Anthropic, OpenAI-compatible or a local model) configured on the server.
Endpoint
| Transport | Address |
|---|---|
| Streamable HTTP | POST /api/v1/mcp, header Authorization: Bearer tc_pat_… |
| stdio (via a bridge) | teamcraft mcp --server https://pdm.example.org |
The HTTP endpoint is stateless. Each POST carries one JSON-RPC 2.0
message (or a batch), and the answer is application/json. Notifications
get 202 Accepted. There is no event stream (GET returns 405) and no
session id. Protocol revisions 2025-06-18, 2025-03-26 and 2024-11-05
are accepted. Without a valid token the endpoint answers like the rest of
the API, with 401 and a JSON error.
With TEAMCRAFT_LOCAL_AGENTS=off the endpoint refuses every call with
403 and a JSON-RPC error naming the setting, for every token: the
desktop app’s agent, the teamcraft mcp bridge and any other client (GET /api/v1/me then reports
capabilities.mcp: false). The built-in
assistant is not affected. See
Local agents for what this
setting can and cannot enforce.
Setup
Create a personal access token under Account → API tokens. Give it an
expiry, and give each assistant or machine its own token so you can revoke
it on its own. Prefer a scoped token: read_only (only the read-only tools),
write (all tools, but no approving on the REST API) or mcp_only (see
Token scopes).
Claude Code
claude mcp add --transport http teamcraft http://localhost:8080/api/v1/mcp \
--header "Authorization: Bearer <token>"
Use the server’s public https:// URL for a shared installation. Then ask,
for example: “What changed in ASM-000003 between B and C?”, “Is
ASM-000003 ready to be released?”, “What problems were reported on
PRT-000012?”, “Which simulations are out of date after yesterday’s
release?”, “Summarise the results of SIM-000003 rev B vs A”, or use one
of the prompts below (/mcp__teamcraft__release_check).
Clients that only speak stdio
Some clients, such as Claude Desktop, start MCP servers as local processes.
The teamcraft binary bridges them to the HTTP endpoint:
{
"mcpServers": {
"teamcraft": {
"command": "teamcraft",
"args": ["mcp", "--server", "https://pdm.example.org"],
"env": { "TEAMCRAFT_TOKEN": "<token>" }
}
}
}
The bridge reads newline-delimited JSON-RPC from stdin, forwards each
message to POST /api/v1/mcp and writes the answers to stdout. It writes
nothing for notifications, and diagnostics go to stderr. The token comes
from TEAMCRAFT_TOKEN or --token-file <path> and is never printed.
--server defaults to TEAMCRAFT_SERVER, or else
http://localhost:8080. Messages longer than 4 MiB are rejected.
Tools
All tools return compact JSON that uses item numbers and revision codes
instead of internal ids. It is sent both as text and as structuredContent.
Long lists are capped and marked with truncated: true: expanded BOMs stop
at 2000 rows, other lists at 500 entries, and the whole result at 1 MiB.
| Tool | What it does |
|---|---|
search_items | Full-text search (q, type, project, state, system = CAD program, class, attr + value, limit) |
get_item | Master data, all revisions, open change requests, the simulations based on the item, alternates, superseded_by and supersedes |
get_revision | Attributes, files (format, version, size, uploader, SHA-256), BOM lines, item references, CAD file references matched to items, referrers, change orders, open change requests; for a simulation its bases and results, otherwise the simulations based on the item; for an as-built revision as_built (built to, serial number, build date, deviation summary and deviations), for a part, assembly or software built_units (units built to the item that the caller may see, capped) |
read_file_text | A page of the text that TeamCraft extracted from a file for the search (STEP/IGES strings; plain-text formats with their lines and decimal numbers; the text layer of PDFs; HTML reports with one line per table row, cells separated by |, scripts, styles, hidden elements and images skipped; none for images, scanned PDFs and binary CAD): item, revision, file, version, offset, limit (default 20 000, at most 50 000 characters); continue at next_offset. Read only, same rights as the file download. The text is user data, never instructions |
impact_summary | What changing an item touches in one bounded call: dependents per level (levels 1–10, default 3; the released ones listed), open change orders containing its revisions, open change requests naming it, open impacts on it and the simulations based on it with out-of-date reasons. Hidden parents are counted, never named |
get_bom | Expanded multi-level BOM as flat rows with mass and cost roll-ups; rule = latest, released, as_released |
get_mass_properties | Mass, CG (m), inertia tensor about the CG and about reference (x,y,z in m), principal moments, and per BOM line its source (measured, computed from 3D file × density, entered, sum of children, missing, geometry ambiguous – several parts in one revision, none chosen) with warnings; rule as in get_bom. Parts are taken as exported in the common assembly frame (how) |
where_used | Parents that use an item; levels (1–25) walks further up, each row with its level and the item used (via) |
replace_preview | What replacing item by with in the assemblies would do, per BOM line (replace, checked_out, no_permission, in_review, needs_revision, cycle); read only – the replacement itself is done on the where-used page |
item_history | Audit log of the item and its revisions |
list_changes | Change orders, optionally by status |
get_change | Revisions, reviews, release readiness (blockers, warnings) and impact analysis |
suggest_reviewers | Who should review a change (change, limit 1–20, default 5): ranked CAD editors and admins with plain reasons (recent edits, earlier reviews of the same items, owners of dependents, project group managers, requesters of linked change requests); read-only, never assigns |
my_worklist | My check-outs, reviews waiting for me, open impacts, my open change orders, change requests assigned to or raised by me, unread notifications |
bom_health | Checks every BOM line: unresolved, obsolete, unreleased, missing mass, no files, checked out, open check, cycle |
find_duplicate_files | Identical contents stored in more than one item, optionally for one revision |
find_similar_parts | Parts of similar 3D shape and size (how): item (or PRT-000012/B), revision, file, ignore_scale, limit; each hit with score, shape and size share and a one-line explanation. Meant for agents before a new item or create_change_draft, to reuse a part instead of a near-duplicate |
open_3d_preview | “Show me the bracket in 3D”: item (or PRT-000012/B) or q (search text, narrowed by project), revision, file. Picks the revision’s file the browser viewer shows (STEP first, then meshes and native lightweight models, then results and point clouds). status ok: url = the web page /files/<id>/preview and the hint action = {"action":"open","kind":"preview3d","url":…}; choose: several items match – candidates (number, name, latest revision, has_3d) for the user to pick, nothing guessed; no_3d_file: nothing to show in 3D. Opens nothing itself (MCP is stateless) |
compare_revisions | Attributes, files (added, removed, new content by SHA-256) and BOM lines (added, removed, quantity or find number) between two revisions |
list_change_requests | Change requests (ECR), newest first; status (a status, open or all), scope (mine, assigned), item, limit |
get_change_request | Description, affected items (revision the problem was found in, latest state), discussion (newest 100 comments), attachment names and sizes, linked change orders, history (newest 50 entries) and what I may do in TeamCraft (you_may) |
list_simulations | With item: the simulations based on that part or assembly, grouped, with result and failure counts, open checks and their bases on that item with the out-of-date reason. Without: simulation items (q, project, at most 50) with their latest revision’s bases and pass/fail summary. out_of_date_only filters |
get_simulation | Bases of a simulation revision (item revision, file version, role, out-of-date reason) and result values with unit, limits, load case, solver and pass/fail |
compare_simulation_results | Result values of two revisions of a simulation matched by name and load case: added, removed, changed (delta, percent), unchanged; changes_only |
create_change_draft | Creates a draft change order with the given revisions and, by default, their unreleased dependencies |
add_to_change | Adds revisions (optionally with dependencies) to one of my draft change orders |
create_change_request | Raises a change request (title, description, reason, priority, due_date, items); allowed for every signed-in user, starts as new |
comment_change_request | Adds a comment to a change request in my name (request, body) |
propose_result_values | Proposes result values of a simulation revision from a report (PDF, HTML or text) I read with read_file_text: item, revision, values[] of name, value, unit, limit_min, limit_max, load_case, solver, file and quote (the report text, copied exactly). Drafts only: a CAD editor accepts them in TeamCraft with the revision checked out; the tool never writes a result |
Revisions are given as item plus revision (latest by default). In the
change order tools, items are given as PRT-000012 (meaning its in-work
revision) or PRT-000012/B. If create_change_draft cannot add one of the
revisions, it cancels the new draft again, so nothing half-finished is left
behind. In create_change_request, PRT-000012 names the item and
PRT-000012/B the revision the problem was found in (at most 100 items);
every item is resolved before the request is created, so a typo creates
nothing. compare_simulation_results defaults like compare_revisions:
to = latest, from = the revision before it.
Each tool declares MCP annotations. The read tools are readOnlyHint, and
no tool is destructiveHint. Clients that ask before running write tools
will ask before create_change_draft, add_to_change,
create_change_request, propose_result_values and comment_change_request.
Prompts
| Prompt | Arguments | Produces |
|---|---|---|
release_notes | item, revision? | Release notes from the comparison with the previous revision |
review_change | change | Review notes: scope, risks, questions for the author, who else should look at it (suggest_reviewers) |
release_check | item, revision? | Checklist of what blocks or warns before a release (for a new part also near-duplicate existing parts) |
worklist_triage | – | Short ordered to-do list from my worklist |
triage_requests | – | One block per open change request assigned to me (and new unassigned ones): problem, affected items, open points, a suggested next step – the decision stays mine |
simulation_status | item, revision? | For a part or assembly its simulations, for a simulation its bases: up to date or why not, failed results, changes between revisions, what to re-run |
Safety model
- Same rights as the user. The token identifies a user, and every tool checks that user’s role and project groups, exactly like the REST API. A viewer’s assistant can only read, raise change requests and comment on them – the same as a viewer in the web interface.
- Restricted groups. Every tool result is filtered by the user’s read
permissions (restricted project groups): an
item, change or request of a restricted group the user is not a member of
is reported as not found, with the same message as an unknown number;
BOM lines and other links to such items come back as placeholders with
"restricted": trueand no number or name, and lists report how many entries they left out (hidden_parents,hidden_dependents,hidden_changes). The server instructions tell the model not to guess or retry. Rights are read on every HTTP request; the messages of one JSON-RPC batch share that snapshot. - Copying out of restricted groups. Text other users typed can try to make a model copy restricted content into a comment or draft that those users can read. The built-in assistant refuses its text-writing tools for the rest of a run once it has read such content; an external MCP client gets no such check and acts with the full rights of the token’s user (see Notifications, e-mail, chat, webhooks, assistant).
- No decisions. There are no tools to check out, upload, edit BOMs, submit, review, release or make anything obsolete, and none to triage, assign, accept, defer, reject or withdraw a change request. The assistant can only prepare a draft change order, which the user then opens, completes (reviewers) and submits in TeamCraft, raise a change request that CAD editors then triage, and post a comment.
- Audit. Drafts, added revisions, change requests and comments show up in the audit log under the user’s name, like any other action; a new request and a comment notify the same people as in the web interface.
- Errors. User errors, such as an unknown item or missing rights, come back as tool errors with the same message the web interface shows. Internal errors are logged on the server, and the assistant only gets a generic message.
- Tokens. Treat the token like a password. It is stored hashed on the server, can expire, and can be revoked on the account page. The bridge never prints it.
Architecture
browser (HTML, WebGL viewer) desktop app (Tauri) scripts
│ cookies │ bearer tokens │
┌───────▼─────────┐ ┌────────▼────────────────▼──┐
│ web (handlers) │ │ api (JSON, /api/v1) │
└───────┬─────────┘ └────────┬────────────────────┘
└──────────────┬────────────────┘
┌────────▼────────┐ pure rules: domain
│ services │ formats: cadformat, cadrefs
│ (transactions, │ 3D: mesh, preview ──► teamcraft preview-mesh
│ permissions, │ (child process, time limit)
│ audit log) │
└───┬─────────┬───┘
PostgreSQL vault (objects/ab/cd/<sha256>, previews/)
- One binary:
teamcraftis web server, REST API and CLI. Templates, migrations, stylesheets, scripts, fonts and logos are embedded. - Services are the only code that writes: they check permissions (
Actor::require), lock rows (FOR UPDATE), validate lifecycle transitions and write the audit entry in the same transaction. Web and API handlers are thin and call the same functions. - SQL uses runtime queries (
sqlx::query_as+FromRow), so building never needs a database; schema changes are numbered migrations. - Vault: uploads are streamed into
tmp/, hashed on the fly and moved to their content address; identical content is stored once. - 3D preview:
mesh.rsdispatches to one reader per format insrc/mesh/(stl.rs,obj.rs,ply.rs,off.rs,three_mf.rsfor 3MF and AMF,gltf.rs,three_ds.rs,points.rsfor PTS/XYZ/LAS); the mesh and itsTCMESH01encoding are inmesh/model.rs, the shared bounded byte access inmesh/helpers.rs. It reads STL/OBJ/PLY/OFF/3MF/AMF/glTF/3DS/point clouds and tessellates STEP with the truck kernel (mesh/step.rs; an own Part 21 reader inmesh/p21.rsfilters what the kernel gets, AP242 tessellated geometry is read bymesh/tessellated.rs), reads the JT 9 tessellation inside NX parts (mesh/jt.rs), the display tessellation inside SOLIDWORKS parts (mesh/sw.rs) and Inventor parts (mesh/inventor.rs), the CGR visualisation data inside CATIA V5 parts (mesh/catia.rs) and the display scene inside Fusion designs (mesh/f3d.rs, archive infusion.rs) and refines them along their stored normals (mesh/pn.rs); Creo parts carry no tessellation, so their exact B-rep is read and triangulated (mesh/creo/: container, tagged format, surfaces, tessellation); stand-alone JT 10 files and JT 9.5 assemblies come with their scene graph (mesh/jt10.rs), Rhino models with their stored meshes and SubD (mesh/rhino.rs), SketchUp models are triangulated with earcut (mesh/sketchup.rs,mesh/earcut.rs; SketchUp 3–2020 through the vendoredvendor/openskp); NX, SOLIDWORKS, Inventor and Creo assemblies (mesh/creo/asm.rs) and CATIA products (catprod.rs) are put together from their parts’ previews (services/assemblies.rs);preview.rscaches the result and runs each conversion in ateamcraft preview-meshchild process with a time limit. The browser only draws the compactTCMESH01buffer;simmesh.rsturns VTK/VTU/VTP (simmesh/vtk_legacy.rs,simmesh/vtk_xml.rs) and CalculiX.frd(simmesh/frd.rs) results, over a shared cell grid (simmesh/grid.rs) and number parsing (simmesh/numbers.rs), into their outer surface with scalar fields (an optional, flagged section of the same format) that the viewer colours with a colour map. The same child process writes the shape signature of the similar part search (shape.rs), stored once per content hash inshape_signatures, and the mass integrals of the mass properties (massprops.rs), cached per content hash next to the preview mesh.
Source layout
| Path | Contents |
|---|---|
src/main.rs | CLI: serve, local, migrate, user (incl. holdings, deactivate), vault check/signatures, assistant check, rescan-references, healthcheck, mcp (stdio bridge) |
src/localserver.rs | teamcraft local: a private PostgreSQL cluster (bundled or installed programs, scram password, 127.0.0.1 only) and the server for a test installation; started by the desktop app with local server |
src/lib.rs | AppState, router, health endpoints, security headers, the OIDC back-channel logout route (outside the web pages’ CSRF guard) |
src/domain.rs | roles, lifecycle, revision letters, skins, validation |
src/services/ | items, revisions, files, chunked uploads, BOM, changes, references, impacts, simulations, projects, users, …; offboarding: offboarding (holdings report, idle SSO users), checkouts, deactivation, ownership, manager_gaps, season |
src/auth.rs, src/oidc.rs | passwords, sessions, tokens; OpenID Connect sign-in (authorization code + PKCE) and back-channel logout tokens checked against the provider’s keys |
src/web/, templates/, static/ | HTML interface |
shared/ | UI files of both the web UI and the desktop app: skins and tokens, fonts, 3D viewer (shared/README.md) |
src/api/ | REST API |
src/mcp/ | MCP server for AI assistants (POST /api/v1/mcp): JSON-RPC, tools and prompts over the services |
src/llm/ | provider-neutral LLM layer: conversation types, Provider (wire format, pure), Transport; anthropic.rs = Messages API, openai.rs = Chat Completions |
src/assistant.rs | built-in assistant: tool loop over src/llm with the MCP tools, markdown rendering |
src/pagecontext.rs | the page context the assistant sidebar sends (kind, identifiers, view, rule, query, rows shown): parsing and caps; the services resolve it with the user’s rights |
src/cadformat.rs, src/cadrefs.rs | format registry, references inside CAD files |
src/nxpart.rs, src/swpart.rs | NX part stream container, SOLIDWORKS 2015+ section container (references, preview images, assembly placements) |
src/invpart.rs, src/invplace.rs, src/invasm.rs | Inventor segments below RSeStorage (zlib or Zstandard); assembly components and placements for the 3D preview (UFRxDoc, AmRxSegment); occurrence counts for BOM quantities |
src/massprops.rs | mass properties of closed meshes (volume, CG, inertia; Eberly), edge check, parallel axis roll-up |
src/fusion.rs | Fusion design archives: ZIP with Zstandard entries (display scene, preview image) |
src/mesh.rs, src/preview.rs | 3D readers and the preview cache; src/simmesh.rs + src/simmesh/: VTK/VTU/VTP/FRD results with fields |
src/shape.rs | shape signatures (extents, area, volume, D2 distribution) for the similar part search |
src/webhooks.rs | event webhooks: outbox worker, HMAC-SHA256 signature, backoff, target (SSRF) guard |
migrations/ | PostgreSQL schema |
tests/ | end-to-end tests, each against its own temporary database |
desktop/ | the Tauri desktop client (separate Cargo project); its UI is plain ES modules in desktop/ui/js without a build step (e.g. graph.js for the Graph tab, checked in Chromium and WebKit by scripts/desktop-graph-check.mjs) |
vendor/ | vendored third-party crates, unchanged, with their licences (vendor/README.md) |
docs/ | this documentation (mdBook) |
platforms/ | build and test guides for Linux, macOS and Windows |
Pure Rust
Everything TeamCraft runs is built by cargo: no Node.js, no downloaded
browser engines, no C++ CAD kernel. The 3D preview uses the Rust truck
kernel and own readers; TLS uses rustls. The only external service is
PostgreSQL.
Performance notes
- Dynamic BOM (
services::bom::tree): the structure is loaded level by level – one query per BOM level, whatever the number of lines. Each query reads the lines of all parents of the level and resolves their child revisions under the revision rule in the same statement (DISTINCT ON (item_id) … ORDER BY item_id, seq DESCfor latest / released, the frozenreleased_child_revision_idfor as released). A revision used in several places is loaded once. The nested tree, cycle detection and the mass/cost roll-ups are then built in memory by pure code (bom::assemble, unit tested without a database). A 4000-line, 11-level structure takes 13 queries (tests/bom_performance.rs). - Working set (
services::workset, the desktop Get): uses the same level-wise loading, but walks the loaded lines without building the expanded tree (bom::structure_revisions: each sub-assembly once, in the order of the expanded BOM). Each round of CAD references takes three queries – referenced names, the items holding them, and their revisions under the rule in one statement – whatever the number of files. The 4000-line structure takes 16 queries. - Limits: at most 5000 distinct assemblies are loaded, 64 levels shown and 100 000 lines expanded (shared sub-assemblies expand wherever they are used); larger structures are refused with a message instead of exhausting the server. The working set has no expansion limit, but at most 5000 revisions.
- Where-used: single level is one query;
levels=Nwalks up one query per level (the parents not seen before form the next level, so each item is reported on its lowest level), and both stop at 2000 rows. The impact analysis of a change walks up the same way for all its revisions at once. - Item search narrows the items set-based first (text, state or CAD
program, trigram indexes for substring matches when
pg_trgmis available) and looks up the latest revision only for those candidates; the total comes from the same query (COUNT(*) OVER ()). - Scale check:
tests/scale.rs(ignored) seeds ~100k items and ~1M BOM lines and times these paths with their query plans – see Operations → Scale check. - API requests: the bearer token of a request is resolved in one query
(
services::users::actor_for_api_token) – the user with the memberships, the groups that switched personal access tokens off and whether the use of the token has to be recorded come from the same statement. The write (last_used_at, the sliding expiry) follows at most once a minute per token. - Assembly previews (
services::assemblies): the distinct parts are converted as many at a time as the preview queue runs (preview::MAX_PARALLEL), each instance is placed straight into the assembly mesh (Mesh::append_placed, no copy of the part per instance) and a part’s mesh is given up after its last placement. - Thumbnails and compositions (
Previews::render): an image in the cache is answered at once; a missing thumbnail is drawn and an assembly preview composed at mostpreview::MAX_RENDERSat a time and once per cache entry – requests that arrive together (the viewer and a folder of thumbnails) wait for the one that works and read its result. - Notification delivery (
delivery.rs): a run servesdelivery::PARALLEL_RECIPIENTSrecipients at the same time, each recipient’s notifications in the order they were queued, e-mail and chat of a notification side by side. A slow mail server (20 s timeout) holds up the recipient it serves, not the run. - Pages run their independent queries concurrently (
tokio::try_join!) and load per-row data with= ANY($1)instead of one query per row (vault explorer, BOM health, Pack and Go file lists).
FieldCraft and SolidCraft
SolidCraft (parametric CAD) and FieldCraft (FEA/CFD) are open-source engineering tools in pure Rust by Tangency Labs (MIT OR Apache-2.0). TeamCraft manages their files like those of any other CAD program:
| File | TeamCraft |
|---|---|
SolidCraft design .sc (ZIP container with container.json, or JSON with "format": "solidcraft/N") and the legacy .solidcraft | recognised as SolidCraft design (.sc is checked by content, so scripts with the same extension are not mistaken for designs) |
FieldCraft model .fieldcraft (ZIP: fieldcraft.json, model.json, mesh.bin, journal.json) | recognised as FieldCraft analysis model |
| STEP, 3MF (incl. SolidCraft’s face-tagged 3MF), STL, OBJ exports | 3D preview in the browser and the desktop app; STEP is searchable and scanned for references |
Abaqus input decks (.inp) written by FieldCraft | recognised as Abaqus input file |
A typical workflow: keep the .sc design together with its STEP or 3MF
export in one TeamCraft revision – the design for editing in SolidCraft, the
export for the preview, the BOM and downstream tools; attach the
.fieldcraft model to the same item, or to an analysis document that
references it.
The three projects share their approach – one Rust build, a desktop app and a command line, open formats – but not code: TeamCraft is licensed under the MPL-2.0 and does not include SolidCraft or FieldCraft code.
Evaluation of other open-source PLM systems
Evaluated on 2026-11-03 before TeamCraft was extended beyond its first milestone. Points that could not be verified are marked [unverified].
Overview
| TurboPLM | OdooPLM | DocDokuPLM | GitPLM | |
|---|---|---|---|---|
| What | web PLM for hardware teams | 36+ PLM modules inside the Odoo ERP | Java EE PLM | Go CLI/TUI for CSV BOMs in Git |
| License | AGPL-3.0 (FSL-1.1-MIT until 2026-08-01) | core LGPL-3, modules AGPL-3; CAD client proprietary | AGPL-3.0 | Apache-2.0 |
| Stack | React, Node/Express, Prisma, PostgreSQL, three.js, occt-import-js | Python, Odoo 19, PostgreSQL | Java 8 / Java EE 8, Payara, PostgreSQL, Elasticsearch, Kafka | Go, CSV, YAML |
| Activity | pre-1.0, single vendor | active since 2017 | dormant (main repo last pushed 2021) | v0.10.1 (2026-09) |
| Hosting | SaaS or Docker Compose (4 services) | full Odoo instance | 11 containers | local tool |
Feature coverage (Y / P = partial / N)
| Feature | TurboPLM | OdooPLM | DocDoku | GitPLM |
|---|---|---|---|---|
| Roles + group access control | P | P | Y | N |
| Check-in/check-out locking | Y | Y (proprietary CAD client) | Y | N |
| File versions / rollback | P | P | P | Y (Git) |
| Audit log | P | P | P | Y (Git) |
| Metadata + full-text search | P (metadata only) | P | Y (incl. file contents) | P |
| STEP/STL web preview | Y (browser + server conversion to glTF) | Y (server conversion) | Y (FreeCAD conversion) | N |
| Lifecycle with transition rules | Y | Y | P | N |
| Change propagation to dependents | P (release gate) | P | Y (changelog to verify) | P |
| Review/approval + notifications | Y | P | Y | P |
| Multi-level BOM, where-used, export | Y | Y | P | P |
| ECR/ECO with impact analysis | Y (where-used only) | P | P | N |
| API / webhooks / SSO | REST + webhooks, no OIDC/LDAP | XML-RPC/REST, OIDC via OCA module | REST, OIDC [unverified] | Git |
Why TeamCraft is its own system
- TurboPLM has the closest feature set but is young, from a single vendor that already changed the license once, without versioned migrations and without OIDC.
- OdooPLM is mature but requires a full Odoo ERP and a proprietary CAD client – heavy to operate for a small team.
- DocDokuPLM has the best data model but is dormant on old Java, Elasticsearch and Kafka versions across 11 containers.
- GitPLM targets electronics BOMs, not CAD data; it complements TeamCraft for KiCad projects.
TeamCraft therefore is a small, single-binary PLM: one process plus PostgreSQL, a pure-Rust build, and an MPL-2.0 licence that allows embedding.
Ideas adopted
| Idea | Source | TeamCraft |
|---|---|---|
| Released revisions immutable, all revisions of a change released atomically | TurboPLM | implemented |
| Admin can break a lock (audit-logged) | TurboPLM | implemented |
| File versions within a revision (check-in = new version) | DocDoku (master → revision → iteration) | implemented |
| Assembly changelog: dependents flagged until acknowledged | DocDoku | implemented (change propagation) |
| Pack-and-go: an assembly with all files as ZIP | OdooPLM | implemented |
| Server-side conversion of CAD files for the web preview | TurboPLM, DocDoku | implemented, in pure Rust |
| Numbering masks per item type | DocDoku | implemented (per project group) |
| HMAC-signed webhooks with delivery history | TurboPLM | planned |
| KiCad HTTP library endpoint | GitPLM | planned |
Sources
TurboPLM: github.com/horatiumm-creator/turboplm, turboplm.com · OdooPLM: github.com/OmniaGit/odooplm, github.com/OCA/server-auth · DocDokuPLM: github.com/docdoku/docdoku-plm, docdoku-plm-conversion-service, docdokuplm.com user guide 2.5 · GitPLM: github.com/git-plm/gitplm
Rust API (rustdoc)
The crate documentation of the teamcraft library – every module, type and
function with its doc comments – is generated by rustdoc and published
next to this book:
Open the Rust API documentation
Build it locally:
cargo doc --no-deps --lib --open
Good entry points: teamcraft::domain (the rules), teamcraft::services
(the business logic), teamcraft::cadformat (the format registry) and
teamcraft::mesh (the 3D readers).
Test coverage map
Where the tests are, where they are thin, and what to write next. The page
is the answer to “which code can break without a test noticing?” – it is
meant to be updated, not admired. Whether the tests check what they run is
the job of mutation testing. Last measured: 2026-11-06, from the CI
job coverage on main at b4cf014 (release 0.2.0; it contains #89–#95
and the proofing tests).
Method
-
Lines of code –
wc -lof every file undersrc/(blank lines and comments included) anddesktop/src/. -
Unit tests – the number of
#[test]/#[tokio::test]functions in the module’s own file (src/simmesh/tests.rsbelongs tosimmesh.rs). -
Integration tests –
tests/*.rstalk to the real router (HTTP), so the routes and services they reach are mapped by what the test asserts, not by imports. The module table names only the test files that use a module directly (teamcraft::module::…); the area tables list the test files that drive the code through the API or the web UI. -
Measured line coverage –
cargo llvm-covin the CI job coverage: the complete test suite (unit, integration and database tests) against PostgreSQL 16 with the client tools. Its per-file summary is the source of the tables below. Locally, without a database, a quick check is:cargo install cargo-llvm-cov --locked && rustup component add llvm-tools-preview cargo llvm-cov --lib --summary-only # unit tests only cargo llvm-cov --summary-only --no-fail-fast # every test that runs without PostgreSQL cargo llvm-cov --html # report in target/llvm-cov/htmlWithout PostgreSQL the database tests skip themselves, so
services/*,web/*andapi/*look uncovered locally although CI exercises them. Inline#[cfg(test)]code counts as covered lines, so the percentage of a module with a large test section is too high – the number of missed lines is the honest figure. -
Ranking – risk × thinness. Risk: untrusted input (file parsers, HTTP bodies, tokens), data integrity (multi-row mutations, vault, backup, migrations, imports, uploads), size of the code. Thinness: missed lines, and whether failure paths (refusal, conflict, rollback, damaged input) are tested at all and not only the happy path.
Coverage in CI
The PostgreSQL 18 leg of the job tests in .github/workflows/ci.yml (until
2026-11-07 a separate job coverage) runs cargo llvm-cov over
the complete test suite with PostgreSQL 18 and the client tools, prints the
per-file summary into the job summary and uploads the HTML report (coverage-html, 14 days) as an artifact.
The floor. Since 2026-11-06 the job fails when the total line coverage
drops below the value in .github/coverage-floor. The last runs on main
were 91.48 % (6b7c95a), 91.52 % (e513b37), 91.53 % (5a39a85) and 91.65 % (0fcfa26, b4cf014): the variation between runs
is about a tenth of a point,
so the floor is the current value minus half a percentage point – 91.1 %,
about 280 missed lines more than today. Raise it when the coverage has grown
for good; lowering it is a deliberate change of that file in the pull
request, with the reason in its description.
Areas
Missed lines / lines and line coverage from CI (b4cf014).
| Area | Code | Tests | CI: missed / lines | HTTP / integration tests |
|---|---|---|---|---|
File parsers (mesh*, simmesh, stepbom, swpart, nxpart, thumbnail, cadrefs, bomtable, textindex, cadformat, shape, pdftext, resultscan) | 10.1k counted lines | unit tests per module; property tests (#94) | 361 / 10,058 – 96.4 % | cad_fixtures (real NIST files), uploads, web_ui, parser_robustness, parser_corpus, preview_limits |
services/* (business logic, the only writer) | 17.0k counted lines | unit tests in a third of the modules | 1,165 / 16,999 – 93.1 % | api_flow, read_permissions, change_orders, change_requests, concurrency, uploads, webhooks, structure, numbering_and_watches, release_readiness, reviewers, audit_and_vault, interchange, working_folder, similar_parts, attributes, search_listing, scale, bom_performance, import_atomicity, import_proofing, service_refusals, migrations, atomic_processes, result_values, result_proposals, assistant_threads, agent_tools |
web/* (HTML handlers) | 5.6k counted lines | – | 891 / 5,613 – 84.1 % | web_ui, the feature tests that use the forms, no_unchecked_in_handlers |
api/* (REST handlers) | 2.9k counted lines | – | 305 / 2,878 – 89.4 % | api_flow and the feature tests, range_downloads |
domain/*, config.rs, util.rs, error.rs | 3.1k counted lines | unit and property tests | 87 / 3,068 – 97.2 % | all of them; domain_and_vault |
auth.rs, oidc.rs | 0.6k counted lines | 10 unit | 191 / 649 – 70.6 % (oidc.rs 53.5 %) | api_flow, read_permissions (tokens, sessions), oidc_flow (SSO against a fake provider, session and PAT lifetime; the CI numbers predate it) |
vault.rs, backup.rs, db.rs | 1.1k counted lines | unit tests in each | 101 / 1,148 – 91.2 % | audit_and_vault, domain_and_vault, uploads, backup_restore, backup_errors, backup_proofing, migrations |
webhooks.rs, delivery.rs, notify.rs | 1.0k counted lines | unit tests in each | 129 / 971 – 86.7 % (notify.rs 26 %) | webhooks, webhook_ssrf, api_flow (e-mail outbox), atomic_processes; no test against a real SMTP/Mattermost server |
assistant.rs, llm/*, mcp/* | 5.2k counted lines | unit tests in each | 306 / 5,236 – 94.2 % | assistant, assistant_threads, mcp, agent_tools, read_permissions |
main.rs, lib.rs (CLI, router) | 0.8k counted lines | 3 unit | 533 / 756 – 29.5 % | the CLI runs only in the CLI smoke test of the tests job, which the coverage job does not measure |
Desktop (desktop/src, own crate, not measured) | 9.1k lines | 72 unit | – | none (CI runs the desktop tests on Linux only) |
Feature to test map
Every file in tests/ (2026-11-06, ab1abd9), grouped by the feature it
pins. The count is the number of #[test] / #[tokio::test] functions in the
file (grep -c "#\[test\]\|#\[tokio::test\]"); it is a size hint, not a
measure of quality. The CI numbers in the tables above are still from
b4cf014.
| Feature | Test files (count) |
|---|---|
| REST workflow, end to end | api_flow (24), atomic_processes (10), service_refusals (6) |
| Items, numbering, lists | numbering_and_watches (1), search_listing (1), working_folder (1) |
| Change orders and release | change_orders (6), release_readiness (2) |
| Engineering change requests | change_requests (4), change_request_paths (1), request_digests (1) |
| Files, vault, uploads, downloads | uploads (11), range_downloads (1), domain_and_vault (10), audit_and_vault (3), concurrency (6), preview_limits (5), folder_import (2) |
| Product structure and BOM | structure (4), bom_performance (4), bom_tree_view (2), insights (3), interchange (2) |
| Mass properties | mass_properties (3; unit tests in massprops and services::mass_properties) |
| BOM import | import_atomicity (4), import_proofing (7) |
| CAD readers, previews, results | cad_fixtures (13), parser_robustness (16), parser_corpus (2), step_export_names (2), similar_parts (2), result_values (4), result_proposals (3) |
| Custom attributes | attributes (2) |
| Suggested reviewers | reviewers (1) |
| Login, SSO, access control | oidc_flow (9), read_permissions (8), no_unchecked_in_handlers (2), web_security (7) |
| Outgoing webhooks | webhooks (8), webhook_ssrf (4), web_webhooks (3) |
| Error notifications | notify (1) |
| Backup, restore, migrations | backup_restore (1), backup_errors (7), backup_proofing (5), migrations (6) |
| Assistant, MCP, agent tools | assistant (3), assistant_threads (3), agent_tools (4), mcp (3), mcp_contract (2) |
| Web UI | web_ui (16) |
| Command line | cli (4) |
| Desktop app and server versions | desktop_compat (4, the version handshake rules, no database) |
| Architecture rules | architecture_guards (7, reads the sources, no database) |
| Scale check | scale.rs (0 active, one #[ignore] test; scale/seed.sql) |
| Shared helpers and data | common/mod.rs (test database per test), fixtures/ (NIST CAD files, v0.1.0 seed) |
Unit tests in src/, by feature (count of test functions in the module or
its tests file):
| Feature | Modules (count) |
|---|---|
| CAD and mesh readers | mesh (13), mesh/p21 (5), mesh/step (4), mesh/tessellated (6), simmesh (19), shape (9), stepbom (13), swpart (14), nxpart (7), thumbnail (12), bomtable (20), cadrefs (21), cadformat (19), textindex (9), pdftext (5), resultscan (9), fuzz_harness (2) |
| Previews, pages, HTTP ranges | preview (5), httprange (5), pagecontext (5), assets (8), templates (4), templates/accessibility (1) |
| Pure rules and config | domain/mod (38), models (10), config (16), util (5) |
| Vault, backup, database | vault (4), backup (9), db (1) |
| Services with unit tests | services/bom (15), services/bom_compare (4), services/imports (6), services/readiness (6), services/simulations (5), services/requests/tests (5), services/uploads (5), services/files (3), services/impacts (3), services/request_digests (3), services/webhooks (4), services/users (2), services/checkouts (2), services/reviewers (2), services/notifications (2), services/assistant_threads (2), services/desktop (2), services/similar (1), services/result_proposals (1), services/personal (1), services/access (1) |
| Web, auth, integrations | web/mod (2), auth (8), oidc (4), webhooks (9), delivery (4), notify (4), assistant (12), mcp/mod (2), mcp/tools/tests (14), llm/anthropic (5), llm/http (3), llm/openai (8), main (3) |
Services and pages with no unit test (services/items, changes,
revisions, interchange, attributes, insights, projects, explorer,
references, workset, save_as, package, stats; every web/* and
api/* module): they are covered through the HTTP tests above only.
Gaps
Judged by the test counts above, the missed lines of the CI table and whether a failure path has a test. Ordered by risk.
- Vault explorer (
services/explorer.rs,web/explorer.rs): no test file of its own and no unit test; onlyapi_flow,web_uiandscaletouch it. - Projects and self-service groups (
services/projects.rs, 53 of 558 lines missed): no own file and no unit test; the failure paths (a refused change by a non-manager, a removed member keeping access) are the ones to pin, in a newprojectsfile. - Simulations (
services/simulations.rs85 missed,web/simulations.rs74.6 %,api/simulations.rs90.7 %): five unit tests and no own file. - Change requests triage (
services/requests.rs, 162 missed;web/requests.rs, 131 missed at 75.1 %): one integration test for the paths, the triage pages are untested except throughweb_ui. - Admin and account pages (
web/admin.rs74.3 %, 110 missed;web/account.rs83.4 %;web/personal.rs80.7 %): no own file, no unit test. - Attributes and interchange (2 integration tests each, no unit test;
web/attributes.rs75 %,web/interchange.rs88 %): the failure paths to add are a refused edit by a non-administrator and a conflicting alternate. - Search, watches, reviewers (1 to 2 tests each): the failure paths to add are a filter that must not reveal a restricted item and a watch on a removed item.
- Identity and notifications with real servers:
oidc.rs53.5 % (CI),notify.rs26 % (CI, beforetests/notify.rs), no test against a real SMTP or Mattermost server;services/users.rs72.9 % (187 missed) for deactivation and token revocation of a whole account. - Range downloads (
tests/range_downloads.rs, 1 test):httprangeis at 100 % line coverage, but the file-level test count is the thinnest of the download path; the failure path to add is a resume whoseIf-Rangeno longer matches the stored object.
Next tests come from these items in the order above, failure path first (refusal, conflict, rollback).
Top 15: the thinnest high-risk spots
Ranked by risk × thinness on 2026-11-05; the last two columns are today’s state.
| # | Spot | Why it is risky | Tests that pin it | CI missed / lines | Open |
|---|---|---|---|---|---|
| 1 | services/imports.rs + bomtable/stepbom import path | Multi-row write from untrusted tables; one transaction must hold | import_atomicity (refusals, injected failure, retry, no duplicates), import_proofing (7), damage tests | 62 / 860 | – |
| 2 | db.rs + migrations/ | A bad upgrade destroys every installation | migrations (6): numbering and the known gap, every migration one at a time ending at the fresh schema (constraints, indexes, triggers, functions), twice a no-op, checksum rule, a failed migration blocks, a v0.1.0 database with data upgrades without loss (#91) | 16 / 105 | – |
| 3 | backup.rs | A restore is the last line of defence | backup_errors (7), backup_proofing (5, #93): corrupt or missing objects refuse the backup and nothing is left, a v0.1.0 backup is restored and migrated, a failing --force leaves database and vault untouched, credentials, large objects, preview cache | 55 / 719 | – |
| 4 | services/changes.rs | Release of a change order flips many revisions at once | atomic_processes (#92): an injected failure (error or lost connection) at the second revision, the notifications or the webhook deliveries leaves nothing; two releases at once release once | 85 / 1,223 | – |
| 5 | services/files.rs, services/uploads.rs | Check-in, versions, chunked uploads: data loss on races | atomic_processes: failure between the versions and the lock release, upload finish repeatable; two check-ins at once give one winner; concurrency, uploads, range_downloads | 45 / 935, 30 / 457 | – |
| 6 | services/items.rs, revisions.rs | Creation, numbering, lifecycle | service_refusals, domain_and_vault, atomic_processes (12 parallel creations, the same number twice) | 54 / 724, 4 / 490 | – |
| 7 | services/bom.rs | Cycles and quantities corrupt every roll-up | 10 unit, structure, service_refusals | 14 / 1,004 | – |
| 8 | services/checkouts.rs | The lock is the integrity mechanism of the vault | concurrency | 10 / 240 | – |
| 9 | services/users.rs, auth.rs, oidc.rs | Sessions, tokens, SSO – untrusted input | unit tests, api_flow, read_permissions, oidc_flow (8, fake identity provider in the test process): a good login provisions the account with the mapped role; a missing, forged or expired state, a replayed callback, an error from the provider, a wrong nonce, an old exp, another aud or iss, no subject sign nobody in; a PKCE mismatch and a used code are a login error (fixed, below), a wrong client secret stays an internal error; ID token signatures are not checked by design (pinned); sessions and PATs end on expiry, sign-out / revocation (owner or admin, audited) and deactivation | 187 / 690, 11 / 262, 180 / 387 (before oidc_flow) | – (a plain-HTTP issuer, below) |
| 10 | webhooks.rs + services/webhooks.rs (SSRF guard, signing) | Server-side requests to configurable URLs | unit tests of the address rules (internal_addresses, default_policy_blocks_internal_targets, allow_list_restricts_and_trusts_hosts), HMAC vectors, webhooks, atomic_processes, webhook_ssrf (4) and every_connection_checks_the_answer_it_uses: a name that passes when saved and resolves to loopback, cloud metadata, an IPv4-mapped or unique-local address when used (DNS rebinding) is refused at connect time and every attempt resolves anew; mixed answers keep only public addresses; localhost via the system resolver and a stored IP literal are refused; a listed host is delivered (resolution injectable: Dispatcher::with_lookup) | 43 / 582, 25 / 679 | – |
| 11 | mesh.rs readers | Untrusted bytes, big allocations | parser_robustness, parser_corpus, fuzz targets | 108 / 1,535 | – |
| 12 | simmesh.rs (VTK/VTU/FRD) | Untrusted, hand-written parsing | 19 unit, damage tests | 81 / 1,465 | an FRD sample in the damage tests |
| 13 | swpart.rs, nxpart.rs, thumbnail.rs | Offsets, lengths, decompression bombs | unit tests, NIST files, damage tests | 8 / 657, 3 / 262, 15 / 543 | a SOLIDWORKS container built by hand |
| 14 | stepbom.rs, bomtable.rs, cadrefs.rs, textindex.rs, cadformat.rs | Untrusted text and spreadsheets; heuristic | unit tests, damage tests | 5 / 655, 10 / 747, 2 / 442, 2 / 200, 4 / 771 | – |
| 15 | vault.rs | Content-addressed storage: the bytes of every revision | domain_and_vault; since #93 every backup hashes every object and requires every referenced one | 30 / 324 | – |
Thin, outside the top 15: services/requests.rs (162 of 1,587 lines missed),
web/requests.rs, web/simulations.rs, web/attributes.rs and web/admin.rs
(a quarter of their lines missed each), desktop/src/commands.rs. Covered
since (the numbers in the table below are from before, until the next
coverage run):
notify.rs(48 of 65 missed) –tests/notify.rs: an internal error of a request reaches a local stand-in for Mattermost, further errors within the minute are only counted, a receiver answering 500 changes nothing; unit tests for the rate limit (with the clock passed in) and the message.services/insights.rs(83 of 187) –tests/insights.rs: every kind of BOM issue once per line, the released rule, a cycle, a hidden line reported without its number, an empty BOM, the cap of 500 findings; duplicate files with and without a scope, for a reader and an administrator.web/webhooks.rs(48 of 216) –tests/web_webhooks.rs: every page and action for a non-administrator and from another site, the form rules (including internal addresses as IP literals), test delivery, retry (409 for a sent delivery and an inactive webhook), secret rotation, deletion.
Found while writing the tests
- Fixed (2026-11-05) –
mesh::read("gltf", …)panicked (index out of bounds insidegltf-json) for a primitive whosePOSITIONpoints past the accessor list. The library call is now wrapped incatch_unwindand yields an error; regression testgltf_accessor_index_out_of_range_is_an_error. - Fixed (#93) –
backup::backupcopied vault objects without checking their hash and did not require the objects the database refers to; a corrupt or missing object was found only at restore time. The backup now refuses both and removes what it wrote. - Fixed (2026-11-06) – the commands of the CLI wrote their log lines (e.g. the audit line of
user create) to standard output, between their own output. They now log to standard error; the server still logs to standard output (tests/cli.rs). - No bug (#92) – every multi-row mutation covered by
atomic_processesalready ran in one transaction; a mutation check (a check-in split into two transactions) makes those tests fail. - Fixed – a login code the identity provider refused (
invalid_grant: a code already used or expired, or a PKCE verifier that does not match – an intercepted code) ended in a 500 page and an incident report. It is a failed login attempt, not a server fault: it is now the login page with a 400 and “the identity provider refused the login code”; other token endpoint errors (invalid_client, a 5xx, no connection) stay internal errors because the administrator has to act. Regression testsa_pkce_mismatch_or_a_used_code_is_refused_by_the_provideranda_misconfigured_client_stays_a_server_error. - Observation, decision left open – ID token signatures are not
verified; that rests on the token coming straight from the token endpoint
over TLS (OIDC Core 3.1.3.7 step 6).
TEAMCRAFT_OIDC_ISSUER_URLalso acceptshttp://(an internal identity provider without TLS); then nothing protects the token on its way. Refusinghttp://outside oflocalhost, or verifying the signature against the provider’s JWKS when the issuer is plain HTTP, would close that – not changed here because it can break existing installations. - Fixed (2026-11-06) –
insights::duplicate_fileswith a revision of a group the reader may not see answered with the visible copies of its files (only the MCP tool checked the revision first). The service now answers “not found” itself (tests/insights.rs). - No bug – a webhook URL with a host name such as
localhostis saved: only IP literals are checked then, the name when the connection is made (tests/webhook_ssrf.rs). - No bug – the SSRF check at connect time: the resolver filters every answer for each new connection; a mutation that drops the filter fails the four resolver-based tests.
What to write next
services/requests.rsand the thin web pages (web/requests.rs,web/simulations.rs,web/attributes.rs,web/admin.rs).- FRD sample and a SOLIDWORKS container built by hand in the damage tests.
- Done: the CLI (
main.rs, 511 of 643 lines missed) –tests/cli.rsruns the real binary against a test database (migrate,user,vault check,rescan-references,assistant check,healthcheck,preview-mesh,text-extract, themcpbridge), which the coverage job measures;serve,backup/restore(seebackup_restore) andvault signaturesstill run only elsewhere. - Done: atomicity under an injected failure (#92), a floor for the CI
coverage job (
.github/coverage-floor), OIDC failure paths and the SSRF check at connect time (oidc_flow,webhook_ssrf),cargo fuzztargets (see Fuzzing the file readers), error notifications, BOM health check and duplicates, webhook pages (notify,insights,web_webhooks).
Per module
From the CI job coverage on b4cf014. Integration names the test files
that use the module by name (teamcraft::…); the HTTP-driven coverage of
services/*, web/* and api/* is in the area table above.
| Module | LOC | Unit tests | CI missed / lines | Line coverage | Integration (direct use) |
|---|---|---|---|---|---|
api/bom_api.rs | 261 | 0 | 7 of 191 | 96.3 % | – |
api/change_requests.rs | 425 | 0 | 46 of 306 | 85.0 % | – |
api/changes_api.rs | 228 | 0 | 18 of 179 | 89.9 % | – |
api/concurrency_api.rs | 64 | 0 | 1 of 36 | 97.2 % | – |
api/files_api.rs | 139 | 0 | 3 of 114 | 97.4 % | – |
api/interchange_api.rs | 156 | 0 | 2 of 105 | 98.1 % | – |
api/items_api.rs | 468 | 0 | 3 of 342 | 99.1 % | – |
api/mod.rs | 699 | 0 | 157 of 530 | 70.4 % | – |
api/notifications_api.rs | 142 | 0 | 1 of 109 | 99.1 % | – |
api/personal.rs | 222 | 0 | 17 of 143 | 88.1 % | – |
api/projects_api.rs | 135 | 0 | 1 of 91 | 98.9 % | – |
api/references_api.rs | 149 | 0 | 0 of 103 | 100.0 % | – |
api/simulations.rs | 406 | 0 | 28 of 301 | 90.7 % | – |
api/uploads_api.rs | 256 | 0 | 17 of 189 | 91.0 % | – |
api/webhooks.rs | 201 | 0 | 4 of 139 | 97.1 % | – |
assets.rs | 413 | 8 | 2 of 280 | 99.3 % | web_ui |
assistant.rs | 2094 | 12 | 71 of 1608 | 95.6 % | agent_tools, read_permissions, result_proposals |
auth.rs | 410 | 6 | 11 of 262 | 95.8 % | api_flow, bom_performance |
backup.rs | 961 | 9 | 55 of 719 | 92.4 % | backup_errors, backup_proofing, backup_restore |
bomtable.rs | 978 | 19 | 10 of 747 | 98.7 % | api_flow, import_proofing, parser_robustness |
cadformat.rs | 1372 | 19 | 4 of 771 | 99.5 % | cad_fixtures, parser_robustness |
cadrefs.rs | 629 | 18 | 2 of 442 | 99.5 % | cad_fixtures, parser_robustness |
config.rs | 1527 | 16 | 47 of 1108 | 95.8 % | api_flow, backup_errors, backup_proofing +3 |
db.rs | 185 | 1 | 16 of 105 | 84.8 % | api_flow, backup_proofing, migrations |
delivery.rs | 477 | 4 | 38 of 324 | 88.3 % | api_flow, read_permissions |
domain/attributes.rs | 289 | 0 | 8 of 184 | 95.7 % | – |
domain/changes.rs | 336 | 0 | 4 of 147 | 97.3 % | – |
domain/concurrency.rs | 173 | 0 | 6 of 97 | 93.8 % | – |
domain/items.rs | 324 | 0 | 0 of 209 | 100.0 % | – |
domain/lifecycle.rs | 155 | 0 | 0 of 66 | 100.0 % | – |
domain/mod.rs | 1021 | 38 | 7 of 663 | 98.9 % | api_flow, assistant, assistant_threads +28 |
domain/notifications.rs | 156 | 0 | 1 of 64 | 98.4 % | – |
domain/references.rs | 88 | 0 | 0 of 32 | 100.0 % | – |
domain/roles.rs | 96 | 0 | 5 of 39 | 87.2 % | – |
domain/simulations.rs | 130 | 0 | 0 of 74 | 100.0 % | – |
domain/skins.rs | 177 | 0 | 1 of 57 | 98.2 % | – |
domain/validation.rs | 220 | 0 | 1 of 167 | 99.4 % | – |
error.rs | 133 | 0 | 6 of 57 | 89.5 % | change_orders, preview_limits, read_permissions |
fuzz_harness.rs | 157 | 2 | 10 of 63 | 84.1 % | parser_corpus |
httprange.rs | 176 | 5 | 0 of 91 | 100.0 % | – |
lib.rs | 233 | 0 | 22 of 113 | 80.5 % | – |
llm/anthropic.rs | 389 | 5 | 4 of 289 | 98.6 % | – |
llm/http.rs | 204 | 3 | 4 of 153 | 97.4 % | – |
llm/mod.rs | 302 | 0 | 7 of 93 | 92.5 % | – |
llm/openai.rs | 550 | 8 | 11 of 425 | 97.4 % | – |
main.rs | 1032 | 3 | 511 of 643 | 20.5 % | – |
mcp/mod.rs | 291 | 2 | 7 of 167 | 95.8 % | read_permissions |
mcp/tools.rs | 3395 | 14 | 202 of 2501 | 91.9 % | read_permissions |
mesh.rs | 1933 | 12 | 108 of 1535 | 93.0 % | cad_fixtures, parser_robustness, web_ui |
mesh/p21.rs | 672 | 5 | 26 of 459 | 94.3 % | – |
mesh/step.rs | 529 | 4 | 17 of 351 | 95.2 % | – |
mesh/tessellated.rs | 438 | 6 | 9 of 359 | 97.5 % | – |
models.rs | 868 | 10 | 0 of 313 | 100.0 % | – |
notify.rs | 116 | 2 | 48 of 65 | 26.2 % | – |
nxpart.rs | 397 | 7 | 3 of 262 | 98.9 % | parser_robustness |
oidc.rs | 492 | 4 | 180 of 387 | 53.5 % | – |
pagecontext.rs | 400 | 5 | 4 of 272 | 98.5 % | – |
pdftext.rs | 321 | 5 | 36 of 241 | 85.1 % | – |
preview.rs | 941 | 5 | 111 of 651 | 82.9 % | cad_fixtures, preview_limits |
resultscan.rs | 863 | 9 | 15 of 600 | 97.5 % | – |
services/access.rs | 159 | 1 | 0 of 61 | 100.0 % | – |
services/assistant.rs | 470 | 0 | 53 of 359 | 85.2 % | – |
services/assistant_threads.rs | 264 | 2 | 1 of 175 | 99.4 % | assistant_threads |
services/attributes.rs | 307 | 0 | 23 of 251 | 90.8 % | – |
services/audit.rs | 322 | 0 | 2 of 242 | 99.2 % | read_permissions |
services/bom.rs | 1435 | 10 | 14 of 1004 | 98.6 % | – |
services/bom_compare.rs | 601 | 4 | 8 of 385 | 97.9 % | – |
services/changes.rs | 1393 | 0 | 85 of 1223 | 93.0 % | api_flow, change_orders, read_permissions |
services/checkouts.rs | 376 | 2 | 10 of 240 | 95.8 % | concurrency |
services/desktop.rs | 234 | 2 | 1 of 146 | 99.3 % | web_ui |
services/explorer.rs | 164 | 0 | 0 of 80 | 100.0 % | – |
services/files.rs | 1219 | 3 | 45 of 935 | 95.2 % | api_flow, backup_proofing |
services/impacts.rs | 1173 | 3 | 24 of 786 | 96.9 % | – |
services/imports.rs | 1197 | 6 | 62 of 860 | 92.8 % | – |
services/insights.rs | 276 | 0 | 83 of 187 | 55.6 % | – |
services/interchange.rs | 708 | 0 | 20 of 496 | 96.0 % | – |
services/items.rs | 1023 | 0 | 54 of 724 | 92.5 % | api_flow, bom_performance, read_permissions |
services/mod.rs | 89 | 0 | 0 of 27 | 100.0 % | api_flow, assistant_threads, backup_proofing +7 |
services/notifications.rs | 838 | 2 | 3 of 544 | 99.4 % | read_permissions |
services/package.rs | 181 | 0 | 4 of 121 | 96.7 % | – |
services/personal.rs | 169 | 1 | 3 of 122 | 97.5 % | – |
services/projects.rs | 688 | 0 | 53 of 558 | 90.5 % | api_flow, read_permissions |
services/readiness.rs | 627 | 6 | 4 of 423 | 99.1 % | – |
services/references.rs | 506 | 0 | 24 of 352 | 93.2 % | – |
services/requests.rs | 1942 | 5 | 162 of 1587 | 89.8 % | read_permissions |
services/result_proposals.rs | 424 | 1 | 16 of 303 | 94.7 % | – |
services/result_templates.rs | 234 | 0 | 15 of 196 | 92.3 % | – |
services/reviewers.rs | 446 | 2 | 12 of 188 | 93.6 % | – |
services/revisions.rs | 594 | 0 | 4 of 490 | 99.2 % | – |
services/save_as.rs | 173 | 0 | 3 of 130 | 97.7 % | – |
services/similar.rs | 677 | 1 | 43 of 434 | 90.1 % | – |
services/simulations.rs | 1769 | 5 | 85 of 1326 | 93.6 % | – |
services/stats.rs | 44 | 0 | 0 of 26 | 100.0 % | – |
services/uploads.rs | 644 | 5 | 30 of 457 | 93.4 % | uploads |
services/users.rs | 836 | 2 | 187 of 690 | 72.9 % | api_flow, backup_proofing, migrations +2 |
services/webhooks.rs | 915 | 4 | 25 of 679 | 96.3 % | – |
services/workset.rs | 304 | 0 | 7 of 192 | 96.4 % | – |
shape.rs | 1031 | 9 | 20 of 771 | 97.4 % | cad_fixtures |
simmesh.rs | 1968 | 19 | 81 of 1465 | 94.5 % | – |
stepbom.rs | 865 | 13 | 5 of 655 | 99.2 % | import_proofing, parser_robustness |
swpart.rs | 978 | 14 | 8 of 657 | 98.8 % | parser_robustness |
templates.rs | 1615 | 3 | 6 of 1263 | 99.5 % | – |
textindex.rs | 302 | 9 | 2 of 200 | 99.0 % | parser_robustness |
thumbnail.rs | 723 | 12 | 15 of 543 | 97.2 % | cad_fixtures, parser_robustness |
util.rs | 156 | 5 | 1 of 104 | 99.0 % | api_flow, web_ui |
vault.rs | 434 | 4 | 30 of 324 | 90.7 % | – |
web/account.rs | 192 | 0 | 24 of 145 | 83.4 % | – |
web/admin.rs | 585 | 0 | 110 of 428 | 74.3 % | – |
web/assistant.rs | 193 | 0 | 23 of 132 | 82.6 % | – |
web/attributes.rs | 170 | 0 | 34 of 136 | 75.0 % | – |
web/auth_pages.rs | 285 | 0 | 55 of 197 | 72.1 % | – |
web/bom.rs | 286 | 0 | 51 of 214 | 76.2 % | – |
web/changes.rs | 484 | 0 | 93 of 400 | 76.8 % | – |
web/dashboard.rs | 55 | 0 | 3 of 39 | 92.3 % | – |
web/desktop.rs | 85 | 0 | 1 of 40 | 97.5 % | – |
web/explorer.rs | 118 | 0 | 10 of 75 | 86.7 % | – |
web/formats.rs | 22 | 0 | 0 of 16 | 100.0 % | – |
web/imports.rs | 242 | 0 | 13 of 187 | 93.0 % | – |
web/interchange.rs | 241 | 0 | 20 of 168 | 88.1 % | – |
web/items.rs | 404 | 0 | 28 of 289 | 90.3 % | – |
web/mod.rs | 889 | 2 | 18 of 639 | 97.2 % | – |
web/notifications.rs | 176 | 0 | 5 of 148 | 96.6 % | – |
web/personal.rs | 324 | 0 | 39 of 202 | 80.7 % | – |
web/references.rs | 143 | 0 | 15 of 96 | 84.4 % | – |
web/requests.rs | 672 | 0 | 131 of 527 | 75.1 % | – |
web/revisions.rs | 1194 | 0 | 105 of 1014 | 89.6 % | – |
web/simulations.rs | 330 | 0 | 65 of 256 | 74.6 % | – |
web/uploads.rs | 90 | 0 | 0 of 49 | 100.0 % | – |
web/webhooks.rs | 271 | 0 | 48 of 216 | 77.8 % | – |
webhooks.rs | 930 | 8 | 43 of 582 | 92.6 % | read_permissions |
Mutation testing
Line coverage (coverage map) says which code the tests run.
Mutation testing says which behaviour they check: cargo-mutants makes a
small change to the code – a >= becomes <, a function returns "", a !
disappears – rebuilds and runs the unit tests. If no test fails, the change
went unnoticed: the tests restate the code instead of pinning it down.
Background, licence and the decision: research note.
What is examined
.cargo/mutants.toml lists the files (examine_globs): the pure modules
whose rules the unit tests are meant to pin down – src/domain/**,
httprange, pagecontext, util, htmltext, resultscan, bomtable,
cadformat, cadrefs and stepbom, plus the 3D readers in src/mesh/ and
src/simmesh/ (untrusted input, unit tests per format). STEP goes through
the truck CAD kernel and stays out (exclude_globs: mesh/step.rs,
mesh/tessellated.rs); the own Part 21 reader mesh/p21.rs is in. Only cargo test --lib runs per
mutant: no PostgreSQL, no integration tests in tests/. A rule that only an
integration test checks therefore shows up as missed here – the fix is a
unit test next to the rule, which is the point.
Services, handlers and templates are not examined yet (What to cover next in docs/OPEN_WORK.md).
Running it locally
cargo install cargo-mutants --locked # the CI version: CARGO_MUTANTS_VERSION in .github/workflows/mutants.yml
cargo mutants --list | wc -l # what the configuration selects
cargo mutants -F '^src/util\.rs:' # one module (a regex on the mutant name)
cargo mutants -F 'format_bytes' # one function
git diff origin/main... > my.diff && cargo mutants --in-diff my.diff # what the pull request job runs
-f <file> does not narrow the run: cargo-mutants 27.1 adds the file to
the examine_globs of the configuration. Use -F with a regex instead. The
eleven field-deletion mutants of resultscan::TemplateInput::validated
ignore -F and -E (a tool quirk) and run along – about five minutes more.
Each mutant is an incremental rebuild of the lib test target (profile
mutants, no debug info, --lib also for the build step) plus the unit
tests. Measured on a 12-thread machine with -j 1: 8–10 s build + 18 s
tests, about 30 s per mutant – httprange (13 mutants) in 10 minutes
with the baseline, util (40) in about 25. The whole configuration (~1 900
mutants, ~16 h at -j 1) is a job for the weekly CI run, not a laptop.
The lib tests are more than half of each mutant’s time, so a slow unit test
costs twice here.
The results land in target/mutants.out/ (output in the configuration):
| File | Meaning |
|---|---|
caught.txt | a test failed – good |
missed.txt | every test passed with the change – a gap in the tests, or an equivalent mutant |
timeout.txt | the tests hung (a loop bound depends on the changed code); counts as caught but is slow |
unviable.txt | the change did not compile – no information |
outcomes.json | everything, machine-readable; log/ has the build and test output per mutant |
cargo mutants --iterate reruns only what was missed or new since the last
run in the same output directory – the quick loop while writing the tests.
Reading a missed mutant
src/util.rs:84:35: replace < with <= in format_bytes
File, line and column, then the change. Ask what wrong behaviour the change
would cause and write the test that shows it – here a value of a few PiB
that must stay in TiB instead of indexing past the unit table. Do not test
the mutation (“< is not <=”); test the behaviour a user or caller would
see.
Some mutants cannot be caught because they do not change behaviour (equivalent mutants): x >= 0 on an unsigned
value, a capacity hint, a
cache that only saves time. Leave them missed and say so in the pull request,
or – when the same pattern keeps coming back – add an exclude_re to
.cargo/mutants.toml with a comment saying why. TeamCraft does not use
#[mutants::skip]: that would add the mutants crate as a dependency, and
the exclusions belong in one reviewed place.
In CI
Workflow Mutation testing (.github/workflows/mutants.yml):
- Pull requests that touch
src/**:cargo mutants --in-diffover the pull request’s diff – only mutants in changed lines of examined files. No such lines, no run. Report-only for now: missed mutants appear as warnings on the changed lines, in the job summary and in the artifact mutants-pr; the check stays green. A broken run (the unmutated tests fail, a diff that does not match, a tool error) fails the job.--in-placeruns one mutant at a time (about 30 s each), so the run is stopped after 80 minutes with a warning and partial results; check a large diff locally. - Weekly and by hand (Actions → Mutation testing → Run workflow): every
examined file, split over 16 shards (
--shard k/16), each shard with its own baseline, at most 8 shards at a time (a single run at a time, weekly or manual); the mutants report job merges the shards’missed.txtinto one summary, counted per file.
Policy
- A missed mutant in code your pull request touches should be fixed in the same pull request: a unit test that checks the behaviour, or a sentence in the description why the mutant is equivalent.
- A new pure module with unit tests joins
examine_globs. - When the weekly run of the examined modules is clean (missed only where documented), the pull request job becomes blocking.
Fuzzing the file readers
Every reader of untrusted files has a fuzz target. Users upload CAD files, BOM tables and simulation results, and TeamCraft opens them on the server (previews, references, text index, BOM import). The readers must return an error or a valid result for any bytes. They must never panic, hang or allocate what a file only claims.
The pieces
| Piece | Where | Runs |
|---|---|---|
| Entry points, one per reader | src/fuzz_harness.rs (TARGETS) | shared by both below |
cargo fuzz targets | fuzz/ (own crate and workspace, never part of the server build) | weekly and on demand: Fuzzing workflow (.github/workflows/fuzz.yml), nightly Rust, Linux |
| Seeds | fuzz/seeds/<target>/ (small text samples; for invpart a synthetic Inventor file with Zstandard segments), the NIST CAD files in tests/fixtures/cad and the synthetic HTML reports in tests/fixtures/reports | both |
| Corpus test | tests/parser_corpus.rs | every cargo test (stable): seeds and matching fixtures whole, cut and with a few bytes corrupted |
| Damage test | tests/parser_robustness.rs | every cargo test: small hand-made samples, every prefix and hundreds of corruptions |
The targets are stepbom, swpart, nxpart, invpart (Inventor segments, part meshes and assembly placements),
creopart (the Creo B-rep container, tagged format and tessellation), cadrefs, bomtable,
thumbnail, textindex, htmltext (HTML reports and the charset detection of text files), mesh (STL, OBJ, PLY, OFF,
3MF, AMF, glTF, 3DS,
points, LAS, VTK legacy and XML, CalculiX FRD) and mesh_step (the STEP
tessellation through truck, kept apart because it is slow), cadformat (the
format detection over a file’s first 1024 bytes) and resultscan (the
extraction templates and the exchange file) and solversummary (the solver
summary of Fluent, Nastran and STAR-CCM+ output).
Two readers catch panics of a library on purpose: the STEP kernel in
mesh::step (its preview conversion also runs in a child process with a
time limit) and gltf-json in mesh::gltf. libFuzzer’s own panic hook
would abort on those too. The targets therefore run through
fuzz_harness::guarded with a hook that only prints. A panic counts as a
crash only when it reaches the harness.
No panics by construction
Every reader of untrusted files denies clippy::unwrap_used, expect_used,
indexing_slicing, panic and unreachable with #![deny(...)] at the
top of the module. These are swpart, mesh with its submodules,
simmesh, stepbom, nxpart, thumbnail, cadrefs, bomtable,
textindex, htmltext, resultscan and cadformat, and config too. Clippy in CI therefore
refuses a new data[at], data[a..b] or .unwrap() there. Read through the
bounded helpers in mesh (bytes_at, u16_le, u32_le, f32_le, range,
quads) or get, slice patterns (let [x, y, z] = *v.as_slice() else { … }),
windows and zip. A file that is shorter than it claims then becomes an
error, not a panic. Tests may still unwrap and index (clippy.toml). An
exception needs #[allow(clippy::indexing_slicing)] on the smallest item,
with a comment why no file can reach it (example: mesh::compose,
fixed-size arrays).
The lint does not see every cut of a &str. Cut strings with get as well,
and never at a byte position computed on another string. A lower-cased copy
can be longer: the Kelvin sign K is three bytes, its lower case k is one.
Running a target locally
rustup toolchain install nightly
cargo install cargo-fuzz
cargo +nightly fuzz run stepbom fuzz/corpus/stepbom fuzz/seeds/stepbom tests/fixtures/cad/nist -- -max_total_time=60
The first corpus directory collects new inputs. It is git-ignored, like
fuzz/artifacts and fuzz/target. cargo fuzz needs a sanitizer-capable
nightly toolchain: Linux and macOS work. On Windows run the corpus test
instead (cargo test --test parser_corpus). The fuzz workflow does the rest.
The workflow pins --target x86_64-unknown-linux-gnu. The prebuilt
cargo-fuzz it installs is a musl binary, and cargo fuzz otherwise defaults
the target to its own build triple (musl), which is not installed on the
runner and whose static libc the AddressSanitizer refuses. A local
cargo install cargo-fuzz builds a host (GNU) binary, so local runs need no
flag; only CI does.
When a target crashes
- Download the artifact fuzz-crash-<target> of the failed run (or take
the file from
fuzz/artifacts/<target>/). - Minimise it:
cargo +nightly fuzz tmin <target> <crash file>. - Add the minimised bytes as a regression unit test next to the reader (as a byte string literal if it is small). The test checks that the reader returns an error or a sensible result. Then fix the reader.
- Put the input into
fuzz/seeds/<target>/as well when it is small and says something about the format.
Threat model
What TeamCraft protects, from whom, where the trust boundaries are, and – per
threat – what the code does about it and which test pins it. Every claim
names the code (file, function) and the test; a threat without both is
marked open and has an entry in docs/OPEN_WORK.md (section 2, Security). Written against TeamCraft 0.2.0 (main
after #108); keep it in
step with the code – a change to a mitigation changes its row.
Status words: mitigated (code and test), partial (code, but a gap named in the row), open (no mitigation), accepted (a deliberate decision, documented). STRIDE letters: Spoofing, Tampering, Repudiation, Information disclosure, D enial of service, Elevation of privilege.
System and trust boundaries
browser ──HTTPS (reverse proxy)──┐ ┌── identity provider (OIDC)
desktop app ──HTTPS + PAT────────┤ ├── LLM provider (assistant)
MCP client / `teamcraft mcp` ────┤ teamcraft ├── SMTP, Mattermost
REST client (PAT) ───────────────┘ server ──────┼── webhook receivers
│ │ │
PostgreSQL ──────────┘ │ └── (outbound, server-initiated)
vault directory ─────────┤
preview / text-extract child process (untrusted file bytes)
- B1 – network ↔ server. Everything from browsers, the desktop app, MCP
and REST clients is untrusted until authenticated. TLS ends at the reverse
proxy (the server speaks HTTP); deployments without TLS are out of scope (
SECURITY.md). - B2 – authenticated user ↔ data of other project groups. A signed-in user is trusted with their role, not with restricted groups they are not a member of.
- B3 – file bytes ↔ server. Uploaded files are hostile input to the parsers (CAD, mesh, point cloud, BOM tables, PDF, simulation results).
- B4 – text in the database ↔ the LLM. File text, descriptions and comments reach the model provider and can carry instructions.
- B5 – server ↔ outbound targets. The server makes requests to URLs partly chosen by administrators (webhooks, LLM, IdP).
- B6 – server ↔ host. Administrators, the file system, the database and
backups are trusted; attacks that need them are out of scope (
SECURITY.md).
Assets
| Asset | Where | Why it matters |
|---|---|---|
| Files of every revision (CAD, drawings) | vault objects/ab/cd/<sha256> (src/vault.rs) | the product data itself; often confidential |
| Items, BOMs, changes, requests, audit log | PostgreSQL | engineering record; release decisions |
| Restricted project groups | projects.visibility, Actor.hidden_projects | confidentiality between teams or customers |
| Credentials | users.password_hash (Argon2), sessions.token_hash, api_tokens.token_hash (SHA-256), webhooks.secret (plain, needed for HMAC), OIDC client secret and LLM key (configuration, _FILE) | takeover of accounts or integrations |
| Availability | server process, disk, database | engineering work stops |
| Desktop working folders | the user’s disk | files written by the desktop app |
Attackers
| Attacker | Can | |
|---|---|---|
| A1 | anonymous network attacker | reach the login page, the API and /auth/* |
| A2 | signed-in user (viewer or editor) | everything their role allows; tries to read restricted groups or act beyond the role |
| A3 | author of a hostile file | gets a file uploaded (a supplier’s STEP, a colleague’s spreadsheet) |
| A4 | author of hostile text | writes a description, comment or file that the assistant or an MCP client later reads |
| A5 | network attacker between server and IdP / LLM / webhook target | reads or changes outbound traffic |
| A6 | administrator, host or database access | out of scope (B6) |
Authentication: passwords, sessions, tokens
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Password database stolen | I | Argon2id with a random salt (src/auth.rs hash_password); minimum 10 characters (src/domain/validation.rs validate_password) | src/auth.rs password_hash_round_trip | mitigated |
| User enumeration at sign-in | I | one message for unknown user, wrong password, OIDC-only and inactive accounts; a dummy Argon2 verify for unknown users (src/services/users.rs authenticate_local, src/auth.rs DUMMY_HASH) | tests/web_ui.rs wrong_password_is_rejected (message); no timing test | mitigated |
| Password guessing (A1) | S | failed sign-ins counted per username and per client address for 15 minutes (TEAMCRAFT_SIGNIN_MAX_FAILURES, 10 per name, 50 per address); then 429 with Retry-After without checking the password; the address is the peer or, behind an internal proxy, the proxy’s X-Forwarded-For entry; in memory per process (src/auth.rs SignInThrottle, client_address; src/web/auth_pages.rs login_submit). The own password change (web form /account/password and POST /api/v1/me/password of the desktop app) shares the per-username counter: a wrong current password counts, a full counter refuses even the right one with 429 (src/services/users.rs change_own_password); so whoever holds a stolen session or token can lock the user’s web sign-in for 15 minutes, as anyone can at the sign-in form. Not throttled: the desktop token exchange (its one-time code is deleted on the first attempt) | src/auth.rs sign_in_throttle_counts_per_name_and_address, client_addresses_behind_a_proxy; tests/web_security.rs failed_sign_ins_are_throttled_per_name_and_address; tests/api_password.rs wrong_current_passwords_are_throttled | mitigated (F1 fixed) |
| Session token guessed or stolen from the database | S, I | 256-bit random tokens, only SHA-256 hashes stored (src/auth.rs generate_token, hash_token; src/services/users.rs create_session) | src/auth.rs tokens_are_random_and_hashed | mitigated |
| Session cookie read by script or sent cross-site | I | HttpOnly; SameSite=Lax, Secure when the public URL is https (src/auth.rs cookie_attributes, src/config.rs TEAMCRAFT_COOKIE_SECURE) | – (attributes not asserted by a test) | partial: no test |
| Session outlives its purpose | S | absolute lifetime TEAMCRAFT_SESSION_TTL_HOURS (72 h); the lookup requires expires_at > now() and an active user (actor_for_session); sign-out deletes the row; deactivation and an admin password reset delete all sessions | tests/oidc_flow.rs sessions_expire_end_with_sign_out_and_with_deactivation | mitigated |
| A stolen session survives the victim’s password change | S | fixed in #110: users::change_own_password ends every other session of the user (the one that made the change stays) and records how many in the audit entry | tests/oidc_flow.rs changing_the_own_password_ends_the_other_sessions | mitigated |
| Role or membership changed, old rights stay | E | role and memberships are read from the database on every request (actor_for_session, actor_for_api_token) | tests/read_permissions.rs visibility_is_managed_and_audited | mitigated |
| Personal access token (PAT) stolen | S, I | 256-bit random, SHA-256 hash stored, shown once, optional expiry, revocation by the owner or an admin (audited token.revoke), dead with a deactivated account (src/services/users.rs create_token, revoke_token, actor_for_api_token) | tests/oidc_flow.rs personal_access_tokens_expire_are_revoked_and_die_with_the_account; tests/api_flow.rs api_requires_a_valid_token | mitigated |
| A PAT has more rights than its client needs | E | token scopes (domain::TokenScope, enforced in the ApiCaller extractor and the MCP dispatcher): full = read, write, approve (existing and desktop tokens), write = no review, change request decision or obsoleting, read_only, mcp_only; a token cannot create a wider one; the desktop token expires after 30 days without use (sliding) | tests/token_scopes.rs; unit tests in src/domain/tokens.rs | mitigated (F2 fixed; the desktop token is still full) |
| Sign-in events cannot be traced | R | token creation and revocation, password changes, role changes, OIDC provisioning and desktop authorisation are audited, and so are sign-in (user.sign_in with the method: password, SSO, setup), failed password sign-in (user.sign_in_failed; an unknown typed username is not kept), a wrong current password at the own password change (user.password_failed) and sign-out (user.sign_out) – never a password or token (src/services/users.rs create_session, delete_session, record_failed_sign_in) | tests/web_security.rs sign_in_and_sign_out_are_audited; tests/architecture_guards.rs | mitigated (F3 fixed) |
Web: CSRF, XSS, headers, redirects
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Cross-site form post (CSRF) | E | csrf_guard on every non-GET web request compares Origin (or Referer) with the host and the public URL; Origin: null is refused; SameSite=Lax as second layer (src/web/mod.rs csrf_guard, same_origin). A request without both headers is refused (#111); X-Forwarded-Host is trusted as sent by the proxy (documented in the install guide) | src/web/mod.rs origin_checks; tests/web_security.rs a_form_post_without_origin_and_referer_is_refused, cookie_requests_without_origin_or_referer_are_refused (nothing written, sign-out refused); tests/web_ui.rs cross_site_posts_are_blocked; tests/assistant_threads.rs, tests/uploads.rs (cross-site cases) | mitigated (F4) |
| Cookies used against the API | E | the API accepts only Authorization: Bearer (src/auth.rs ApiUser); no cookie code in src/api | – (no test sends a session cookie to /api/v1) | partial: no test |
| Script injection (XSS) | E, I | MiniJinja templates; CSP default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self', no inline JS or styles (src/lib.rs) | tests/web_security.rs security_headers_and_hsts_for_https (the full policy, no unsafe-inline/unsafe-eval) | mitigated |
| Clickjacking | T | frame-ancestors 'none' and X-Frame-Options: DENY (src/lib.rs) | tests/web_security.rs security_headers_and_hsts_for_https | mitigated |
| Downgrade to HTTP | I | Strict-Transport-Security: max-age=31536000 on every response when the public URL is https (follows TEAMCRAFT_COOKIE_SECURE, src/lib.rs; #111); the install guide covers the proxy | tests/web_security.rs hsts_follows_the_public_url, security_headers_and_hsts_for_https (pages, API, /healthz) | mitigated (F5) |
| Open redirect after sign-in | S | util::safe_redirect_target accepts only local paths (no //, no \); applied to next and to the OIDC return target | src/util.rs redirect_targets; tests/oidc_flow.rs (return to next) | mitigated |
| First-start setup taken over | E | /setup only with local sign-in, a loopback bind address and no user yet, under an advisory lock (src/web/auth_pages.rs setup_open) | tests/web_ui.rs first_start_creates_the_administrator | mitigated |
Authorization and restricted project groups
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| A non-member reads a restricted group (A2) | I | reads go through the SQL guards of src/services/access.rs (item_visible, project_visible, change_visible, request_visible, …) with the actor’s hidden_projects; a hidden object answers like a missing one (ensure_visible); design in ADR 0001 | tests/read_permissions.rs outsiders_see_nothing_of_a_restricted_group and every_route_has_a_placeholder_rule (every web, REST and MCP route), texts_naming_restricted_objects_are_redacted, the review-round tests; tests/no_unchecked_in_handlers.rs | mitigated; residual oracles listed in SECURITY.md |
| An administrator who is not on the named list reads a restricted group (#151) | I | need-to-know policy per group: with_reason (time-limited, audited elevation with a written reason, managers notified) or none; the rule fills Actor::hidden_projects for administrators (access::hidden_of_user), so all guards apply | tests/need_to_know.rs | mitigated for groups with a policy: conditions on membership – IdP claims, allowed networks, maximum sign-in age, administrators included (step 2, services::membership_conditions, tests/membership_conditions.rs); interface switches (api_tokens, mcp, assistant, webhooks, packages, notification_content, external_llm, desktop_sync, tests/interface_switches.rs, tests/interface_switches_remaining.rs); the read and download log incl. MCP and assistant tool reads and desktop Get (tests/access_log.rs, tests/access_log_channels.rs); the access review with schedule and reminders (tests/access_review.rs, tests/access_review_schedule.rs); token scopes (F2) |
| A viewer or editor writes beyond the role | E | Actor::require, can_edit_project, revisions::ensure_editable in the services (src/auth.rs, src/services/*) | tests/api_flow.rs, tests/service_refusals.rs, tests/read_permissions.rs | partial: by convention; no route sweep for writes as there is for reads |
| A mutation leaves no trace | R | audit::record in the transaction of every service mutation; exceptions listed with a reason | tests/architecture_guards.rs every_service_mutation_writes_an_audit_entry (#96) | mitigated |
| One request makes the server resolve an unbounded list (A2) | D | list lengths are checked before the list is resolved: the affected items of a change request at most requests::MAX_ITEMS (100) in the REST API, the MCP tool and again in requests::create (distinct items; repeated references are dropped before any lookup – before the CodeQL triage each repeat was looked up and inserted, ~150 000 times for a 2 MB body of one item); simulation results at most MAX_RESULTS (2000) per import; JSON bodies are capped by axum’s default limit (2 MB) | tests/change_requests.rs too_many_items_are_rejected_before_any_is_resolved | mitigated for the lists found by CodeQL; no general audit of list fields |
Single sign-on (OIDC)
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Login CSRF / injected code | S | state (stored, 15 minutes, used once: DELETE … RETURNING), nonce in the ID token, PKCE S256 (src/oidc.rs start_login, finish_login) | tests/oidc_flow.rs broken_state_never_signs_in, a_good_login_signs_in_and_maps_the_groups (replay), broken_id_tokens_never_sign_in (nonce) | mitigated |
| Intercepted or replayed authorization code | S | PKCE verifier and single-use codes at the provider; a refused code (invalid_grant) is a login error (fixed in #105) | tests/oidc_flow.rs a_pkce_mismatch_or_a_used_code_is_refused_by_the_provider | mitigated |
| Forged ID token | S | iss, aud, exp (120 s skew), nonce and sub are checked (validate_id_token_claims); the issuer of the discovery document must match the configured one. The signature is not verified: the token comes straight from the token endpoint over the back channel, which OpenID Connect Core 1.0, 3.1.3.7 step 6 allows only when that channel is TLS. TEAMCRAFT_OIDC_ISSUER_URL also accepts http:// (src/config.rs), and then nothing protects the token against A5 | tests/oidc_flow.rs broken_id_tokens_never_sign_in, id_token_signatures_are_not_checked_by_design | open for plain-HTTP issuers (F6) |
| Takeover of a local account by an SSO name | S, E | accounts are linked by the provider’s sub only; a username that is taken gets a suffix (src/services/users.rs upsert_oidc_user) | tests/oidc_flow.rs a_good_login_signs_in_and_maps_the_groups | mitigated |
| Everyone at the IdP gets in | E | without TEAMCRAFT_OIDC_VIEWER_GROUP every account of the provider signs in as viewer; with it, only members (src/oidc.rs role_for_groups; documented in docs/src/admin/users.md) | src/oidc/tests.rs group_mapping | accepted (documented) |
| Removed at the IdP, still signed in | E | the role follows the groups at the next SSO sign-in. Back-channel logout (OpenID Connect Back-Channel Logout 1.0, POST /auth/oidc/backchannel-logout): a logout token signed by the provider ends every browser session of the user at once (src/oidc.rs verify_logout_token, src/services/users.rs idp_logout, audited user.idp_logout). Without it a running session stays until it expires (72 h by default). API and desktop tokens of SSO users can get an idle lifetime (TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS), and administrators see SSO users without sign-in or token use for 90 days who still hold something (services::offboarding::idle_sso_users, #173 step 9). A need-to-know group can set a maximum sign-in age (max_signin_age_hours): after it, the group is hidden from that session (and from tokens once the user’s last sign-in is that old) until the user signs in again, with the claims the IdP gives then (services::membership_conditions) | tests/oidc_flow.rs the_identity_provider_ends_the_sessions_over_the_back_channel, sso_tokens_get_an_idle_lifetime_and_idle_sso_users_are_reported; tests/membership_conditions.rs the_group_is_hidden_after_the_maximum_sign_in_age, claims_of_the_last_single_sign_on_decide | mitigated when the provider sends back-channel logout (F7); otherwise until the session expires |
| Forged back-channel logout ends sessions | S, D | the endpoint is outside the cross-site guard and takes no cookie; the logout token is the credential: a JWS signed with a key of the provider’s jwks_uri (RS256 or ES256 only, alg: none and a key whose alg differs are refused), iss, aud, iat (at most 5 minutes old), exp, the logout event, a sub and no nonce are checked; an unknown key id fetches the keys again at most once a minute; a refused token gets 400, an unknown subject 200 without effect. No jti replay cache: a replayed token only ends sessions again | src/oidc/tests.rs a_signed_logout_token_names_its_subject, logout_token_claims_are_checked; tests/oidc_flow.rs the_identity_provider_ends_the_sessions_over_the_back_channel, backchannel_logout_needs_single_sign_on | mitigated |
| Provider misconfiguration hidden | – | invalid_client, 5xx and network errors stay internal errors (reported) | tests/oidc_flow.rs a_misconfigured_client_stays_a_server_error | mitigated |
Vault, uploads and downloads
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Path traversal into or out of the object store | T, I | object paths only from 64 hex characters (src/vault.rs object_path); staging names are UUIDs; file names are sanitised (util::sanitize_filename: no /, \, control characters or leading dots) | tests/domain_and_vault.rs hashes_cannot_escape_the_object_tree; src/vault.rs rejects_bad_hashes; src/util.rs filenames; tests/backup_errors.rs symlinks_in_the_vault_are_not_followed | mitigated |
| Content does not match its name | T | the hash is computed while storing, chunked uploads are re-hashed against the announced size and SHA-256 (src/vault.rs Upload::write, hash_staged; src/services/uploads.rs verify_content) | tests/uploads.rs; src/services/uploads.rs announced_hashes_are_validated | mitigated |
| A blob changed on disk is served | T | downloads stream the blob without re-hashing; teamcraft vault check --verify-hashes and every backup find it (Vault::verify, backup::copy_checked_objects) | tests/domain_and_vault.rs a_blob_that_changed_on_disk_fails_verification; tests/backup_proofing.rs a_corrupt_or_missing_vault_object_refuses_the_backup | partial (needs host access, B6; noted as F8) |
| Another user’s upload session used | S, T | sessions are loaded only for their owner (others get 404), expire after 24 h, appends are serialised and need the confirmed offset (409 otherwise) (src/services/uploads.rs load, append) | tests/uploads.rs sessions_are_private_and_checked, concurrent_chunks_at_one_offset_are_serialised, expired_sessions_are_removed | mitigated |
| Disk or memory exhausted by uploads | D | TEAMCRAFT_MAX_UPLOAD_MB (512) as body limit and in Upload::write; chunks up to TEAMCRAFT_UPLOAD_CHUNK_MB (16); at most 16 open upload sessions per user; expired sessions and stale staging files are removed; optional storage quotas per user and for the vault (413) | tests/domain_and_vault.rs an_upload_over_the_limit_is_refused_and_leaves_no_blob; src/services/uploads.rs chunks_stay_within_the_announced_size; tests/quotas.rs | mitigated (quotas are opt-in) |
| Download of a restricted file | I | every download path goes through files::get / get_version with access::ensure_visible; Range and If-Range in src/httprange.rs | tests/read_permissions.rs outsiders_see_nothing_of_a_restricted_group; tests/range_downloads.rs downloads_resume_with_range_and_if_range | mitigated |
| An uploaded HTML or SVG file runs script | E | downloads are always Content-Disposition: attachment (util::content_disposition), X-Content-Type-Options: nosniff and the CSP on every response; request attachments additionally default-src 'none'; sandbox. The stored Content-Type is the one the client sent (files::clean_content_type checks only its form) | – tests/web_security.rs pins attachment, nosniff and CSP | partial (F10) |
File parsers
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| A crafted mesh, STEP, PDF or result file crashes or hangs the server (A3) | D | 3D conversion (teamcraft preview-mesh) and the text of PDF and HTML reports (teamcraft text-extract --format pdf or html) run in a child process with a wall-time limit, kill_on_drop, size caps per format and two at a time (src/preview.rs run_worker, src/pdftext.rs; HTML: the first 16 MB are read, at most 256 open elements are tracked, 200 000 characters out, src/htmltext.rs); a failure is cached, not retried | src/preview.rs failures_of_the_conversion_process_are_explained; tests/parser_robustness.rs; tests/preview_limits.rs the_html_text_process_is_bounded | mitigated on Linux and macOS |
| Memory exhaustion in the child | D | RLIMIT_AS 1 GiB on Unix (src/preview.rs limit_memory); no memory limit on Windows; a 3MF package entry may claim up to 2 GiB (src/mesh.rs zip_entry) | – | partial (F11) |
| A compromised child reads the server’s secrets | I | the children start with a cleared environment (env_clear in preview.rs, pdftext.rs); no sandbox beyond the process boundary | – | mitigated (F12) |
| Parsers that run inside the server process | D | CAD reference scan (src/cadrefs.rs, with nxpart, swpart), plain text index, thumbnails, BOM import (stepbom, bomtable) run on blocking threads with their own caps: scan 32 MiB, text 4 MiB / 200 000 characters plus a 64 KiB tail of a larger plain-text file (at most a quarter of the characters, only after a text head, no NUL), result-template scan 2 MB head plus 2 MB tail of every scannable upload above 2 MB (up to 4 MB held per file during a check-in, also for files no template reads), thumbnail stream 8 MiB, tables 20 MiB, STEP 200 MiB, CATIA product structure 64 MiB (src/catprod.rs, assembly previews), Creo assembly structure 64 MiB (src/mesh/creo/asm.rs, section sizes bounded by the container reader), Deflate sections bounded and CRC-checked (src/swpart.rs). A panic ends only the blocking task. XLSX and ODS are unpacked by calamine in the server process without a cap on the unpacked size (src/bomtable.rs), so a ZIP bomb under 20 MiB can exhaust memory | tests/parser_robustness.rs damaged_bom_tables_never_panic, damaged_containers_never_panic, thumbnail_extraction_survives_damaged_files, claimed_sizes_beyond_the_file_are_refused | partial (F13) |
| Panics on hostile bytes | D | catch_unwind around third-party readers (truck STEP, glTF, pdf_oxide); fifteen cargo fuzz targets (creopart included) run weekly (fuzz/, .github/workflows/fuzz.yml, Fuzzing) – the workflow built no target until fix/fuzz-build-target (it defaulted to the musl triple); the seed and fixture corpus runs in every CI run. #![deny(clippy::unwrap_used, expect_used, indexing_slicing, panic, unreachable)] on the readers of untrusted files (mesh, simmesh, swpart, nxpart, invpart, invplace, stepbom, bomtable, cadrefs, textindex, htmltext, thumbnail, resultscan, cadformat; #106, #108), which fixed a 3DS and a reference-scan panic. Not fuzzed: simmesh, pdftext; pdftext is not under the lint | tests/parser_corpus.rs the_corpus_never_panics_a_reader; tests/parser_robustness.rs; src/mesh.rs three_ds_list_chunks_without_their_count; src/cadrefs.rs a_non_ascii_letter_in_the_extension_does_not_panic | partial (F14) |
| XML entity expansion / external entities | D, I | quick-xml (3MF, AMF, VTU/VTP, SOLIDWORKS component tree) neither loads DTDs nor expands custom entities | tests/parser_robustness.rs component_trees_in_broken_xml_never_panic (no DOCTYPE payload) | mitigated; no explicit test |
| Names inside CAD files used as paths | T | references are normalised names stored in file_references and matched in SQL only, never opened (src/cadrefs.rs, src/services/references.rs) | src/cadrefs.rs scan_file_reads_stored_files | mitigated |
Webhooks (server-side requests)
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| A webhook reaches internal services or cloud metadata (SSRF) | I, E | only administrators manage webhooks (src/services/webhooks.rs, Actor::require(Role::Admin)); http/https without credentials; IP literals checked when saved and again when sent (TargetPolicy::check_url); every resolved address checked when the connection is made (GuardedResolver), loopback, private, link-local, shared, unique-local, NAT64/6to4 forms refused unless the host is listed; no redirects, no proxy (src/webhooks.rs) | src/webhooks.rs internal_addresses, default_policy_blocks_internal_targets, allow_list_restricts_and_trusts_hosts, every_connection_checks_the_answer_it_uses; tests/webhook_ssrf.rs (DNS rebinding, localhost, stored IP literal) | mitigated |
| Forged webhook deliveries | S | X-TeamCraft-Signature HMAC-SHA256 over the body with a per-webhook secret, shown once, rotatable (audited) | src/webhooks.rs hmac_sha256_matches_rfc_4231, signature_header; tests/webhooks.rs release_events_are_signed_and_delivered | mitigated |
| A webhook leaks restricted data | I | a project webhook sees only its group; deliveries of groups restricted after queueing are withheld | tests/webhooks.rs project_webhooks_see_only_their_revisions | mitigated |
| Webhook secrets read from the database | I | stored in plain text (the HMAC needs them); part of every backup | – | accepted (B6) |
| Tokens in webhook URLs end up in logs | I | delivery errors are recorded without the URL (describe, without_url) | tests/webhooks.rs failed_deliveries_back_off_and_give_up | mitigated |
MCP server and assistant
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Unauthenticated tool calls | S | POST /api/v1/mcp and /api/v1/assistant take bearer PATs only; the web sidebar uses the session with the CSRF guard (src/mcp/mod.rs, src/api/mod.rs) | tests/mcp.rs mcp_protocol_and_tools (401 without token) | mitigated |
| A tool reads beyond the user’s rights | I | every tool calls the same services with the caller’s actor; the assistant reloads the actor before each tool call; page context ids are hints, filtered by the access guards (src/assistant.rs, src/services/assistant.rs) | tests/read_permissions.rs (MCP routes), tests/agent_tools.rs read_file_text_of_a_restricted_group_is_hidden_from_outsiders | mitigated |
| Prompt injection through file text or descriptions makes the model act (A4) | T, E | five write tools only, all drafts or comments (no submit, approve, release); the assistant offers them only when the user ticks Allow drafting and may edit; read_file_text marks its output as untrusted; the HTML report reader skips comments, scripts and hidden elements (hidden, aria-hidden, inline display:none/visibility:hidden – not text styled invisible by a style sheet or colour) (src/htmltext.rs); the system prompt says tool results are data; after reading something an outsider could not read, the four text-writing tools refuse (taint) (src/mcp/tools.rs, src/assistant.rs) | src/mcp/tools.rs tool_definitions_are_complete; tests/agent_tools.rs; tests/assistant_threads.rs a_follow_up_reads_with_the_rights_of_now | partial: the untrusted marker is advice to the model; no taint for external MCP clients (F15) |
| Exfiltration through the rendered answer | I | markdown-lite escapes everything, has no images and no free links (only /i/<item> links); the CSP blocks foreign images (src/assistant.rs render_markdown) | src/assistant.rs markdown_is_rendered_safely | mitigated |
| Data sent to the model provider | I | off unless an administrator configures a key; the dialogs name provider and host; only question and answer text are stored, threads expire (30 days) and are owner-only; audit entries carry counts, not content (src/services/assistant_threads.rs, src/services/assistant.rs) | tests/assistant_threads.rs | accepted (documented in docs/src/guide/assistant.md, SECURITY.md) |
| The LLM client is turned against internal hosts | I | the base URL is administrator configuration; no redirects, key in a sensitive header, errors without URL (src/llm/http.rs) | src/llm/http.rs redirects_are_not_followed_and_the_key_stays_on_the_host, network_errors_do_not_carry_the_url | accepted (B6) |
| Cost or capacity exhausted by one user | D | 12 steps, 16 000 output tokens per call, 800 000 tokens per run, 240 s, four runs at a time; no per-user quota or rate limit | – | open (F16; OPEN_WORK per-user daily budget) |
teamcraft mcp bridge leaks its token | I | token from a file or TEAMCRAFT_TOKEN, never printed; it follows redirects with the default policy (reqwest drops Authorization when host or port change) and accepts http:// servers (src/main.rs) | src/main.rs mcp_endpoint_accepts_base_or_full_url | partial (F17) |
Desktop app ↔ server
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Sign-in code intercepted | S | browser flow to a loopback callback with PKCE S256 and state; one-time code (hashed, 5 minutes, deleted on any exchange attempt); the result is a PAT (sliding, 30 idle days), audited desktop.authorize (src/services/desktop.rs, desktop/src/signin.rs) | tests/web_ui.rs desktop_sign_in_through_the_browser; src/services/desktop.rs pkce_s256, requests_are_validated; desktop/src/signin.rs pkce_matches_rfc_7636, loopback_round_trip | mitigated |
| Token read from the user’s disk | I | the OS keychain (Windows Credential Manager, macOS Keychain, Secret Service), written and read back before it counts; a token file of older versions moves into it at the next start. Without a keychain: the file token in the app’s config folder (mode 0600 on Unix, the profile folder’s permissions on Windows) and a warning at the start and in Settings (desktop/src/settings.rs) | desktop/src/settings.rs settings_and_keychain_token_round_trip, without_a_keychain_the_token_falls_back_to_a_private_file (mode 0600), a_token_file_moves_into_the_keychain, a_keychain_that_loses_the_token_counts_as_none, sign_out_fails_when_the_keychain_keeps_the_token | mitigated (F18) |
| Token sent in clear text | I | rustls with certificate validation; API paths are checked so the token goes only to the configured server (desktop/src/api.rs check_path); an http:// server URL to another computer is accepted but warned about in the connect dialog, on the sign-in step and in Settings (desktop/src/settings.rs insecure_http) | desktop/src/settings.rs insecure_http_spares_https_and_loopback | mitigated (F18): warned, the user decides |
| Update notice abused | T / I | the daily release check (opt-out) asks only api.github.com for the latest release, without redirects and with a 10 s timeout; a version from the answer is used only if it is a plain x.y.z; the notice opens a fixed release page – the app downloads and installs nothing. GitHub sees the address and app version once a day (desktop/src/update.rs) | desktop/src/update.rs newer_versions, tags, at_most_daily | mitigated |
| Server writes outside the working folder | T | every server-supplied name goes through safe_name / normalize_folder (no .., no separators, reserved Windows names) before join_rel (desktop/src/layout.rs local_rel) | desktop/src/layout.rs names, folders_like_the_server, layout_mapping | mitigated |
| Crash report leaks data | I | reports (panics, uncaught UI errors, sessions that ended unexpectedly) are written locally and redacted before writing: home directory → ~, URLs → [url], tc_pat_/tc_sess_ tokens → [token]; no server address, user name, token or file contents; at most 20 reports of 64 KiB. Nothing is uploaded: the user sees the whole text and opens a prefilled GitHub issue themselves (the address is built in Rust from the stored reports, never from text of the web view), copies or deletes it (desktop/src/crash.rs, desktop/ui/js/crash.js) | desktop/src/crash.rs redaction_removes_home_urls_and_tokens, issue_links_fit_and_say_when_they_were_cut, reports_are_written_listed_capped_and_discarded | mitigated; a panic message may still name an item number or file name the user can see in the dialog |
| Old desktop app against a newer server | T | none: no version handshake | – | open (OPEN_WORK desktop ↔ server compatibility) |
Local test server (teamcraft local, desktop with local server)
A test installation on one computer: a private PostgreSQL cluster and the
server, started by teamcraft local or by the desktop app’s Try TeamCraft on
this computer (src/localserver.rs, desktop/src/localserver.rs). Not a
deployment: one user’s computer, no TLS, no backups.
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Another computer reaches the server or the database | S / I | the server binds 127.0.0.1:<port> (overlay wins over TEAMCRAFT_BIND_ADDR); PostgreSQL gets listen_addresses = '127.0.0.1', a free port per start and no Unix socket (local_conf) | src/localserver.rs cluster_settings_listen_on_loopback_only, local_variables_win_over_the_environment, local_configuration_is_valid_and_loopback | mitigated |
| Another account on the same computer uses the database | S / I | initdb with scram-sha-256 and a random 32-character password (OS RNG), written 0600 to <data>/db-password and given to initdb through a temporary 0600 file, never on a command line; the data directory is the user’s own | src/localserver.rs passwords_are_random_and_alphanumeric, private_files_are_owner_only; tests/local_server.rs (mode of db-password) | mitigated; on Windows the profile folder’s permissions apply |
| Another local account opens the web UI first and becomes admin | E | the first-start page is offered only while no account exists and the server listens on loopback (as for every local start); the desktop app opens the browser right after the server is ready | tests/web_ui.rs first_start_creates_the_administrator | accepted: on a shared computer, a local user who is faster can create the administrator |
| The setup page redirects elsewhere | T | /setup continues at the login’s next, through util::safe_redirect_target (local paths only) | src/web/auth_pages.rs setup_continues_at_next; tests/web_ui.rs first_start_creates_the_administrator | mitigated |
| A second instance stops the database of the first | D | a port file in the data directory; a second teamcraft local whose port file answers /readyz refuses to start | tests/local_server.rs local_server_runs_stops_and_restarts | mitigated |
| The database keeps running after the app ends | D | --exit-with-stdin: the app holds a pipe to the server’s stdin; its end (also a crash or kill of the app) shuts the server down and pg_ctl stop runs; a cluster left over by a killed server is stopped at the next start | tests/local_server.rs; release job local-server (smoke test of each installer) | mitigated; a killed server leaves PostgreSQL until the next start |
| Tampered PostgreSQL programs in the installer | T | the release job takes theseus-rs builds over GitHub and checks the release’s .sha256; the installers are not code-signed | release job local-server | partial: the checksum comes from the same release (integrity, not authenticity); no code signing yet |
| CAD conversions run in the server process | E | the bundled binary is called teamcraft-server; preview::worker_exe accepts that name, so conversions keep their isolated child process with limits | src/preview.rs worker_names | mitigated |
Backup and restore
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Backup taken by an unauthorised user | I | command line only (teamcraft backup, restore); no web or API route (src/main.rs) | – | mitigated (B6) |
| A corrupt or incomplete backup | T | every object is re-hashed while copied, every object the database refers to must exist; a failed backup removes what it wrote; the manifest is renamed into place last (src/backup.rs) | tests/backup_proofing.rs, tests/backup_errors.rs | mitigated |
| Restore of a tampered or broken backup | T | manifest, dump checksum, object count and every object hash are checked before anything is written; a non-empty database needs --force; pg_restore runs in one transaction (src/backup.rs verify_backup, ensure_database_empty) | tests/backup_errors.rs, tests/backup_proofing.rs a_failing_forced_restore_leaves_database_and_vault_untouched, tests/backup_restore.rs | mitigated; the manifest is not signed (whoever can write the backup can rewrite both) |
| The backup leaks credentials | I | the dump holds password, session and token hashes and the webhook secrets; files are written with the process umask (no restrictive mode), not encrypted; the database password goes only through PGPASSWORD (src/backup.rs) | src/backup.rs commands_keep_the_password_out_of_the_arguments | partial (F19) |
Branding uploads
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| An administrator’s SVG logo runs script in the origin of other users | E, I | the type is decided from the content; SVG with scripts, event handlers, animations, foreignObject, entity declarations or href/src/url(…) references other than #id and data:image/… is refused; logos are shown only through <img> (no script runs there) and served with Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox and nosniff; 512 KiB and 4096 px caps, bitmaps whose size cannot be read (PNG, JPEG, WebP) are refused (src/services/branding.rs validate, src/web/branding.rs image) | src/services/branding.rs unit tests (refuses_active_svg_content, refuses_external_references_in_an_inkscape_svg, huge_webp_and_bitmaps_without_a_readable_size_are_refused, …); tests/branding.rs | mitigated; the SVG check is a deny list, the sandbox and <img> are the real barrier |
| A non-administrator replaces the logos | E | Actor::require(Admin) in branding::set and reset; every change audited (branding.set, branding.reset) | tests/branding.rs bad_logos_and_other_users_are_refused | mitigated |
| The public logo route leaks data | I | GET /branding/{wide,square} serves only the logo images (no sign-in, so the sign-in page can show them) | tests/branding.rs | accepted: logos are public by design |
Metrics endpoint
| Threat | STRIDE | Mitigation | Tests | Status |
|---|---|---|---|---|
| Metrics read by outsiders | I | off unless TEAMCRAFT_METRICS_TOKEN (at least 16 characters) is set; bearer token compared without early exit; no label comes from a request (no paths, no users) (src/metrics.rs, src/lib.rs metrics_endpoint) | src/metrics.rs token_comparison; tests/metrics.rs | mitigated; a reverse proxy should not expose /metrics publicly |
Open findings
Each open one has an entry in docs/OPEN_WORK.md (section 2, Security);
fixed ones are struck through and name the change.
| Finding | Area | |
|---|---|---|
| F1 | fix/security-web-auth): per username and client address, 429; the own password change (web form and REST) counts on the same per-username counter; the desktop token exchange is not throttled (see the table above) | authentication |
| F2 | feat/scoped-tokens); the desktop token keeps full rights (expires after 30 days without use, sliding) | tokens |
| F3 | fix/security-web-auth) | audit |
| F4 | Origin and Referer passesX-Forwarded-Host is trusted as sent (documented) | web |
| F5 | web | |
| F6 | ID token signatures are not verified, yet the issuer may be plain http:// | OIDC |
| F7 | OIDC | |
| F8 | Downloads do not re-check the blob hash (tampering on disk shows only in vault check or a backup) | vault |
| F9 | services::quotas) | uploads |
| F10 | Served Content-Type is the client’s (the download headers are now pinned by a test) | downloads |
| F11 | No memory limit for the preview and text children on Windows; 3MF entries up to 2 GiB | parsers |
| F12 | parsers | |
| F13 | XLSX/ODS are unpacked in the server process without a cap on the unpacked size | parsers |
| F14 | The Fuzzing workflow never built a target (musl default; fixed in fix/fuzz-build-target: builds for the GNU host). simmesh, pdftext, resultscan not fuzzed (the no-panic lints and the 3DS fix are in main, #106/#108) | parsers |
| F15 | Prompt injection: the untrusted marker is advisory; no taint for external MCP clients | assistant / MCP |
| F16 | No per-user quota or rate limit for the assistant and MCP | assistant / MCP |
| F17 | The teamcraft mcp bridge follows redirects and accepts http:// | MCP |
| F18 | http:// server URLs acceptedga/desktop-security): OS keychain with a warned file fallback, a warning for http:// servers | desktop |
| F19 | Backups are written with the process umask, unencrypted, with an unsigned manifest | backup |
Fixed while writing this model: a password change kept the user’s other
sessions alive (users::change_own_password, test
changing_the_own_password_ends_the_other_sessions).
Accessibility
What is checked about the web UI against WCAG 2.2 level AA, how, and what is
still open. Contrast is a test of its own (domain::skins_meet_contrast:
every skin, text and controls); this page covers the structure of the pages
and the keyboard.
Checked by a test on every page
templates::tests::every_rendered_page_is_accessible runs over every page
that every_template_renders renders – every template in the states that
test covers – and fails on (src/templates/accessibility.rs):
| Rule | WCAG |
|---|---|
every input (except hidden, submit, button, reset), select and textarea has a label: <label for>, an enclosing <label>, aria-label or aria-labelledby | 1.3.1, 4.1.2 |
every button, link and input type=submit/button/reset/image has an accessible name: text, aria-label, aria-labelledby, title, or an image with alt inside | 2.4.4, 4.1.2 |
every img has an alt (empty for decoration) | 1.1.1 |
no tabindex above 0 | 2.4.3 |
<html lang> on a full page | 3.1.1 |
headings do not skip a level on the way down (h2 → h4) | 1.3.1 |
every <th> has a scope | 1.3.1 |
every <th> has a name: text (a visually hidden “Actions” for action columns), aria-label or title | 1.3.1 |
a full page with a <main> starts with a skip link: its first link points to the id of <main> | 2.4.1 |
A rule’s own test (the_checks_find_what_they_promise) feeds it a page with
one violation of each kind and a page without any. When the test was added
it found: <th> without scope in 21 templates (now scope="col", all of
them in <thead>), two headings that skipped a level (the impact groups on
the revision page and the assistant’s answer – each got a visually hidden
<h2>), and two file inputs without a label (now aria-label). None of the
fixes changes how a page looks.
The two later rules found 17 empty column headers (16 action columns, now a visually hidden “Actions”, and the corner of the inertia tensor on the mass page, now “Axis”) and the missing skip link: every page now starts with Skip to the main content, visible only while it has the focus.
Keyboard: the main flows
Checked by hand on 2026-11-06 in Chromium (the browser pane of the desktop
app) against a throwaway database, with the keyboard only – Tab, Shift+Tab,
Enter, the arrow keys and Escape. The focus was recorded at every step (focusin: element, :focus-visible, outline or
shadow).
| Flow | Result |
|---|---|
| Sign in | The username has autofocus; Tab to the password, Enter signs in. |
| Dashboard and header | Tab order: brand, navigation, search, notifications, account menu, then the page. Every stop has a visible focus (2 px outline, form fields 1 px outline plus a border in the focus colour). Landmarks: header, nav “Main”, the search form, main, footer. |
| Menus (Admin, account) | Enter opens, Tab and ↑/↓ move inside, Escape closes and returns the focus to the menu button. |
| New item | Every field labelled, order as on screen; Enter in a text field submits; a missing required field moves the focus to it (browser validation). Enter on a <select> does not submit – the browser’s behaviour, not the page’s. |
| Check-in | “Check in…” (aria-expanded) opens the panel and moves the focus to its first field; Escape closes it and returns the focus to the button (fixed: Escape did nothing before, see below); Enter checks in. |
| BOM | Revision rule, levels and group are labelled <select>s; ↑/↓ only change the choice, Enter or leaving the field applies it (WCAG 3.2.2, data-autosubmit); collapse/expand, export menu and the lines are reachable. |
| New change request | Every field labelled, order as on screen, Enter raises the request. |
Every message after a form (.flash) has role="status", an error
role="alert", so screen readers announce it.
Fixed with this check: the panels opened by a data-toggle button (check-in, master data, lifecycle rules, file
versions, the edit forms of
changes, requests and project groups, acknowledging impacts) did not close
with Escape. Now Escape inside an open panel closes
it, sets aria-expanded="false" and returns the focus to the button that
opened it (static/app.js); a menu inside a panel closes first.
Open
Each of these is an entry in docs/OPEN_WORK.md (Clients and packaging):
- HTML built by JavaScript – the assistant sidebar, the BOM tree
controls, the 3D viewer, the upload progress – is not covered by the
template test, and the desktop app (
desktop/ui) not at all. - No screen reader run. The checks above are structural; nobody has used the main flows with NVDA, JAWS or VoiceOver yet.
- Focus of form fields is a 1 px outline plus a coloured border: enough for 2.4.7 (AA), below the 2 px of 2.4.13 (AAA).
Release checklist
This is the list a maintainer follows to cut a release. The rules behind it are
in CONTRIBUTING.md
(Releases and versions) and in the release rules of
CLAUDE.md. The
workflow is .github/workflows/release.yml. Nothing is published before the
maintainer pushes the tag.
Version numbers follow Semantic Versioning. While the
version is 0.x, a release may contain breaking changes. From 1.0 on, the
rules of the support policy apply.
1. Before the release candidate
-
mainis green: the CI workflow (ci.yml, fmt, clippy, tests on PostgreSQL 16 and 18, macOS and Windows builds),docs.ymlandrelease.yml(build only; it runs nightly, so start it with Run workflow onmainwhen the commit is newer than the last nightly build) all pass on the commit to release. - The latest run of the scale check (
scale.yml, manual or weekly) passed on this commit or a later one, or a failure is known and written down in the release notes. -
CHANGELOG.mdhas every change since the last tag under Unreleased, grouped as Added, Changed, Fixed and Security. Each entry is taken from a merged change; nothing is added from memory. - The threat model (
docs/src/dev/threat-model.md) anddocs/OPEN_WORK.mdshow no open finding that the release claims to fix. - The work-in-progress and no-warranty notices are still in the README,
docs/src/index.md, the web footer and the desktop About dialog, unless the maintainer decides that this release is the first stable one.
2. Version bump and release candidate
The version must be the same in three files. The release workflow refuses a mismatch.
-
Cargo.toml(version) -
desktop/Cargo.toml(version) -
desktop/tauri.conf.json(version) - Also
docs/theme/doxygen.js(version, shown in the title and footer of the book); the release workflow does not check this one. - Run
cargo checkandcargo check --manifest-path desktop/Cargo.tomlso that bothCargo.lockfiles follow, then commit the bump onmainthrough a pull request. - Move the Unreleased entries of
CHANGELOG.mdunder the new version heading, with the date, and leave an empty Unreleased section. - Tag a release candidate:
git tag -a v1.0.0-rc.1 -m "TeamCraft 1.0.0-rc.1"andgit push origin v1.0.0-rc.1. A suffixed tag is a pre-release. It gets no:latestimage tag and no:<major>.<minor>update.
3. Soak time
- Let the release candidate run for the soak period before the final tag.
The project’s proposed minimum is 7 days, with no new open bug
rated as blocking. A fix during the soak needs a new candidate (
-rc.2) and the soak starts again. - Test the candidate: the install from the container image with
deploy/install.sh, an upgrade from the previous release with data, and one desktop installer per platform. With one installer with local server, click Try TeamCraft on this computer on a computer without PostgreSQL: the browser offers to create the administrator, the app signs in, and quitting the app stops the server. - Note what was found in the soak in the changelog or in the release notes.
4. Final tag
- Tag the same commit as the last candidate, without a suffix:
git tag -a v1.0.0 -m "TeamCraft 1.0.0"andgit push origin v1.0.0. Do not move a tag that was pushed; use a new version instead. - Watch
release.ymlfor the tag. It builds everything again from the tag, runs the install smoke test against the new image, and creates the GitHub Release. A-rcor0.xversion is marked as a pre-release.
5. Verify the assets
- The GitHub Release lists the server archives (Linux x86_64 and ARM64,
Windows x64), the desktop installers (AppImage,
.deb,.rpm, Flatpak,.dmg,.msi, NSIS setup and portable.zip), the three installers with local server (TeamCraft-with-local-server_…: Windows x64 setup, macOS.dmg, Linux x86_64 AppImage) andSHA256SUMS.txt. - Download
SHA256SUMS.txtand at least one archive and check the hash withsha256sum -c SHA256SUMS.txt --ignore-missing. A mismatch means the release is withdrawn, not patched. - The release notes are the generated notes, with the changelog section added when it is more complete.
6. Container image tags
The image ghcr.io/rudalabs/teamcraft is published by CI only.
- The workflow sets
:<version>,:<major>.<minor>and, for a version without a suffix,:latest(also for0.x). Forv1.0.0that is1.0.0,1.0andlatest. There is no:<major>tag. - For a suffixed candidate such as
v1.0.0-rc.1,:latestmust not move. Check the tags on the registry after the run and report any mismatch. - The
:edgetag comes frommain; it is not a release. - Pull the image by its release tag and check
/readyzon a test instance. Do not build or push an image by hand.
7. Announcement
- Publish the GitHub Release (it is created by the workflow; check that it is not a draft).
- Update the install examples that name a version (
deploy/install.shhelp text and the Docker guide) through a pull request, if they still name an older release. - Post the announcement with the link to the release and the changelog section. Say what changed for operators first: configuration, migrations and the upgrade path.
- Report security fixes in the release through the advisory that was prepared under SECURITY.md, and publish it on the release day.
What is not part of a release
- No package registries. Nothing is published to crates.io, npm or a similar registry.
- No code signing yet. Installers are not signed (see Releases and versions in CONTRIBUTING.md); the release notes say so.
- No backport branch unless the support policy requires one.
Architecture decision records
Decisions that keep coming back in reviews, written down once: the context,
the decision, its consequences, the alternatives that were turned down, and
where the code and the tests that hold the decision live. Each record only
states what the repository documents (code, tests, research notes, commit
messages, docs/OPEN_WORK.md); where the reason for a choice is not written
down anywhere, the record says so instead of guessing.
| No. | Decision | Status |
|---|---|---|
| 0001 | Read access: a deny list on the actor, “not found” for what is hidden, enforced in the services | accepted (#52, #55) |
| 0002 | Notifications and webhooks are an outbox written in the event’s transaction | accepted |
| 0003 | Files live in a content-addressed vault on the local file system | accepted |
| 0004 | No Node.js and no bundler in the product | accepted |
A new record gets the next number, the same headings and a row here. A decision that is replaced keeps its record with the status superseded by NNNN.
ADR 0001 – Read access: deny list on the actor, “not found”, enforced in the services
Status: accepted – introduced in #52 (Read access control: restricted project groups), review fixes in #55. Research and the full reasoning: Read permissions for project groups.
Context
Until #52 every signed-in user could read every item, file and change; project groups only limited editing. Some groups have to be invisible to non-members, without making every object carry its own access list. The same data reaches users through the web UI, the REST API, the MCP server, the assistant, notifications, e-mail, Mattermost and webhooks.
Decision
- The project group is the boundary. A group is
internal(everybody reads, members edit) orrestricted(only members – of any role – and administrators read). Columnprojects.visibility, migration0024_restricted_projects.sql. - A deny list on the actor. The signed-in
Actor(src/auth.rs) carrieshidden_projects: the restricted groups it may not read, empty for administrators. It is never serialized. - Hidden looks like missing. A hidden item, revision, file, change or group answers exactly like one that does not exist (404 / “not found”), like a private GitHub repository.
- Links across the boundary stay visible as placeholders when they go out of a visible object (a BOM line, a reference: “restricted item”, quantity kept) and are dropped and only counted when they come in (where-used, referrers: “+N in groups you cannot see”).
- One place writes the SQL guard.
src/services/access.rsbuilds every guard (item_visible,project_visible,change_visible, …) that bindshidden_projects; read services take&Actorand use them. The*_uncheckedgetters (no read check) are for background and system code. - Side channels – notifications, e-mail, Mattermost, webhooks, the assistant, the MCP server – apply the same rule per recipient.
Consequences
- Every read service needs the actor; a forgotten guard is a leak, so the rule is held by tests rather than by review alone (below).
- Lists that leave entries out say how many (
X-Hidden-Countin the REST API, “+N …” in the UI). - Copying out of a restricted group declassifies content, so Save As across
the boundary is limited to managers of the source group (
items::ensure_may_take_out, used byservices::save_as). - A need-to-know policy (
project_policies.admin_read, #151 step 1) fills the hidden groups of administrators too (access::hidden_of_user): the guards stay the only place that decides. Administrators keep maintaining such a group (projects::get_for_management,require_manager) without reading it; elevations are rows inproject_elevations, never deleted. - Interface switches (
project_policies.api_tokens,mcp,assistant,webhooks,packages, #151 step 3) also feed the hidden groups, per channel:project_policy::narrowhides the denied groups from the actor of that channel (Actor::without_groups), webhook outbox inserts check the switch in SQL. The read log (access_log, step 4) is a separate table written byservices::access_log;tests/interface_switches.rsreads the sources and fails when an interface lacks its check. - Conditions on membership (#151 step 2) feed the hidden groups as well, for
everybody including administrators: the claims of the last single sign-on
and the last sign-in’s age are part of
access::hidden_of_user(membership_conditions::unmet_sql), so notification recipients and tokens are covered; the client network and the web session’s age depend on the request and are applied once by theWebUser/ApiCallerextractors (membership_conditions::apply,Actor::without_groups) and again at each assistant step. A member hidden this way is no member for that request (Actor::projectsdrops the group). - There are no per-object ACLs and no per-membership access levels; a read-only member is a member with the global viewer role.
Alternatives turned down
From the research note:
- PostgreSQL row-level security – RLS can only hide rows, while TeamCraft needs placeholders and counts; and per-request session state on a shared connection pool is risky.
- Per-object ACLs and field-level rules (as in large PDM systems) – hard to audit, not needed for “not everybody may see every repository”.
- 403 for hidden objects – would confirm that the object exists.
- Onshape-style view / copy / export levels and token scopes are listed as possible follow-ups, not adopted.
Where it is held
| What | Where |
|---|---|
| Deny list on the actor | Actor::hidden_projects, Actor::can_see_project in src/auth.rs |
| The only SQL guard writer | src/services/access.rs |
Handlers never call *_unchecked; nobody but access writes a guard | tests/no_unchecked_in_handlers.rs |
| Every web route, REST route and MCP tool leaks nothing to an outsider | tests/read_permissions.rs (routes read from the router sources) |
| Handlers never touch SQL themselves | proposed in #96 (tests/architecture_guards.rs) |
ADR 0002 – Notifications and webhooks are an outbox written in the event’s transaction
Status: accepted – notifications with e-mail and Mattermost delivery (migration 0009_notifications.sql), event
webhooks (migration
0022_webhooks.sql, commit Event webhooks: signed JSON for releases,
changes, requests and simulations).
Context
Events such as a submitted change, a release or a flagged dependent have to reach people (in-app, e-mail, Mattermost) and other systems (webhooks). A mail server or a webhook receiver can be slow, down or misconfigured; the event itself must neither wait for them nor be lost or announced twice.
Decision
- Rows in the same transaction as the event. The services create
notifications (
services::notifications) and webhook deliveries (services::webhooks::emit, one row per matching webhook inwebhook_deliveries) inside the transaction that changes the data. A notification or delivery exists exactly when the event was committed. - Background senders read the rows.
src/delivery.rs: thenotificationstable is the outbox for e-mail and Mattermost (email_state/chat_state=pending); a task sends the pending ones every few seconds, retries failures and marks themfailedafterMAX_ATTEMPTS(5); without configuration they are markedskipped. One instance delivers (no row locking). A run serves a few recipients at the same time (PARALLEL_RECIPIENTS, small because mail services limit the connections of a sender); the notifications of one recipient keep their order.src/webhooks.rs: rows are claimed withFOR UPDATE SKIP LOCKEDand a lease, signed with HMAC-SHA256, retried with exponential backoff and jitter up toMAX_ATTEMPTS(8); redirects are never followed and response bodies are not stored; finished deliveries are purged.
- The in-app view (bell, page, API, desktop) reads the same notification rows.
Consequences
- A failing mail server or webhook receiver never fails or slows down the event; it is retried and finally marked failed.
- A rolled-back event leaves no notification and no delivery behind – proven
by injected failures in
tests/atomic_processes.rs(change order release: a failing notification insert or webhook delivery insert leaves nothing). - Delivery is at-least-once for webhooks (the
X-TeamCraft-Deliveryid is the same for every retry, so receivers can de-duplicate). - E-mail and Mattermost delivery assume one sending instance; several servers would need row locking there (the webhook sender already has it).
Alternatives turned down
Not documented in the repository: no research note, commit message or
OPEN_WORK.md entry records which alternatives (sending synchronously in the
request, a message broker, a separate queue service) were considered.
Where it is held
| What | Where |
|---|---|
| Notifications created in the event’s transaction | src/services/notifications.rs (module documentation) |
| E-mail / Mattermost sender | src/delivery.rs |
| Webhook outbox writer, sender, SSRF guard | src/services/webhooks.rs (emit), src/webhooks.rs |
| Deliveries, signatures, retries, SSRF | tests/webhooks.rs |
| No notification or delivery survives a rolled-back release | tests/atomic_processes.rs |
| Writing webhook rows needs no audit entry of its own (the event is audited) | NOT_AUDITED in tests/architecture_guards.rs, proposed in #96 |
ADR 0003 – Files live in a content-addressed vault on the local file system
Status: accepted – part of the first version; backup checks added in #93.
Context
Revisions carry files – CAD models, drawings, exports, simulation results – that are large, often identical across revisions (a revision copies the files it does not change) and must never change once a version is checked in. The database needs to name a file version; the bytes need somewhere to live.
Decision
- Named by content.
src/vault.rsstores every blob underobjects/ab/cd/<sha256>, named by the SHA-256 of its content. The database stores the hash (files.sha256,file_versions.sha256,change_request_attachments.sha256). - Write once. An upload goes to
tmp/on the same file system and is renamed into place when complete (atomic); a blob is never modified after it was written. Identical files are stored once, however many revisions reference them. - Chunked uploads are staged in
uploads/<session>until they are finished (services::uploads). - Derived data is separate: the preview cache (
previews/) is not part of the vault’s content and is rebuilt when missing. - Backups check the vault. Since #93,
teamcraft backuphashes every object against its name while copying it and requires every content the database refers to (services::files::all_hashes, the same listteamcraft vault checkuses); a corrupt or missing object refuses the backup, and a failed backup removes what it wrote.teamcraft restoreverifies every object before anything is restored and copies the vault only after the database.
Consequences
- Integrity is checkable at any time:
teamcraft vault check [--verify-hashes]compares the referenced hashes with the vault. - De-duplication is free; downloads can carry the hash as a strong ETag and
resume half-way (
src/httprange.rs). - The code has no function that deletes an object: content of deleted items or failed check-ins stays in the vault as unreferenced blobs. There is no garbage collection.
- The vault is a local directory; several servers would need a shared file system.
Alternatives turned down
Not documented in the repository: no research note, commit message or
OPEN_WORK.md entry records whether storing files in the database (large
objects / bytea) or in an object store (S3-compatible) was considered.
Where it is held
| What | Where |
|---|---|
| Layout, atomic write, verify | src/vault.rs |
| Consistency check | teamcraft vault check in src/main.rs |
| Backup and restore with object checks | src/backup.rs |
| Corrupt or missing objects refuse the backup; a failing restore leaves the vault untouched | tests/backup_proofing.rs, tests/backup_errors.rs |
| Backup and restore end to end | tests/backup_restore.rs |
| Vault and audit trail | tests/audit_and_vault.rs, tests/domain_and_vault.rs |
ADR 0004 – No Node.js and no bundler in the product
Status: accepted – a hard rule of the project (CLAUDE.md, Pure Rust),
restated for the shared UI files in
Sharing UI code between the web UI and the desktop app
and shared/README.md.
Context
TeamCraft is a single binary (axum server, REST API, CLI) plus a Tauri desktop app. Both have user interfaces in HTML, CSS and JavaScript, and both need a 3D viewer and shared styles.
Decision
- Plain files, no build step. The JavaScript of the web UI (
static/), the desktop app (desktop/ui/) and the shared files (shared/) is written as plain scripts and ES modules: no bundler, no transpiler, no Node.js packages. - Rust embeds and serves them.
build.rswalksshared/and writes the asset table;src/assets.rsembeds every file and serves it with cache busting (?v=<assets version>, versioned script paths).desktop/build.rsmirrorsshared/into the desktop app. - 3D conversion stays in Rust (
src/mesh.rs); no downloaded engines. - Node.js only checks, never ships. The CI job
uiin.github/workflows/desktop.ymlrunsnode --checkover the scripts (syntax only); the product has no Node.js dependency.
Consequences
- Everything builds with
cargoalone (CONTRIBUTING.md: “No Node.js, no downloaded engines and no C/C++ CAD kernels”); the release builds no JavaScript. - No
innerHTMLwith data, noeval, no inline scripts or styles – both Content Security Policies (src/lib.rs,desktop/tauri.conf.json) enforce it, and plain modules fitscript-src 'self'. - Logic that both user interfaces need (labels, dates, markdown-lite) exists in Rust and in JavaScript; the research note plans shared test vectors to keep the two in step (not in place yet).
- JavaScript libraries from npm cannot be used as they are; what the UI needs is written in the repository.
Alternatives turned down
The reason for the rule itself is not documented beyond the rule: CLAUDE.md
and CONTRIBUTING.md state it (“everything builds with cargo alone”)
without a rationale, and the
research note adopts “no bundler, no transpiler, no Node.js packages” for
shared/ as a given. No alternative (a bundled front-end build, a framework)
is recorded as evaluated.
Where it is held
| What | Where |
|---|---|
| The rule | CLAUDE.md (Pure Rust), CONTRIBUTING.md, shared/README.md |
| Embedding and serving | build.rs, src/assets.rs |
| Every shared file served and mirrored, with its licence header | assets::tests::shared_files_are_served, assets::tests::shared_files_carry_spdx_header, shared_ui_files_are_mirrored (desktop) |
| Scripts parse | job ui in .github/workflows/desktop.yml (node --check) |
Research notes
Findings of investigations behind design decisions: how other systems solve a problem, what is possible for an open-source project (licences, documented formats), the decision and the sources. Every investigation gets its own page here, written and pushed as soon as the findings are in – before the implementation starts.
| Topic | Decision | Date |
|---|---|---|
| One download to try TeamCraft on a laptop | bundle PostgreSQL’s programs (theseus-rs builds, PostgreSQL Licence, newest 18.x, checksum-checked) with the desktop app and the server as teamcraft-server; teamcraft local runs a private cluster (scram, random password, 127.0.0.1 only, free port, no socket) and the server, and stops both when the app’s stdin pipe closes; separate with local server installers for Windows x64, macOS Apple silicon and Linux x86_64; no new crate, no SQLite, no postgresql_embedded | 2026-11-14 |
| Deprovisioning through the identity provider (SCIM 2.0) | not now: the target audience’s IdPs (university SAML/OIDC proxies, Keycloak) send no SCIM; the idle-SSO report, idle token lifetimes and OIDC back-channel logout cover what they can signal (#173 step 9). Later: active: false (boolean or string, PATCH or PUT) → Offboard and deactivate with a configured default, soft delete only | 2026-11-12 |
| 3D preview of CATIA V5 products | put together from the parts like NX, SOLIDWORKS and Inventor assemblies: instances, part numbers and placements (X, Y, Z axes and origin in mm) read from the product’s CATProdCont object stream; no file names are stored, so components are found by the UUID of their CATProdCont (CATIA files of the own revision and name matches) or by <part number>.CATPart/.CATProduct; hidden components later | 2026-11-12 |
| 3D preview of Creo assemblies | put together from the parts like NX, SOLIDWORKS, Inventor and CATIA: section NeuAsmSld lists the direct components (part or sub-assembly, model name, axes e1–e3 and origin in the parent’s frame); files found as <name>.prt / <name>.asm; lengths converted to mm from UnitSystemDef (parts too); decoded on unlicensed samples, tested with synthetic assemblies around NIST ctc-01 | 2026-11-12 |
| 3D preview of Creo parts | Creo stores no display tessellation: the exact B-rep (VisibGeom, a self-describing tagged format with compressed numbers) is read instead – analytic faces with contours, every edge sampled in both faces’ parameter spaces; faces are ear-clipped in the parameter plane, welded and refined with PN triangles; spline, fillet and ruled faces included; assemblies in their own page | 2026-11-08 |
| 3D preview of CATIA V5 parts | read the visualisation (CGR) mesh CATIA stores in every part (nested V5_CFV2 document CATCGRCont, stream SceneGraph/MainDataStream, uncompressed; decoded from sample files, verified against the NIST STEP export) and refine it along its exact normals like NX parts; thumbnails rendered from it; products from their parts (CATIA V5 products), the exact B-rep never | 2026-11-08 |
| 3D preview of Inventor files | read the display tessellation Inventor stores in every part (PmGraphicsSegment: one indexed triangle list per face with exact per-vertex normals, cm), weld it on its exact coordinates and refine it with PN triangles like NX and SOLIDWORKS; assemblies from UFRxDoc (files, visibility) and AmRxSegment (placements, checked against the stored bounding boxes of 243 sample assemblies), re-posed flexible sub-assemblies and suppressed components (addendum 2026-11-11), thumbnails rendered from the 3D preview; design views, weld beads and the ShapeManager geometry later | 2026-11-08 |
| Instance counts of Inventor assemblies | read the occurrence count of each referenced file from the first file descriptor table in UFRxDoc (checked against Inventor’s API: 190 of 190 files); suppressed occurrences left out via the occurrence records in the same stream (flag 07, linked by name), not guessed when they do not add up | 2026-11-11 |
| Crash reports from the desktop app | panic hook, session marker and uncaught UI errors written locally (redacted); at the next start the user sees the report and can open a prefilled GitHub issue, copy or discard it – no telemetry service, nothing sent unseen | 2026-11-10 |
| Mass properties from exported meshes | Eberly’s divergence-theorem integrals per triangle, computed in the isolated preview process and cached per content hash; edge check (open, non-manifold, misoriented) and fix of fully inverted meshes; admin material library plus density override per revision; per-file length unit and instance count; measured mass/CG/inertia wins and covers the sub-tree; parts exported in the common vehicle frame (placements on BOM lines later); roll-up by parallel axis under the read rights | 2026-11-09 |
| 3D preview of Fusion designs | read the display scene Fusion stores in every .f3d (OGS world + stream_mesh_*, ZIP entries in Zstandard, inflated with ruzstd): the triangle meshes of the body faces, welded and refined along their exact normals like NX parts, centimetres to millimetres; thumbnails rendered from it, the stored PNG as fallback; the ShapeManager B-rep stays unread; instance transforms and hidden bodies wait for a sample assembly | 2026-11-08 |
| 3D preview of SketchUp models | SketchUp 2021+ (ZIP + tag–length–value model.dat) with an own reader: faces triangulated with earcut, nested components placed (p' = s·(R p + t)), hidden faces, instances and layers left out, thumbnails rendered from the mesh; the 40 OpenSKP test files (MIT) as fixtures; classic files (SketchUp 3–2020, MFC object stream) through the unchanged Rust port of OpenSKP (MIT) in vendor/openskp; no colours or textures yet | 2026-11-08 |
| 3D preview of Rhino models | read the meshes a .3dm stores (mesh objects, B-rep render meshes, extrusion mesh caches), subdivide SubD control nets (Catmull-Clark with creases), place block instances; openNURBS sources read only; B-reps without stored meshes (truck) later | 2026-11-08 |
| 3D preview of SOLIDWORKS files | read the display tessellation SOLIDWORKS stores in every part (Contents/DisplayLists: triangle strips with exact per-vertex normals), weld it and refine it with PN triangles like NX; assemblies from the placements in COMPINSTANCETREE (checked against the NIST assembly’s NX placements), thumbnails rendered from the 3D preview; the Parasolid geometry (readable) and pre-2015 OLE files later | 2026-11-07 |
| Mutation testing with cargo-mutants | cargo-mutants (MIT, a CI tool, no new crate): --lib tests over the pure, unit-tested modules (.cargo/mutants.toml), --in-diff on pull requests report-only at first, weekly sharded full run; exclusions in the config with a reason, not #[mutants::skip]; missed mutants in touched code get a test that checks behaviour | 2026-11-07 |
| 3D preview of NX parts | read the JT 9 tessellation NX stores in every part (/Root/FastLoad/JT, topologically compressed) and refine it along its exact normals (PN triangles, creases from both normals); assemblies from their structure stream (placements verified against a JT export), thumbnails rendered from the 3D preview; stand-alone JT 10 and the Parasolid block later or never | 2026-11-07 |
| 3D preview of stand-alone JT files | read JT 10 files (format id jt) with the JT 9 topology coder plus the new Int32 codec, LZMA (lzma-rs, MIT) and the scene graph (instances, row-vector transforms, units, hidden nodes); hashes checked; verified on the NIST box assembly (36 placements equal to NX, sizes equal to NIST STEP); JT 9 stays with the NX reader; shattered files, PMI and B-rep segments not now | 2026-11-08 |
| Text from HTML reports | own bounded tokenizer (no new crate, encoding_rs for charsets) in the same isolated text-extract child as PDF; table rows as lines with | separators, scripts, styles, comments, embedded images and hidden elements skipped, nothing fetched; templates on HTML run on the extracted text; plain text keeps its decimals | 2026-11-07 |
| Text from PDF reports | pdf_oxide (MIT/Apache-2.0) in an isolated teamcraft text-extract child process with size, page, time and memory limits; the only candidate that survived truncated and damaged files without panics; no OCR | 2026-11-05 |
| One provider interface for the assistant | one provider-neutral API layer (src/llm/): Anthropic Messages and OpenAI-compatible Chat Completions now, stateless Responses later; the key stays on the server (desktop Ask… and ACP unchanged); base URL set by the operator, internal hosts allowed, no redirects; next: budgets, progress streaming, threads, context quick actions | 2026-11-05 |
| Sharing UI code between web and desktop | top-level shared/ with tokens, one component CSS, plain ES modules (formatters, states, markdown-lite, widgets, 3D viewer), fonts and icons; web keeps server templates, desktop keeps rendering from JSON; one markup contract, Rust and JS rules pinned to shared test vectors | 2026-11-05 |
| Supersede, replace and alternate parts | global two-way alternates per item, supersession with one successor, replace in assemblies from the where-used page (in-work parents only, released ones listed for a new revision); local substitutes per BOM line later | 2026-11-05 |
| Jira connector for change requests and change orders | one-way TeamCraft → Jira first (ECR/ECO as issues, status mapping, remote link back, outbox like webhooks, explicit project-group scope per connection); Jira webhooks later, never approvals | 2026-11-05 |
| Previews of native CAD files (Inventor, Solid Edge) | embedded preview image: implement · 3D from the native file on the server: skip (licensing) · 3D through the user’s own CAD installation in the desktop app: implement | 2026-11-04 |
| Importing existing CAD folder trees | scan with a duplicate report first, one item per model with its drawings and exports, check-in at the end, BOM from references optional, tutorial in the desktop app | 2026-11-04 |
| Popular features of Teamcenter | implement favorites, saved searches, Save As and BOM comparison; numbering schemes, custom attributes, transmittals and redlining stay planned | 2026-11-04 |
| Openly licensed CAD files for end-to-end tests | NIST test models (no restrictions): parts in Inventor, NX, SolidWorks, Creo, CATIA V5, STEP, NX and SolidWorks assemblies; Solid Edge: none found | 2026-11-04 |
| Read permissions for project groups | restricted project groups: members and admins read, everybody else gets 404; placeholders for links across the boundary; enforcement in the services, no row-level security | 2026-11-05 |
| As-built records | item type as_built = one physical unit: build record (design revision built to, serial, date), as-built BOM copied from the design and compared with it, deviations with disposition (open / use as is / rework / repair / scrap); no submit with an open deviation | 2026-11-06 |
| Fluent, STAR-CCM+ and Simcenter 3D files | recognise by extension plus bounded head checks (NX SPLMSSTR for .sim/.fem/.afm, HDF5/gzip signatures, Nastran markers in .dat); small text summaries from head and tail only (.trn, .out, .f06, journals, macros); never parse .op2, .h5, .dat.h5, .sim, .simh; one simulation item per analysis with the master part as pinned basis; values from run-written exchange files, tail-reading templates and reports (#141) | 2026-11-07 |
One download to try TeamCraft on a laptop
Question: how can somebody who is not an administrator – a student, an engineer who wants to evaluate TeamCraft – try it on their own laptop with one download and no set-up? The release pipeline already builds server archives and desktop installers, but the server needs PostgreSQL, and the only one-command installation is Docker on Linux. Date: 2026-11-14.
Requirements
- One installer per common platform (Windows x64, macOS Apple silicon, Linux x86_64) that brings the desktop app, the server and the database.
- Nothing to configure: no database URL, no password, no port.
- Nothing reachable from the network; nothing left running after the app ends; no data loss when the app crashes.
- No new runtime dependency for normal server installations, no change to the regular desktop installers (size, behaviour).
- Testable in CI on every shipped platform.
Options
| Option | Pros | Cons |
|---|---|---|
| Docker install (exists) | production set-up, backups, upgrades | needs Docker (Desktop on Windows/macOS, licences for companies); a shell command; too much for a first look |
| SQLite instead of PostgreSQL for local use | one file, no server process | the schema and queries are PostgreSQL (FOR UPDATE SKIP LOCKED, CHECK, full-text search, jsonb); a second database backend for every migration and query – not maintainable |
postgresql_embedded crate (MIT/Apache-2.0) in the server | downloads or bundles PostgreSQL, manages initdb/pg_ctl | its bundled feature downloads the archive in build.rs (network access during every build); otherwise it downloads PostgreSQL from GitHub at the first run; brings an archive/HTTP stack into the server for a feature only test installations use |
Per-platform installer scripts (NSIS, WiX, .pkg, deb postinst) that install PostgreSQL as a service | a “real” installation | administrator rights, a service that keeps running, five scripting languages, hard to test and to uninstall cleanly |
Bundle PostgreSQL’s programs; teamcraft local runs them | no new crate; the release job downloads a pinned major version with a checksum; the same command works with any installed PostgreSQL; easy to test end to end | the installers get larger (PostgreSQL ≈ 30–60 MB uncompressed after pruning); macOS needs every Mach-O file signed (ad hoc) |
Decision
Take the last option, in three parts:
-
teamcraft local(src/localserver.rs). The server binary gets a subcommand that runs a private cluster and the server:initdbon the first start: roleteamcraft,scram-sha-256with a random 32-character password kept in<data>/db-password(0600), UTF-8 without locale;- at every start a free port,
listen_addresses = '127.0.0.1',unix_socket_directories = ''(written toteamcraft-local.conf, included frompostgresql.conf),pg_ctl start --wait; - the server’s configuration from an overlay that wins over the environment (bind address, public URL, database, vault); everything else – an assistant key, OIDC – still comes from the environment;
pg_ctl stop --mode=fastafter the server stopped; a cluster left running by a killed instance is stopped at the next start; a second instance on the same data refuses (its port file answers/readyz);--exit-with-stdin: the desktop app keeps a pipe open and never writes to it. When the app ends – also by a crash or kill – the pipe closes and the server shuts down cleanly with PostgreSQL. A plain thread reads stdin, so the runtime never waits for it.
The data lives in the per-user data directory (XDG on Linux,
Application Supporton macOS,%LOCALAPPDATA%on Windows), shared by the app and the command line. -
Desktop app (
desktop/src/localserver.rs). When the server binary is next to the app, the connect screen offers Try TeamCraft on this computer: startteamcraft-server localwith the bundledpostgresql/bin, wait for/readyz, usehttp://127.0.0.1:<port>as the server and open the normal browser sign-in. The first-start page creates the administrator and now returns to the app’s authorize page (nextkept through/setup). The port is kept in the settings so that the address and the token stay valid; while the local server is the configured server the app starts it at every launch. -
Release job
local-server(.github/workflows/release.yml): builds the server, takes the newest patch release of PostgreSQL 18 from theseus-rs/postgresql-binaries (relocatable builds for Linux, macOS and Windows, PostgreSQL Licence), checks its.sha256, prunes headers, docs and static libraries, adds theCOPYRIGHTof that PostgreSQL version, signs the macOS files ad hoc and bundles everything withdesktop/tauri.local-server.conf.json(externalBin+resources). Every installer is then started where an installation puts it (AppImage extracted, the macOS.app, the Windows setup installed silently) and its bundled server runs a first start, the first-start page and a clean stop.
Details that decided the shape
- The binary’s name. The app’s own executable is
TeamCrafton Windows and macOS, whose file systems ignore case: a sidecar calledteamcraftwould be the same file. The server ships asteamcraft-server; its isolated conversion workers (preview-mesh,text-extract) recognise that name too, so CAD conversions stay in a child process with limits. - Separate installers. Only Windows x64, macOS Apple silicon and Linux x86_64 (AppImage) get a with local server variant – the platforms of most testers; the regular installers keep their size. Windows ARM64 runs the x64 build under emulation.
- No new crate, no download at run time, nothing in a normal server build changes.
Limits
- Not for production: one user’s computer, no backups, no TLS, no e-mail.
- Not code-signed or notarised (like every TeamCraft download so far). On macOS the quarantine flag may also have to be removed from the app.
- Data from the local server moves to a real installation with
teamcraft backup/restoreonly by hand (database settings fromdb-password).
Sources
- PostgreSQL:
initdb,pg_ctl, connection settings, licence. - theseus-rs/postgresql-binaries and postgresql_embedded (read for comparison, not used).
- Tauri 2: embedding external binaries, resources.
Deprovisioning through the identity provider (SCIM 2.0)
Question: how TeamCraft can notice that someone was removed at the identity provider (IdP) and start their offboarding, without an administrator having to remember it. Issue #173, step 9 (d). Date: 2026-11-12.
What exists today (step 9 a–c)
| Signal | What TeamCraft does |
|---|---|
| The user stops signing in | SSO users without sign-in on the administrators’ Users page: active SSO users without sign-in or token use for 90 days who still hold memberships, tokens or check-outs |
| The user’s tokens | TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS: new API and desktop tokens of SSO users expire after that many days without use |
| The IdP ends the session or removes the user | OpenID Connect Back-Channel Logout 1.0 at /auth/oidc/backchannel-logout: the signed logout token ends every browser session of that user, audited user.idp_logout, time shown in the report |
| The IdP removes the user from a group | the role and the IdP-synced memberships follow at the next SSO sign-in – a leaver never signs in again |
None of these deactivates anybody or hands their work over. The administrator still runs Offboard and deactivate (step 4). SCIM is the standard way for an IdP to tell an application “this account is disabled”.
How SCIM deprovisioning works
SCIM 2.0 (RFC 7643 schema,
RFC 7644 protocol): the application
serves /scim/v2/Users (and optionally /Groups), the IdP calls it with a
bearer token. The IdP creates users (POST), looks them up
(GET ?filter=userName eq "…"), changes them (PATCH/PUT) and in theory
deletes them (DELETE).
What the common providers send when someone leaves:
| Provider | Leaver | Sources |
|---|---|---|
| Microsoft Entra ID | PATCH with active: false when the user is unassigned, blocked or soft-deleted; DELETE only after the hard delete (30 days later by default). Non-gallery apps may send the string "False" instead of a boolean | Entra SCIM requests, Microsoft Q&A |
| Okta | never DELETE on users: deactivation is PATCH (or PUT for older custom apps) with active: false, reversible | Okta support, WorkOS |
| authentik | a SCIM provider (outbound, as a back-channel provider next to the OIDC provider), static token or OAuth (enterprise); PATCH where supported | authentik docs |
| Keycloak | no outbound SCIM (only an experimental inbound SCIM API, and third-party add-ons) | Keycloak survey feedback |
| University SSO (SAML / Shibboleth federations) | no SCIM to applications at all; accounts end at the IdP and that is all an application learns | – |
So the deprovisioning signal that matters is active: false (boolean or
string), through PATCH or PUT; DELETE may never arrive. Every guide
recommends a soft delete: keep the record, revoke sessions and tokens.
What implementing it would take
/scim/v2/UserswithGET(filteruserName eqandexternalId eqonly),POST,PATCH(replaceofactive, with and withoutpath),PUT,DELETE;ServiceProviderConfig,ResourceTypesandSchemasdiscovery; the SCIM error format. No crate needed (serde); the dialects of each IdP (Entra’s string booleans, Okta’sPUT) need their own tests.- A separate bearer credential per IdP (
TEAMCRAFT_SCIM_TOKEN, outside the personal access tokens), rate limited. - Matching SCIM users to TeamCraft users (
oidc_subjectoruserName), and whatPOSTof a user who never signed in means (create an inactive account or refuse). active: false→ Offboard and deactivate (step 4) with a configured default: release check-outs or keep them, revoke tokens; hand-over needs a successor and stays manual.DELETE→ the same (TeamCraft never deletes users).active: true→ reactivation without old tokens.
Decision
Not now. The target audience – student teams and early-stage startups – mostly signs in through a university SAML/OIDC proxy or a small Keycloak/authentik instance; of these only authentik sends SCIM, and universities do not offer it to team applications. Steps 9 (a)–(c) cover what such an IdP can actually signal: the idle report finds the leavers, idle tokens expire on their own, and back-channel logout ends the sessions at once.
Revisit when a team on Entra ID, Okta or authentik asks for automatic
deprovisioning. The mapping above (active: false → step 4 with a configured
default, soft delete only, string booleans accepted) is then the plan.
3D preview of CATIA V5 parts from their visualisation data
2026-11-08
Question
Can TeamCraft show a native CATIA V5 part (.CATPart) in the 3D viewer
without CATIA, a CGM licence or a translation service, the way it shows NX
parts from their JT stream?
Findings
- A CATIA V5 document is a
V5_CFV2container: the magicV5_CFV2\0, then the offset and length of the directory (u32, big-endian; offset + length = file size). The stream data is packed from byte 0x40 on. The directory starts withCATIA_V5 CB0001and lists every entry:- a 76-byte block (parent id, type 1 = directory or 2 = stream, size, timestamps);
- the name in UTF-16BE with its length in front;
- a record with the id and the segments (absolute offset, length).
- Every container of the document (
CATPrtCont,CATFeatCont, the B-rep inCATBRepModeContainerandCGMGeom, …) is a stream namedCont_<id>. ItsCATStoragePropertystream says which one it is. - The container
CATCGRContholds the tessellation CATIA draws on screen. It is stored as a completeV5_CFV2document nested in the file, with offsets relative to its own start. The mesh is in its streamSceneGraph/MainDataStream;SceneGraph/SurfacicRepsonly holds the bounding boxes of the faces. - Nothing is compressed. The
78 01byte pairs that look like zlib headers are coincidences inside float data; none of them inflates. - The mesh is one record per body, behind the marker
FF FF 02 00 01(after the bounding sphere of the body). Counts are a byte, orFFfollowed by a little-endian u32. The record has three blocks:- Vertices in millimetres, part coordinates, sorted by (x, y, z) and stored with prefix sharing. A 2-bit code per vertex says how many of the last coordinates of the previous vertex are replaced: x y z, y z, z, or none (a copy where the normal changes).
- Normals of the curved faces. Per normal, two components are stored as int16 (× 1/32767) and the largest one is derived, with a 4-bit code for its axis and sign. Normals along an axis are a code only.
- One record per B-rep face. Each lists its triangles as a list, strips and fans, indexes the vertices (u8 below 256 vertices, otherwise big-endian u16), and carries one face normal for planar faces. The record also holds the chordal tolerance CATIA tessellated with (0.2 mm by default, 0.01 mm in the NIST file).
- The triangles are counter-clockwise seen from outside. The vertex normals
are the exact surface normals of the B-rep, so the mesh can be refined
along the true curvature exactly like the JT data of NX parts (curved PN
triangles,
src/mesh/pn.rs). - Mirrored parts (“Symmetry of …”) carry no transform: the mirrored geometry is stored in the vertices.
- Products (
.CATProduct) and drawings have noCATCGRCont; a product’s preview has to be put together from its parts, as for NX assemblies. - No public description of the CGR encoding was found (one web search; the results are listed under Sources). Dassault’s own readers (CATIA, the 3DEXPERIENCE viewer) are proprietary, and no open implementation is known. Everything here was read off sample files: no CATIA software, SDK, documentation or third-party code was used.
Verification
- NIST CTC-01 (
tests/fixtures/cad/nist/ctc-01/catia-v5/, V5-6R2019): 6 932 triangles on 117 faces. That is the same number of faces as the 117ADVANCED_FACEs of the AP242 STEP export. The mesh’s bounding box (-400, -225, -100)..(400, 225, 50) is the vertex box of the STEP file exactly. The mesh is closed and its volume is positive. - 24 parts of a real hobby project (V5R21: pipes, connectors, a frame, a
bucket, mirrored copies), used locally only and not committed:
- all decode, and the renders match the drawings (lengths and diameters within the 0.2 mm tolerance);
- 23 are closed. The 24th has cracks of less than the tolerance between a finely tessellated sliver face and its neighbours. That is CATIA’s own tessellation, not a decoding error.
- Two independent decoders, one following the container directory and one searching the raw bytes for vertex and index arrays, arrived at the same layout.
Decision
- Implement a reader for the CGR mesh in pure Rust (
src/mesh/catia.rs):- find the nested
V5_CFV2document and decode each mesh record behind its marker; - check every count against the bytes that are left and every index against the vertex count, and skip records that do not add up (other scene graph nodes share the marker);
- weld the corners that differ only by their normal, refine along the stored normals (PN triangles, at most 3 × 3, 500 000 triangles), and work in millimetres.
- find the nested
- The thumbnail of a CATIA part is rendered from the 3D preview, as for NX
parts (
thumbnail::rendered). - Products are put together from their part files like NX assemblies (see 3D preview of CATIA V5 products).
- Later: multi-body parts, bodies with a transform in the scene graph, and indices above 65 535 vertices are untested.
- Never: the exact B-rep (CGM). It is undocumented and would need a geometry kernel. The visualisation data is what other PLM viewers show as well.
- Parts saved without visualisation data (an empty
CATCGRCont) get no 3D preview and say so.
Sources
- Sample files: NIST MBE PMI Validation and Conformance Testing models
(public domain, already in
tests/fixtures/cad). The hobby project files were used locally only. - Web search for a CGR or
V5_CFV2format description (2026-11-08). It returned only user documentation, which confirms that CGR files hold the graphical representation (vertices and triangles) for viewing, and no format specification: - Prior work in TeamCraft: 3D preview of NX parts (PN refinement, thumbnails rendered from the mesh).
3D preview of CATIA V5 products
2026-11-12
Question
Can TeamCraft put together the 3D preview of a CATIA V5 product
(.CATProduct) from the previews of its parts, as it does for NX,
SOLIDWORKS and Inventor assemblies? What does a product store about its
components, and how are the component files found?
Findings
- A product is a
V5_CFV2document like a part (see 3D preview of CATIA V5 parts). It has no visualisation data of its own; its component instances and their placements are in the object stream of itsCATProdContcontainer. - No file names are stored. A full scan for Latin-1 and UTF-16 strings
finds no
.CATPartand no component file name. Each instance points to the UUID of the referenced document’sCATProdContcontainer (plus the object id 2, that document’s root product).- Every CATIA document lists its containers in a table near the start of
the file (the first 64 KiB are enough):
02 00 81 20 <u32 n> <name>\0 <type>\0 [up to 8 × 00] 03 00 f7 00 03 00 00 00 <uuid: 4 × u32 big-endian>. The entry namedCATProdContis the one products link to; parts also have aCATPrtContwhose type isCATProdCont. - A copied file keeps its UUID; a renamed one too.
- The part number is stored as well. In the sample it equals the file
name without extension for 67 of 68 instances – not for the one whose
part number is a description (“5 gallon bucket” →
Bucket.CATPart).
- Every CATIA document lists its containers in a table near the start of
the file (the first 64 KiB are enough):
- The object stream begins with
OSMXand a version string and is divided into sections7c <kind> <u32 length>(the length counts from the7c): a header (0c), the objects (04), a side table with one block per object (08), an extension table (0b) and the string table (02). Between sections there can be up to 2 stray bytes.- Small numbers are one byte
81..cf(value − 0x81), two bytesd1..e3 xxor80 <u32>; a string reference is one byte34..ff(index − 0x34) or32 <u32 index + 1>; doubles aree6 <f64>, withe7/e8/e9for 1, 0 and −1. - Every object record (
7c 05) has a header (7c 06: two flag bytes, the class, the name,ea <u32 id>) and an attribute block (7c 07). The classes that matter areASMPRODUCT(a product or an instance; its name is the part number) andPRDBAGREP(holds the placement). - Attributes:
<name> <key> e8|e9 <flags> <type> [extra byte after e9] 37 <value> fe. Type0x12is a string (_InstanceName), type0x07an array of doubles (_Position: 12 values). - The side table block of an object holds its link into another document
(
e5 <u32 length> 11 03 <uuid> … <u32 object id> …) and its list-valued attributes:_Componentof the root lists the instances in the order of the specification tree,_BagRepsListof an instance names thePRDBAGREPwith its placement.
- Small numbers are one byte
- Placement: the 12 doubles are the instance’s X, Y and Z axes and its
origin, in millimetres, in the coordinates of the product. All 68 sample
placements are rotations (det = +1): mirrored components are separate
documents with mirrored geometry. An instance without
_Positionsits at the origin (the first, fixed part). - No public description of the format was found; Dassault’s readers are proprietary. Everything was read off one sample product (V5R21, 68 instances of 22 documents, a private hobby project that is not committed). The stream primitives were also parsed without error in the product container of all 25 CATParts at hand and of the NIST CATPart (V5-6R2019).
Verification
- All 68 instances of the sample are found with their instance name, part
number, reference and placement; the root’s
_Componentlist names the same 68 objects in the same order. - All 68 references resolve by UUID to a CATPart of the project folder.
- Composing the parts with the placements gives the drawing of the assembly: the mirrored halves meet, the pipes run from connector to connector (the convention “stored axes are the columns of the rotation” is the only one for which the pipe axes and the 36 mirror pairs agree).
Decision
- Implement the reader in pure Rust (
src/catprod.rs): container table, object stream, the two attributes and the side table, every count and offset checked against the bytes left. - Resolution of the components (
services/assemblies.rs, the CATIA family):- by UUID among the CATIA files of the product’s own revision and the files found by name (step 2) – the first 64 KiB of each are read;
- otherwise by name:
<part number>.CATPartor.CATProduct, the way NX, SOLIDWORKS and Inventor components are found (own revision first, then the latest revision of another visible item). A component found neither way is left out, as for the other systems.
- Sub-products are entered like sub-assemblies, placements composed.
- The thumbnail of a product is rendered from its composed preview.
- Not yet: hidden components (no sample with one;
VPGlobalin the extension table is the only candidate), components without a document of their own, and CATIA V5-6R20xx products (untested). Products with file links in aCATDLName-style document table were not seen.
Sources
- Sample files: one private product with its parts (local only) and the
NIST CATPart in
tests/fixtures/cad. - Prior work in TeamCraft: 3D preview of CATIA V5 parts,
services/assemblies.rs(NX, SOLIDWORKS and Inventor assemblies).
Instance counts of Inventor assemblies
Question: can TeamCraft tell how often an Autodesk Inventor assembly (.iam)
places each part or sub-assembly – the BOM quantity – without Inventor, the
way it already does for SOLIDWORKS, NX and STEP assemblies (#247)? Until now
every Inventor BOM line from the CAD references had quantity 1 with the note check the quantity. Date: 2026-11-11.
Test data
32 assemblies of one real Inventor project (not published), saved with
Inventor 2025 to 2027: 390 occurrences of 190 referenced files, among them
Content Center standard parts placed up to 10 times, Design Accelerator
assemblies, mirrored parts and suppressed occurrences. The expected counts
came from Inventor 2027 itself, read through its COM API (ComponentDefinition.Occurrences,
ReferencedDocumentDescriptor,
Suppressed, BOMStructure) with an invisible Inventor instance that
opened every assembly read-only. The files themselves cannot be published,
so the unit tests use synthetic files with the same layout.
Findings
An .iam is a compound file (MS-CFB). The stream UFRxDoc holds a table
with one file descriptor per referenced file:
| Field | Content |
|---|---|
u32 n, n × UTF-16 | full path of the referenced file |
u32 | kind of the path (0x43 workspace, 0x4d library, 0x2a older paths) |
u32 m, m × UTF-16 | workspace or library name (e.g. Content Center Files) |
u16 | flags (1 workspace, 3 library) |
u32 k, k × UTF-16 | display name, may be empty |
| 6 bytes | always 0 |
| 2 × 16 bytes | identifiers (not found anywhere else in the file) |
u32, u16 | unknown; the u16 is always 0 |
u16 | number of occurrences of the file in this assembly |
| 10 bytes | then the next descriptor |
- The last
u16matched Inventor’s own occurrence count for all 190 files of the 32 assemblies, standard parts included. - The stream also keeps copies of the table from earlier saves, some with older paths; within a table the descriptors follow each other at exactly 10 bytes (214 of 214), between tables there is a large gap. Only the first table is current.
- The table does not tell suppressed occurrences apart: Inventor’s BOM leaves them out, the count includes them. In the test data that changed 6 of 190 counts (parts whose occurrences were all or partly suppressed).
Suppressed occurrences (2026-11-11)
A controlled experiment told where the suppression is stored: a copy of an
assembly was saved by Inventor once unchanged and once with exactly one
occurrence suppressed, and the two UFRxDoc streams were compared.
UFRxDocalso holds one property record per occurrence, in the order of the occurrences, as tagged properties01 t 01 00 00 00 t value:05the occurrence name (u32length + UTF-16; the base ofBolt:3), 4 bytes,02an identifier (16 + 4 bytes),071 = suppressed (19also changes, 1 → 5). Like the descriptors, the records repeat after earlier saves; the first ones – as many as the descriptors count – are current.- The occurrence name is the descriptor’s display name (Content Center
parts:
DIN EN ISO 4762 M6 x 16), otherwise the file name without extension. Identifiers of the referenced file (property12) are not in the assembly’s descriptor, so the name is the link. - The model state also lists the suppressed occurrences (after
[Primary]: count and the occurrence ids as text) – not needed for the counts. - Checked: every suppressed record taken off the one file whose name it
carries gives Inventor’s active count for all 190 files of the 32
assemblies (Inventor 2027 API,
Suppressed); the two assemblies where an occurrence name fits no file or two files have no suppressed occurrences.
Decision
src/invasm.rs reads the first descriptor table of UFRxDoc and returns (file name, occurrences);
cadrefs::instance_counts uses it for
inventor-assembly like the other formats, so the folder import, Quantities
from CAD… and the BOM-from-references page get Inventor quantities.
Anything that does not fit the layout is skipped, never guessed: a file
without a descriptor has no count (quantity 1, check the quantity), as
before. No migration: Quantities from CAD… reads the counts from the
stored file, so assemblies imported earlier get them at once; their stored
counts (file_references.instances) are refreshed by teamcraft rescan-references --format inventor-assembly (a migration that marks them
for the startup scan would change v0.1.0 data, which the upgrade test
forbids).
Suppressed occurrences are left out (2026-11-11): each suppressed record is taken off the file whose name it carries; a file with only suppressed occurrences counts 0 – still referenced and tracked, but not in the BOM, like SOLIDWORKS parts excluded from the BOM. When the records do not add up (fewer records than occurrences, a suppressed record whose name fits no file or several, more suppressed than placed) the descriptor counts stay as they are, suppressed ones included – never guessed.
Later, if needed: other model states than the one saved last, Inventor Reference and Phantom BOM structures, and Solid Edge assemblies (no test files yet).
Crash reports from the desktop app
Question: how the desktop app (Tauri 2, Rust + system web view) can notice crashes and get the reports to the developers. It must do this without sending anything the user has not seen, and without a hosted service that an open-source project would have to run. Date: 2026-11-10.
What can crash
| Where | How it shows | Can the app catch it? |
|---|---|---|
| Rust (commands, workers) | a panic!; in an async command the task dies, in the main thread the app exits | yes: std::panic::set_hook sees every panic before unwinding, with message, location and a backtrace |
| the web view’s JavaScript | an uncaught exception or unhandled promise rejection; the UI breaks silently | yes: window error and unhandledrejection events |
| native code (web view, GTK, …) | the process dies (segfault, abort), the OS may show its own dialog | not in-process; only afterwards, by noticing that the last session did not end normally |
| killed or power loss | same as a native crash | same; cannot be told apart from a native crash |
Options
| Option | Pros | Cons |
|---|---|---|
Sentry (SaaS or self-hosted; sentry crate, MIT) | full product: grouping, symbolication, minidumps via the Crashpad handler | needs a DSN baked into the app and a service someone pays for or runs; uploads in the background by default (consent has to be bolted on); the self-hosted stack is large; an open-source build would carry the project’s DSN to everyone |
Crashpad / Breakpad minidumps (minidumper, crash-handler crates, MIT/Apache-2.0) | catches native crashes too (out-of-process handler) | needs symbol files per release and a server to process dumps; minidumps contain memory (data the user did not choose to share); a second process to ship and sign |
| Upload to the user’s own TeamCraft server | no third party | goes to the server’s administrator, not to the developers; mixes product bugs with tenant data |
| Panic hook + session marker + UI error events, shown to the user, sent as a GitHub issue they open themselves | no service, no DSN, nothing leaves the computer unseen; works offline; the report is plain text the user can read and edit | no native stack for web-view crashes (only “the last session ended unexpectedly”); developers depend on the user filing the issue; GitHub URLs cap the prefilled text (the full report goes to the clipboard) |
Decision
Take the last option. It fits a no-release, open-source desktop client with
no hosted backend, and it matches the project’s rule that nothing is uploaded
on its own (the update check only reads api.github.com).
-
Rust panics. A panic hook, installed first thing in
main, writes one report per panic to<config>/crash-reports/. It records the message, location, thread and a backtrace, then calls the previous hook. -
Abnormal exit. A session marker is created at start and removed on a normal exit (
RunEvent::Exit). A marker that is still there at the next start becomes an “ended unexpectedly” report with the version and start time of that session. This is the only trace of native crashes. -
Uncaught UI errors. Only real JavaScript exceptions (
Errorinstances) are recorded, deduplicated and capped per session. Rejected command calls (server errors such as 404 or 403) are not crashes and are not recorded. -
Redaction before writing. The home directory becomes
~, URLs become[url], and TeamCraft tokens (tc_pat_…,tc_sess_…) become[token]. The report never contains the server address, user name, token or file contents. The store keeps at most 20 reports of at most 64 KiB each. -
Start-up prompt. At the next start a dialog shows the whole report and offers three choices:
- Report on GitHub: opens a prefilled new issue at
github.com/rudalabs/TeamCraft; the full text is copied to the clipboard in case the URL had to cut it. - Copy.
- Don’t send: deletes the reports.
Don’t ask again turns the prompt off in Settings. The reports are still written locally and can be deleted.
- Report on GitHub: opens a prefilled new issue at
-
Later, if needed. Minidumps for native crashes (
crash-handler+minidumper), only with an explicit opt-in and a place to send them.
Mass properties from exported meshes
Mass, centre of gravity (CG) and inertia tensor of an assembly – a vehicle, a rocket, a robot arm – are needed by everybody downstream of the CAD model: flight simulation, structural analysis, balancing, transport. Getting them out of the CAD system is surprisingly painful: the assembly must be fully loaded, every part needs the right material, bought parts are modelled as envelopes with the wrong density, and the result lives in one engineer’s session. This page records how mass properties are computed from triangle meshes, what can go wrong, how other tools take them, and the design TeamCraft adopts. Date: 2026-11-09.
The method: signed tetrahedra and the divergence theorem
For a closed, consistently oriented triangle mesh, every volume integral of a
polynomial can be turned into a surface integral (divergence theorem) and
evaluated exactly per triangle. Equivalently: each triangle and the origin
span a tetrahedron whose signed volume is a · (b × c) / 6; summed over a
closed surface, the parts outside the body cancel and the sum is the volume.
- Mirtich (1996), Fast and Accurate Computation of Polyhedral Mass Properties, Journal of Graphics Tools 1(2): the classic reduction of the volume integrals of 1, x, y, z, x², y², z², xy, yz, zx to face and then line integrals (projection onto a coordinate plane).
- Eberly (2002, revised 2009), Polyhedral Mass Properties (Revisited), Geometric Tools: the same ten integrals for triangle meshes with a compact set of per-triangle sub-expressions – no projection, no case analysis, about 50 multiplications per triangle. This is the formula TeamCraft uses.
- Tonon (2004), Explicit Exact Formulas for the 3-D Tetrahedron Inertia Tensor in Terms of its Vertex Coordinates, Journal of Mathematics and Statistics 1(1): the same result written per tetrahedron; useful as an independent check in tests.
From the ten integrals with density ρ:
- mass
m = ρ ∫ dV, CGc = ρ ∫ r dV / m; - inertia about the CG
I = ρ ∫ (|r|² E − r rᵀ) dV − m (|c|² E − c cᵀ)(tensor form: the off-diagonal entries are the negative products of inertia,I_xy = −∫ xy dm; CAD systems differ here, so exports must state the convention).
Numerical care. Parts exported in a vehicle frame can sit metres away
from the origin while being millimetres thick. ∫ x² dV − m c² then cancels
almost all digits. The integrals are therefore accumulated relative to a
point inside the part (the centre of its bounding box) and only the CG is
shifted back – the inertia about the CG does not depend on that point.
Combining parts – the parallel axis theorem. For bodies i with mass
mᵢ, CG cᵢ and inertia Iᵢ about their own CG:
m = Σ mᵢ, c = Σ mᵢ cᵢ / m, and with dᵢ = cᵢ − c:
I = Σ (Iᵢ + mᵢ (|dᵢ|² E − dᵢ dᵢᵀ)). The same shift gives the inertia about
any reference point (a nose tip, a launch-lug station, a gimbal point).
Accuracy against tessellation
A mesh is an inscribed approximation of curved surfaces: the volume comes
out slightly low. For a cylinder of radius r tessellated with n segments
the area ratio is (n / 2π) sin(2π / n) ≈ 1 − (2π/n)²/6: 36 segments lose
0.5 %, 360 segments 0.005 %. Expressed in the chordal deviation δ that
CAD exporters ask for, the relative volume error of a round section is about
(4/3) δ / r: δ = 0.01 mm on a 50 mm radius is 0.03 %. Inertia (∝ r²)
has about twice the relative error of the volume. In practice the density –
a handbook value for a material whose real density differs by a percent or
two, infill of printed parts, glue, paint, fasteners – dominates the error,
not the tessellation; an export with a fine chordal tolerance (0.01–0.05 mm)
is ample.
Thin walls are the exception worth knowing: in a tube both surfaces are inscribed, so the outer one loses volume and the cavity loses volume too. The two errors partly cancel, but what remains is relative to the small wall cross-section, not to the whole disc – a 1.5 mm airframe wall exported with a coarse tolerance can be off by a few percent. Fine export settings matter there.
What breaks the method
- Open meshes (holes, missing faces, a partial STEP tessellation): the signed volume then depends on where the origin is – the result is not a physical quantity. Detected by counting edges used by exactly one triangle. The values are computed (relative to the part’s own bounding box centre, which limits the damage for small holes) but flagged.
- Non-manifold edges (three or more triangles on one edge – two bodies touching along an edge, duplicated faces): usually still correct when the bodies are themselves closed; flagged.
- Inconsistent orientation (some triangles flipped): the flipped ones subtract instead of add. Detected per edge (a shared edge must be used once in each direction). Cannot be repaired without a surface walk and is flagged.
- Fully inverted meshes (every triangle flipped, a common exporter mistake): the volume comes out negative – the whole mesh is inverted and the sign is simply flipped, with a note.
- Hollow bodies and cavities are correct by construction: an inner surface oriented inwards subtracts its volume.
- Several bodies in one file (fins exported together) add up correctly as long as each is closed and they do not overlap; overlapping bodies are counted twice (a mesh-boolean would be needed; out of scope).
- Point clouds have no volume.
Units
STL has no unit field at all; OBJ, PLY and OFF neither. 3MF declares a unit
in the model header, glTF is metres by specification, STEP carries its
unit in the file. CAD exports of STL are millimetres by convention but
inch exports exist. Mass properties scale with the unit s (metres per
model unit): volume and mass with s³, CG with s, inertia with s⁵ – an
inch file read as millimetres is off by 25.4⁵ ≈ 10⁷ in inertia, which is
obvious, but a centimetre file read as millimetres is “only” off by 1000 in
mass, which can slip through. A per-file unit, shown next to every
computed value, is therefore necessary; millimetres as the default, glTF as
metres.
Frames: one vehicle frame or local frames with placements
Two ways to get parts into one coordinate system:
- Every part exported in the assembly / vehicle frame. The CAD system writes each component in assembly coordinates (NX: Export STL with the assembly as work part and the component selected, or the output coordinate system; most CAD systems have an equivalent). The PLM then only adds – no transforms, no placements, no dependency on the CAD system’s assembly file. A part used several times (four fins) is either exported once with all instances, or once per instance as its own file.
- Parts in their local frame plus placements. The BOM line stores a 4×4 transform per instance (Teamcenter’s occurrence transforms, the SOLIDWORKS component tree, NX’s structure). This is what CAD-integrated PLM does, but it needs the placements in the PLM – and keeping them in sync is exactly the CAD-integration work that is fragile.
TeamCraft’s BOM lines carry no placements today (the 3D preview of NX and SOLIDWORKS assemblies reads them from the assembly file, not from the BOM). Option 1 is the workflow that removes the CAD system from the loop, and it is the one adopted. Placements on BOM lines (per-instance transforms) are follow-up work.
Quantities in a common frame. A BOM line “fin × 4” with a file that
shows one fin would put four fins on top of each other: the mass is right,
the CG and inertia are not. The file therefore records how many units it
shows (instances, default 1); the line contributes
quantity / instances times the file’s body. When the two differ, the line
is flagged (“copies stacked at the same place”).
How other tools take mass, CG and inertia
| Tool | Mass properties | Overrides |
|---|---|---|
| NX | Measure Bodies / assembly Weight Management from the exact solid and the assigned material; needs every component loaded with geometry | assigned mass and CG on components; stored values go stale when a component is not loaded or a material is unassigned |
| SOLIDWORKS | Mass Properties from the solid and the part material; assemblies from the components | Override Mass Properties: mass, CG and inertia per part or assembly |
| Creo | mass properties from the solid and the density in the material | assigned mass properties per part |
| Onshape | mass properties tool, material library per part | per-part mass override |
| Teamcenter / Windchill | store mass as attributes the CAD integration writes on save | attributes editable; stale when the CAD side was not saved |
| OpenRocket | components of revolution with analytic mass, CG and longitudinal / rotational inertia from material densities (bulk, surface, line) | per-component mass override and CG override, optionally covering the sub-components |
| RocketPy | takes the vehicle as given numbers: dry mass, CG without motor, inertia tuple (I11, I22, I33, I12, I13, I23) about that CG | – (the motor’s mass and inertia are combined with the rocket by parallel axis, in the coordinate system the user declares) |
Common ground: computed values come from geometry × material density; a measured or assigned value always wins and covers the whole sub-tree when set on an assembly; and the simulation tools want one set of numbers – mass, CG and the tensor about the CG – with the coordinate system and the tensor convention stated.
Measured mass for bought and finished parts
Bought parts (motors, electronics, valves, bearings) are modelled as envelopes: geometry × density is meaningless for them. Their mass comes from a data sheet or a scale; the CG from the data sheet or a balance test, the inertia rarely. Finished sub-assemblies are weighed as a whole. So a revision gets a measured mass with an optional CG (and an optional inertia tensor about that CG): when set, it replaces everything computed for that revision and for its sub-tree, and it is shown as measured. Measured mass without a CG still counts in the total mass, but cannot place the mass; the CG and inertia then cover the placed mass only and say so.
Decision
- Pure module
massprops: Eberly’s integrals relative to the bounding box centre, inertia about the CG per unit density in model units, plus an edge check (open, non-manifold, inconsistently oriented edges, welded on a tolerance grid like the shape signatures) and the fix of a fully inverted mesh; combination by parallel axis; principal moments. Bounded, no panics, tested against analytic solids. - Computed in the isolated preview process from the cached preview mesh
and cached next to it per content hash (
<sha>.v<N>.m<V>.json), like the shape signatures – the server never parses an untrusted mesh. Units and density are applied afterwards (unit-free cache). - Densities: an admin-managed material library (name → kg/m³); the revision’s material attribute is looked up there by name. A per-revision density override wins.
- Per-file settings: the length unit (mm by default, m, in; glTF defaults to m) and the number of instances the file shows. The geometry file of a revision is chosen explicitly or picked automatically (STL, 3MF, OBJ, PLY, OFF, AMF, glTF, then STEP, then other formats with a 3D preview).
- Measured values per revision: mass, optional CG, optional inertia about that CG, a note; they win over everything computed and cover the sub-tree.
- Source order per BOM line: measured → computed from the geometry file → the entered mass (no position) → missing. An assembly without a measured mass is the sum of its children (its own geometry and entered mass are not added – they would count the children twice).
- Frames: all parts in the common vehicle frame; placements on BOM lines are follow-up work.
- Roll-up over the multi-level BOM with the revision rule, quantities, and the read rights (restricted lines are left out with a note, as in the existing mass roll-up). Result: total mass, CG, inertia about the CG and about a chosen reference point, principal moments; per line the source, and warnings for open meshes, missing densities, unplaced masses and stacked copies.
- Interfaces: a page on the revision with CSV and JSON export,
GET /api/v1/revisions/{id}/mass-properties, a read-only MCP tool.
3D preview of Fusion designs from their display mesh
2026-11-08
Question
Can TeamCraft show an Autodesk Fusion design (.f3d) in the 3D viewer
without Fusion, an Autodesk account or a translation service?
Findings
-
A
.f3dfile is a ZIP archive. Most entries are compressed with Zstandard (ZIP method 93); a few small ones are stored. Common ZIP tools that know only Deflate refuse these entries. The archive holds one folder per document (FusionAssetName[Active]/…) with:Breps.BlobParts/BREP.<uuid>.smb/.smbh: the exact geometry as binary Autodesk ShapeManager save files (ASM, the ACIS-derived kernel;.smbhwith history). Tessellating them needs a B-rep kernel.truckcannot read them, and no open kernel does.FusionDesignSegmentType1/…: the design (timeline, sketches, features).Previews/small.png,Previews/big.png: thumbnails Fusion rendered when saving.OGS.BlobFolder/OGS/DefaultScene/worldandstream_mesh_000: the display scene of Fusion’s graphics system (OGS). Fusion stores it so that the design opens quickly. It holds the triangles Fusion draws for every face of every body.
-
worldis a binary serialisation of the scene graph. Class names are stored asu32 length+ UTF-16LE:GroupNode,GeometryNode,OgsSimpleMesh,TransformAttribute,VisibilityAttribute, and the Fusion client typesComponent,Instance,Body,Faces,Face,Edges,Edge,ProfileFace,SketchCurveand so on. Shared objects are written once and then referenced by an id string (a pointer value printed as 16 hex digits). -
An
OgsSimpleMeshrecord:"OgsSimpleMesh" 6 × f32 bounding box, u32 primitive type (4 = triangle list, 2/3 = lines, 5 = points), 3 bytes flags "VertexFormat" id string; on first use: u32 count, count × 24 bytes (u32 type: 1 = float2, 2 = float3; u32 semantic: 0 position, 1 normal, 2 uv, 3/4 tangent/binormal; u32 offset in floats; 3 × u32 0) vertex buffer u32 1, u32 stream number, u32 offset, u32 byte length u8 1 = an index buffer follows "AIndexBuffer" id string, u32 stream number, u8 0, u32 offset, u32 byte lengthThe offsets point into
stream_mesh_NNN, which holds the raw buffers: interleaved little-endianf32vertices (position, normal, uv, …) andu32triangle indices. The buffers follow each other without gaps. -
Coordinates are centimetres (Fusion’s internal unit). The meshes of a body’s faces share their boundary points bit for bit, so the faces weld into a closed surface. Every vertex carries the exact surface normal of its face.
-
The sample (a mechanical key switch: 5 bodies with 179 faces in all) was compared with the STL and STEP files Fusion exported from the same design:
display mesh of the .f3dSTL export of Fusion triangles 1,108 4,490 open edges after welding 0 0 connected shells 4 4 volume 0.98459 cm³ 0.98485 cm³ (−0.03 %) bounding box identical identical The STEP export has the same box in millimetres (14.8 × 14.8 × 15.4 mm). The display mesh is coarser than an export, but it has exact normals, so the PN-triangle refinement of the NX preview (see 3D preview of NX parts) applies unchanged.
-
Each body appears twice in the scene: once without meshes and once with the
Facemeshes. Edges, sketch profiles, sketch curves, dimensions, constraints, work planes and origin geometry are separate meshes under their own client types and are left out. -
Instances carry a
TransformAttribute: a 4 × 4f32matrix, row-major, with the translation in the last row (p' = p · M). In the sample every instance and body transform is the identity, because the components are modelled in place. -
Prior art:
cadmpeg-codec-f3d(Rust, Apache-2.0, clean-room) decodes the ZIP container, the ASM B-rep streams and the design records of.f3dfiles. It does not mention the OGS display scene. TeamCraft reads only the display scene and copies no code. The Zstandard decoder isruzstd(pure Rust, MIT).
Decision
- Read the display mesh: open the ZIP, inflate
worldand thestream_mesh_*entries withruzstd(sizes capped), and take the triangle meshes whose client type isFace. Weld them per body and refine them along their normals like NX parts. Convert centimetres to millimetres. - Render the thumbnail from the 3D preview, as for NX. Fall back to
Previews/big.pngwhen the file has no display scene. - The B-rep (
.smb) stays unread: the display mesh already shows what Fusion shows.
Open points
- Instance transforms. The only sample places every component with the
identity. An assembly whose components are moved, or that uses one
component several times, is needed to confirm where the
Instancetransform applies. Until then the meshes are taken as stored. - Hidden bodies and components. It is not yet known whether Fusion
writes hidden bodies into the display scene, or how their
VisibilityAttributeis set. .f3z(a design with its referenced designs) is a ZIP of.f3dfiles; it can be read once assemblies are confirmed.- Files saved without a display scene (if Fusion writes such files) get the stored PNG.
3D preview of Inventor parts and assemblies
2026-11-08
Question
Can TeamCraft show native Autodesk Inventor parts (.ipt) and assemblies
(.iam) in the 3D viewer without Inventor, Inventor View or a
translation service, and with the same quality as the NX and SOLIDWORKS
previews (3D preview of NX parts,
3D preview of SOLIDWORKS files)?
Findings
Inventor files are OLE compound files. Below the storage RSeStorage, a
document is split into segments. RSeSegInfo lists them, and each
segment has two streams with the same id:
M<id>, the meta stream:- the text
RSe Meta Stream Version 8, the segment name (UTF-16) and its class UUID, then two dates; - then a zlib body: the length of every section in the segment, and the table of section classes (UUIDs).
- the text
B<id>, the data stream: an 18-byte header, then zlib. Inflated, it is a flat list of sections:u32 class word | payload | u32 length again | u8 0. The low byte of the class word indexes the class table, and the repeated length is a check.
Sections are named after the first 32 bits of their class UUID (for
example D79AD3F3). Inside the sections, arrays share one form:
u32 0x30000002, u32 count, u32 count, u32 element type, then the
elements. The element type names a class of the table: a point (3 ×
f32), a unit vector or a 2D point.
Parts: the display tessellation
-
Every part has one
PmGraphicsSegment. It is the scene graph Inventor draws the part with. -
One section of class
D79AD3F3per B-rep face holds:- the owning face (a key);
- the vertices (
f32xyz, centimetres, part coordinates); - a plain
u32triangle list, counter-clockwise around the outward normal; - a unit surface normal per vertex;
- the uv parameters and edge flags, which the preview does not need.
-
A simpler class
A79EACD2has the same arrays without the extras. It is used:- for the quads of work planes (owned by no face, and left out);
- for a second triangulation of a face;
- in a few files, as the only mesh of a face.
-
Bodies: the faces come in body groups. A record before each group tells solid bodies (1) from surface bodies and construction copies (0). The preview takes the solid bodies; a part without any shows all its surfaces.
-
No transforms: every body is stored in part coordinates.
-
Watertight without tolerance: neighbouring faces share their boundary vertices bit for bit. Welding on the exact
f32values closes the mesh, whereas welding with a 1 µm tolerance breaks tiny features. -
Tested files:
- all 873 parts of the Autodesk Inventor 2022 sample set (saved with Inventor 2019 and 2022);
- the two NIST parts in
tests/fixtures/cad(Inventor 2021).
-
Results:
Result Parts Decode failures 0 Closed and consistently oriented 790 Watertight, but touching themselves (springs, propeller blades, multi-body mould plates) 18 Open, because they are true surface models 9 Empty: sketches, route and skeleton parts without a body 56 NIST ctc-01 has the same bounding box as its NX and STEP versions: 800 × 450 × 150 mm.
-
Fineness and PN refinement: the resolution is Inventor’s display quality, similar to SOLIDWORKS. Every vertex carries the exact surface normal, so the same PN-triangle refinement as for NX and SOLIDWORKS applies.
-
Stored preview image: the summary information (
\x05Zrxrt4arFafyu34gYa3l3ohgHg, property 17) holds a PNG of 180 or 512 pixels, which TeamCraft already shows.
Assemblies: occurrences and placements
-
An assembly stores no mesh of its own. Its
AmGraphicsSegmentholds proxies, so the preview is put together from the part files, as for NX and SOLIDWORKS. -
Which files: the stream
UFRxDocholds:- the table of referenced files: the author’s absolute path, a file id, and flags for iPart/iAssembly factories and members;
- one record per occurrence: occurrence id, file id, title, and 8 flag
bytes. Bit
0x40of the last flag byte means not visible.
-
Where: the
AmRxSegmentholds one section232792BCper occurrence:- a packed 4 × 4 matrix: one
u32mask says which elements are +1, 0 or −1, and the others follow asf64; - the occurrence id, to join with
UFRxDoc.
The matrix uses column vectors (
p' = R·p + t) and centimetres. - a packed 4 × 4 matrix: one
-
Sub-assemblies are files of their own and are placed relative to their parent.
-
Checks across all 249 sample assemblies:
- all of them parse;
- all 7 700 occurrences resolve to a file;
- 233 of the 243 assemblies that store a bounding box reproduce it exactly with the decoded placements. With the transposed rotation only 40 do, all symmetric cases.
- The misses are six cable and harness assemblies, whose wires are virtual components, and one at 0.9 %.
- The visibility bit agrees with Inventor’s own DWFx exports.
-
Flexible sub-assemblies (addendum 2026-11-11). A parent re-poses children deeper down with override sets (class
F17A4562): a path of occurrence ids from the parent to the sub-assembly, and refs to override sections (class2238DCC5). Each override holds the child’s persistent key (offset 120 of its placement tail) and a packed matrix that replaces the child’s local placement. The overrides of the outermost assembly win. Applying them makes the Test Station bounding box exact (1e-8; 7.2 % off without), and the Blower pulleys turn. Kind 1 overrides (positional representations, with an empty path) are not applied. -
Suppressed components (addendum 2026-11-11). Each occurrence record’s property block holds a name record
01 05 01 00 00 00 05and, 31 bytes after its end, the record01 07 01 00 00 00 07whose byte is 1 for a suppressed occurrence. That is the layout Instance counts of Inventor assemblies verified with real suppressed components; in the sample set it sits at that place for 2 564 of 2 565 occurrences (all with the byte 0). Suppressed components are left out. -
Not decoded:
- design views other than the one active when the file was saved
(
DesignViewSegment): in the sample set only Buffer Prep Skid shows a difference, a room enclosure hidden in its Default view; - the weld beads of weldments, which are stored in the assembly itself.
- design views other than the one active when the file was saved
(
The exact geometry
PmBRepSegmentholds an Autodesk ShapeManager body (ASM BinaryFile, a fork of the ACIS SAB format). Its records can be read, but tessellating trimmed NURBS, blends and offset surfaces ourselves is weeks of work.- The display tessellation with PN refinement is good enough for a preview, so the exact geometry is not needed now.
Prior art, licences and law
- InventorLoader (FreeCAD add-on, GPL-2.0) reads parts through their ShapeManager body and does not support assemblies. It confirmed the section framing and the UUID naming. No code was taken: TeamCraft is MPL-2.0.
- The Open Design Alliance SDK is paid. Autodesk’s own readers (Inventor View / Apprentice) are Windows-only and not redistributable.
- No Autodesk software or SDK was used, and nothing was decompiled. The layout was found by analysing files, which EU law permits for interoperability (Directive 2009/24/EC Art. 6, UrhG §69e).
- “Inventor” and “ShapeManager” are trademarks of Autodesk; they are named only to say which files are read.
- The Autodesk sample set carries no licence that allows redistribution, so it is not part of the repository. The tests use the two openly licensed NIST parts.
- There is no openly licensed Inventor assembly yet. The assembly reader is tested with assemblies built in the test code.
Decision
inventor-part: read the face meshes ofPmGraphicsSegment(solid bodies, or all faces of a surface-only part), weld them on their exact coordinates and refine them with PN triangles like NX and SOLIDWORKS.inventor-assembly:- compose the preview from the part and sub-assembly files, found by file name;
- use the placements of
AmRxSegment, with the poses a parent gives children of flexible sub-assemblies, and leave out the occurrences thatUFRxDocmarks as not visible or suppressed; - this is the same pipeline as NX and SOLIDWORKS assemblies
(
services/assemblies.rs).
- The thumbnails of Inventor parts and assemblies are rendered from the 3D preview instead of the stored PNG. An assembly none of whose parts are in TeamCraft keeps the stored image.
- Later, if needed: design views, weld beads and the ShapeManager geometry.
Addendum 2026-11-12: Zstandard segments (#271)
A user’s 44 Inventor parts all failed with damaged compressed data. The
files were intact, but every M<id> and B<id> stream below RSeStorage
held a Zstandard frame (magic 28 B5 2F FD, RFC 8878) instead of zlib
(78 5E). Which Inventor version started writing Zstandard is not known:
the version could not be read from those files, and every file we have is
zlib – the Autodesk samples (Inventor 2019 and 2022), NIST 2021 and the
NIST STC set saved with Inventor 2024. No openly licensed Inventor file
with Zstandard segments was found.
What changed in src/invpart.rs:
- the body of a meta or data stream is Zstandard when the magic stands
where the zlib body would start (after the
u8of the meta stream, after the 18-byte header of the data stream), zlib when a valid zlib header stands there (RFC 1950 check bits), and otherwise the first Zstandard magic within 64 bytes before or after that offset; - the search is there because the header length in front of a Zstandard
frame could not be checked against real bytes: if newer files changed the
u8flag or the 18-byte header, the frame is still found; - frames that follow each other directly are read one after the other;
- the output is capped like the zlib output, and the window a frame asks for (which the decoder allocates up front) is capped at 32 MiB;
- the decoder is the pure-Rust
ruzstd(MIT), the same crate the Fusion archives use.
Tests repack a NIST part with Zstandard (every segment, the frame also a
few bytes off) and check that every segment unpacks to the same bytes as
from the zlib original, and that a Zstandard part gives the same preview
mesh as with zlib; damaged Zstandard streams are errors, never panics. The fuzz
target invpart gets a small synthetic seed with Zstandard segments
(fuzz/seeds/invpart/zstd-segments.ipt).
Failed conversions are cached, so the preview cache (3), the thumbnail cache (7) and the shape signatures (3) got new versions: the parts that failed before are converted again.
Known gap: no real Zstandard file is in the tests. If one turns up under an
open licence, it belongs in tests/fixtures/cad with a fixture test, and
the header bytes in front of the frame should be checked against it.
Creo parts: a 3D preview from the stored B-rep
Research notes, 2026-11-08. Findings from reading the files only – no PTC software, no toolkit, nothing decompiled.
Summary
PTC Creo part files (name.prt.N) store no display tessellation of the
body. Unlike NX (JT stream), SOLIDWORKS (display lists) or Inventor
(graphics segments), the only geometry in the file is the exact B-rep:
analytic surfaces with their contours, and every edge sampled as a short
point list in the parameter spaces of both neighbouring faces. That
sampling is what makes a preview possible without a geometry kernel: the
face boundaries come ready-made, each face is ear-clipped in its parameter
plane, mapped to 3D, welded with its neighbours and refined along the
analytic normals (mesh/pn.rs, as for NX and SOLIDWORKS).
Status: src/mesh/creo/ reads planes, cylinders, cones, tori, bicubic
splines, rolling-ball fillets and ruled surfaces. The NIST test part
ctc-01 (Creo 10) gives a closed mesh whose bounding box is the STEP
export’s to 0.05 mm; most other NIST parts are closed as well (see
Verification and Gaps).
Container
Text header, then a table of contents in fixed-width text records:
#UGC:2 PART 2149 1860 800 1 1 15 4100 2023294 00000002cc \ last word: base offset (hex)
#- VERS … \ #- HOST … \ #- CMNM <name> \ more header lines
#-END_OF_UGC_HEADER
#Creo TM 10.0 (c) 2023 by PTC Inc. …
#UGC_TOC 2 32 81 17####… 32 entries of 81 bytes
<name> <off> <size> <usize> <ver> <kind> <x> <crc> <crc> hex, '#'-padded
NEXT_TOC_ENTRY <off> … chain to the next table
A section starts with the line #<name> at base + off and is size bytes
long. The kind digit tells the storage: 9 plain tagged data, a/12
Unix compress (LZW, 1f 9d, 9–16 bit codes, no block mode in the files
seen), 11 binary (JPEG previews, DIV-OL07BL graphics), b scrambled,
c an LZW variant (only DispDataTable, not decoded). The inflated size is
usize.
Sections that matter: VisibGeom (visible B-rep), NovisGeom
(construction geometry, not shown), AllFeatur/FeatDefs (feature tree),
MdlRefInfo (references – useful for assemblies later), THMB_IMG_MAIN
(JPEG, not used as the thumbnail is rendered). SolidPrimdata,
SolidPersistTable and CGFPersistTable contain tri-strips, but only for
the PMI annotations (text, symbols, fonts), never for the body.
The tagged format
A section is a stream of values whose schema is written on first use. Field
N is defined by e0 TT name 00 (type TT; in order of appearance), its
value follows. The writer remembers, for every field, the transition that
followed it last time – how many struct levels close and which field comes
next – and writes only what differs:
| Token | Meaning |
|---|---|
e0 TT name 00 | field definition, then its value |
f1 f2 f3, f4 N | close 1, 2, 3, N struct levels; f0 = explicitly none |
f7 NN | the next field is number NN |
e1 | null; e2/e3 a struct begins |
f8 N, f9 R C, fa K d1…dK | array of N, R×C, K dimensions (remembered per field); fc R reuses the column count |
f8 N f7 NN fb | array of N structs, the elements follow |
e4–e6, e7 N | repeat the previous array element 1–3 or N times |
Types: 00 struct, 01 05 07 08 09 0b integers, 02 double, 03/0a
C string, 04 byte, 06 float32-precision number. Integers: 00–7f,
80–bf + 1 byte, c0–df + 2 bytes, e8–eb + 1–4 bytes, f5 <int>
negated, f6 = −1.
Doubles are IEEE-754 big-endian values with a compressed top:
- one-byte constants:
0f1,10−1,11π,12π/2,132π,143π/2,15−π,16−π/2,180,0d0.25,0e0.5,0c0.1,0b0.06,041−ε,030.125 (05–0aunknown); - near-constants
00/01/02+ constant token + 1/2/3 bytes that replace the lowest bytes of that constant (e.g.00 14 d4= 3π/2 + 2 ulp); - group tokens
19–4a: ten groups of five, the group fixes the top byte (3c 3d 3e 3f 40 bc bd be bf c0), the sub-type the payload (7 bytes; 2 bytes + the last byte repeated; 2 bytes + zeros; 3 bytes; 6 bytes); - class tokens
4b–df: the top 16 bits from a fixed table (tagged::double::class_top), then 6 mantissa bytes; - escapes
e8–efwith explicit bytes (ed= all eight).
With this, 99.9 % of the edge samples of the 17 NIST parts land on the same 3D point from both neighbouring faces (chord check, tolerance 1e-5).
The B-rep in VisibGeom
Sld_VisGeom / active_geom
srf_array[] geom_id, geom_type (34 plane, 36 cylinder, 37 cone, 38 torus, 40 spline,
41 fillet, 42 ruled), orient (±1), envlp (uv box), outline (3D box),
srf_prim_ptr(<kind>) { local_sys[4][3], radius | half_angle | radius1, radius2 | … },
frst_cntr_ptr / next_cntr_ptr { frst_cntr_crv_hdr_ptr, trv }, topol_ref_data { list_id, list_type }
crv_array[] crv_id, type, crv_pnt_dir[2], crv_pnt_arr[n][4] = (u0 v0 u1 v1),
crv_hdr_geom_ptr[0/1], next_crv_hdr_ptr[0/1]
body_array[] body_id, outline, first_geom_ptr, mat_name_ptr …
local_sys: rows 0–2 are the rotation by columns (X, Y, Z), row 3 the origin. All 2 492 frames of the test parts are right-handed and orthonormal.- Plane:
O + u·X + v·Y. Cylinder:O + |r|(cos u·X + sin u·Y) + v·Z. Cone: radiusv·tan(half_angle). Torus:O + (R1 + r cos v)(cos u·X + sin u·Y) + r sin v·Z. Which sign each parameter has, whether an angle starts at 0 or π, and which torus angle isuis not stored; it is resolved by walking from planes with the identity frame across shared edges and keeping the variant whose points coincide with the neighbour’s (brep::resolve). - Edges:
crv_pnt_arrholds the same points in both faces’ parameter spaces (row i of both sides is one 3D point);crv_pnt_dirgives the direction per side,next_crv_hdr_ptrthe next edge of the contour on that side. The sampling is the display resolution (about 18° on circles). - Contours:
trv = 1is an outer contour,trv = −1a hole. One surface record may carry several regions (ctc-01: the four feet of the plate share one plane record, each with its own outer contour). A hole belongs to the region that contains its first point. - Faces of the solid body have
topol_ref_data.list_type = 98(list_id= body id). Surfaces withlist_id = −1are loose or cosmetic and are not meshed. - Twins: a periodic face is split in two halves of which only one has a record; the other is referenced by edges only (ctc-01: 212 beside 210). It shares the primitive of the known curved face behind a straight seam edge (type 1 or 8, constant u) and is rebuilt from the edges that reference it.
- Normals: triangles counter-clockwise in the parameter plane have the
normal ∂u×∂v;
orient = −1turns it. The signed volume of the whole mesh decides the global sense.
Freeform faces
splsrf(40): an interpolating bicubic C² spline (de Boor) through the gridi_points[iu·nv + iv]with knotsu_params/v_params, end tangentsend_u_tangts[e·nv + iv],end_v_tangts[e·nu + iu]and the corner twistsend_uv_deriv[eu·2 + ev]; derivatives are with respect to the real parameters. Edge samples are hit to 8·10⁻⁴ of the face size; the rest is the spline’s own approximation of circular neighbours.fillet_srf(41): a rolling-ball blend. Withs = par_v_0 + v (par_v_1 − par_v_0), centreC = ctr(s), radius vectorA = pnt(s) − Cand circle axisT = tan(s), the face isC + cos u · A + sin u · (T × A); the three curves are clamped C² splines (i_pnts,params,end_tangts). Exact to 10⁻⁷ onctc-02.ruled_srf(42):C1(u) + v (C2(u) − C1(u))withunormalised along both curves; acurve(conic)is the ellipseO + c1 cos t X + c2 sin t Y,t = t0 + u (t1 − t0), acurve(spline)a clamped spline.- Freeform faces carry absolute 3D data and seed the convention resolution
of the analytic faces (
ftc-07has no plane with the identity frame). - Every edge is evaluated once on its most exact neighbour (plane before cylinder … before spline) and both faces use those vertices; edges whose sides disagree by more than 10⁻³ of the model size keep separate vertices, so real errors stay visible as open edges. Edge ends that meet in a contour are unified across the contours.
Verification
- 28 NIST parts (22 FTC/CTC in Creo 4 and Creo 10, 5 STC, 1 HTC); the FTC and CTC definitions come with STEP exports.
ctc-01: 145 body faces (8 twins), 151 regions, closed (0 open edges), bounding box equal to the STEP export, volume 14.7·10⁶ mm³; the PN refinement keeps the mesh closed.- Closed as well (prototype):
ctc-04,ctc-05,ftc-06,ftc-08,ftc-10,ftc-11; a handful of open edges onctc-03(4),ftc-07(8),ftc-09(4);ctc-02keeps about 1 200 open edges on planes with twin halves. Bounding boxes equal to STEP forctc-01–ctc-04,ftc-06,ftc-09;ctc-05,ftc-07andftc-08match Creo’s own body outline rather than the STEP definition. - Prototype and notes:
cad-decoding-notes/creo.mdandcreo-prototypes/next to the repository (not in it).
Gaps
- The mesh has vertices only on the face boundaries: freeform faces show
as facets between their edge samples until interior sample points are
added. The twist layout of
end_uv_derivis plausible but only affects the interior and could not be checked against edges. - Model units: read from
UnitSystemDefsince 2026-11-12 and converted to millimetres (3D preview of Creo assemblies); the NIST partctc-05is stored in inches,ctc-01in mm. - Open edges: the prototype leaves a few open edges on some parts (4–8 on
ctc-03,ftc-07,ftc-09) and about 1 200 onctc-02, where planes are split into twin halves; faces whose stored record is wrong or missing are not rebuilt (the prototype has a least-squares primitive fit for them). Onlyctc-01is a repository fixture and checked by the Rust tests. - Assemblies (
.asm.N): no freely licensed test file was found (NIST has no Creo assembly; the GitHub finds carry no licence). Component names are inMdlRefInfo; placements have not been looked at. - Drawings and other Creo files keep the stored JPEG.
Licences and prior art
The layout was worked out from the files alone, which the EU software directive (2009/24/EC, art. 6) and § 69e UrhG allow for interoperability. No open implementation of the Creo binary format was found, nothing was copied. “Creo” is PTC’s trade mark and is only used to name the format. Only the openly licensed NIST files are committed as fixtures.
3D preview of Creo assemblies
2026-11-12. Findings from reading the files only – no PTC software, no toolkit, nothing decompiled.
Question
Can TeamCraft put together the 3D preview of a PTC Creo assembly
(name.asm.N) from the previews of its parts, as it does for NX,
SOLIDWORKS, Inventor and CATIA? What does an assembly store about its
components, and in which unit?
Samples
No openly licensed Creo assembly was found (search of 2026-11-08: NIST, GitHub, Zenodo, GrabCAD, PTC tutorials, open hardware). The format was decoded on two public GitHub projects without a licence, which are therefore used locally only and never committed:
- a model rocket (
rocket.asm.1: three parts, millimetres, Creo 3); - a scissor jack (
sc9132-2000-1.asm.1: three sub-assemblies, 27 part instances, skeleton models, family-table generics, inches, Creo 7).
The repository tests build synthetic assemblies around the NIST part
ctc-01 (see Tests) until real assemblies can be added.
Findings
-
An assembly uses the part container (
#UGC:2 ASSEMBLY …header, text table of contents, LZW sections) and the same tagged format (3D preview of Creo parts). Its ownVisibGeomholds only assembly-level datums – the geometry is in the part files. -
Section
NeuAsmSld(structneut_assem) lists the direct components inmembers, one struct each:id = 42 type = 2 name = 'MS_MID' family_name = 'MS_MID' e1 = [1, 0, 0] e2 = [0, -0.995, 0.096] e3 = [0, -0.096, -0.995] origin = [34.58, 0, 0]type2 is a part (name.prt.N), 1 an assembly (name.asm.N, including skeleton assemblies); other types are not seen.e1,e2,e3are the images of the component’s x, y and z axes in the parent’s frame andoriginits origin: a component pointplands atorigin + p.x·e1 + p.y·e2 + p.z·e3– the row convention ofnxasm::Component(rotation = [e1, e2, e3]). The other reading (transposed) puts the rocket’s base cone the wrong way round; the composed models match the stored thumbnails.- Only direct children are listed: a sub-assembly is its own file, read the same way, its placements composed with the parent’s.
nameis the model name in upper case without extension; a family-table instance is listed under the generic’s name (SC9132-2010-X_GENERIC), whose file the instance’s geometry comes from.- The same placements appear in
FeatDefsCmp(place_rec_ptr.local_sys,trf_arr.transf) with the component’smodel_ptr;MdlRefInfoholds the referenced models of each placement constraint.NeuAsmSldis enough for the preview.
-
Units are in section
ND:0:UnitSystemDef_L03:1, an object archive parsed by the same tagged reader:string_xarholds the names in order of first use, and the length unit’s name is the string afterOffset(mm,in, …, with its definition after it:0.001000 m,1.000000 in).double_xar[0]is the length scale in metres for non-metric systems (0.0254). Parts carry the same section: the NIST partctc-05is in inches (its STEP export too),ctc-01in mm. Parts and placements are converted to millimetres, so components in different units fit together. -
File names: TeamCraft stores Creo files without the iteration suffix (
bracket.prt.7→bracket.prt,cadformat::creo_iteration), so a component is found as<name>.prtor<name>.asm(lower case), like SOLIDWORKS and Inventor components (services::assemblies).
Part reader: parts without a starting face
The parametrisation conventions of a part’s faces are resolved by propagation from planes with the identity frame (and freeform faces) – 3D preview of Creo parts. Turned parts often have none (all planes are perpendicular to an off-axis axis): every face then kept the default convention and the mesh fell apart (rocket nose: 102 open edges). When a part has no starting face at all, one face (planes first, then cylinders, cones, tori) is now tried with each candidate convention; the propagation that makes the most edge samples coincide is kept, the default convention only loses when another candidate is strictly better. The rocket’s body became closed, its nose went from 102 to 9 open edges. Parts with a starting face are unchanged: seeding their unreached faces as well made two NIST parts worse.
Gaps
- Family-table instances are shown with the generic’s geometry.
- Suppressed or excluded components, simplified representations and
explode states are not evaluated (
NeuAsmSldlists the members of the master representation). - The sample parts from older Creo versions show part-reader gaps: some faces arrive as stubs without data (rocket nose: 2 of 12 faces), and several scissor-jack parts keep open edges or single wrong faces.
- No real assembly is a repository fixture yet.
Tests
Unit tests encode small sections in the tagged format (a writer that
spells out every transition) and check the members, the placement
convention, units and damaged input. A synthetic assembly placing the NIST
part ctc-01 twice (one sub-assembly, rotated, in inches) checks the
composition end to end.
3D preview of SOLIDWORKS parts and assemblies
2026-11-07
Question
Can TeamCraft show native SOLIDWORKS parts (.sldprt) and assemblies (.sldasm) in the 3D viewer without SOLIDWORKS,
eDrawings or a
translation service, and with the same quality as the NX preview (3D preview of NX parts)?
Findings
Files from SOLIDWORKS 2015 on are a sequence of Deflate sections (see
src/swpart.rs). Three of them answer the question.
Parts: the display tessellation
Contents/DisplayListsholds the tessellation SOLIDWORKS draws the part with, serialised as an MFC archive (class definitionsff ff 01 00, class tags with bit 15 set).- One
uoTempBodyTessData_cper body, each followed by itsuoTempFaceTessData_cfaces. - Each face is a set of plain triangle strips. There is no index buffer; the triangle order is the usual strip order, counter-clockwise around the outward normals.
- Each face carries three typed arrays, each with a header of
element size, type (8 = integer, 100 = float), 2, count:- the strip lengths (
u32); - the vertices (
f32xyz, metres); - a unit surface normal per vertex (
f32xyz).
- the strip lengths (
- The face header is
n_triangles, n_strips(SOLIDWORKS 2016/2018); 2024 adds 32 bytes. Holes with a shaded cosmetic thread hold a second mesh on the same cylinder, recognisable because it has no face header; it is skipped.
- One
- Tested files:
- 57 parts decoded completely, 470 000 triangles: 47 parts of a real
gearbox project (SOLIDWORKS 2024) and the 10 NIST parts in
tests/fixtures/cad(2016 and 2018). - The NIST parts agree with their STEP and SAT exports: ctc-01 has an identical bounding box, ftc-11 is within 0.03 mm (chords of a 31.5 mm radius).
- 57 parts decoded completely, 470 000 triangles: 47 parts of a real
gearbox project (SOLIDWORKS 2024) and the 10 NIST parts in
- Watertight:
- Neighbouring faces do not share vertices, so positions are welded (1 µm). After that 45 of 47 parts are closed.
- The rest have micrometre cracks where two faces sample their common edge differently (T-junctions, helical gear flanks). These are invisible in the viewer.
- Fineness: the resolution is the document’s image quality setting, about 15° between neighbouring normals (≈ 24 facets around a cylinder), much like the NX lightweight data. Because every vertex carries the exact surface normal, the same PN-triangle refinement as for NX applies. On NIST ftc-11 against the analytic surfaces the deviation drops from 0.034 mm to 0.004 mm (95 % of the points).
Assemblies: placements in the component tree
swXmlContents/COMPINSTANCETREE(already read for file references) contains:- one
swReferenceper component instance, withswTransform: 16 numbersr00 r01 r02 0 r10 r11 r12 0 r20 r21 r22 0 tx ty tz 1. A point lands atp · R + t(row vectors, t in metres), the same convention as NX; - the flags
swSuppressedandswHidden; - the referenced model, and through it the file (
swFile swPath, the author’s absolute path, so components are found by file name).
- one
- Sub-assemblies are models with their own
swReferencechildren, placed relative to the sub-assembly. Each configuration of the assembly has its own top model;swMostRecentConfiguration="YES"marks the one that was active when the file was saved. - Checked against NIST: the NIST MTC assembly gives the same 36 instances as its NX version. The positions agree with the NX placements (themselves verified against a JT export) within 0.025 mm after one global shift of the origin.
- Checked on the gearbox: a real gearbox assembly lists 128 instances, including two levels of sub-assemblies. Its placed part boxes fill the assembly’s stored bounding box exactly.
- Suppressed components keep their transform and are always marked hidden
too.
swHiddenreflects the display state that was active when the file was saved. - Newer assemblies (format 16000 and up) also store copies of their parts’
meshes (
FaceTessellations/*, same face records, part coordinates). Older ones copy the parts’ display lists into their own. Composing from the part files, as for NX, works for both and stays current when a part changes.
The exact geometry
Contents/Config-N-Partitionis a zlib-wrapped Parasolid transmit file: neutral binary, schema 13006 plus embedded deltas. A test reader parsed all 55 part streams and matched the NIST STEP exports to 1e-15 m.- A full tessellation of it would be possible: planes, cylinders, cones,
tori, spheres, rational B-surfaces, swept surfaces and rolling-ball
blends all occur. With
truckthat is weeks of work, mainly for the blends and the tolerant edges. - The display tessellation with PN refinement is good enough for a preview, so the exact geometry is not needed now.
Licences and law
- No SOLIDWORKS software or SDK was used, and nothing was decompiled. The layout was found by analysing files, which EU law permits for interoperability (Directive 2009/24/EC Art. 6, UrhG §69e).
- “SOLIDWORKS” and “Parasolid” are trademarks of Dassault Systèmes and Siemens; they are named only to say which files are read.
- The files of the gearbox project used for testing are not part of the repository. The tests use the openly licensed NIST files only.
Decision
solidworks-part: readContents/DisplayLists, weld the positions (1 µm) and refine with PN triangles like NX (mesh/sw.rs).solidworks-assembly: compose the preview from the part files found by name, with the placements and the hidden or suppressed flags of the component tree. This is the same pipeline as NX assemblies (services/assemblies.rs).- The thumbnails of SOLIDWORKS parts and assemblies are rendered from the
3D preview instead of the stored
PreviewPNG. An assembly none of whose parts are in TeamCraft keeps the stored image. - Not covered:
- files before 2015 (OLE compound files);
- virtual components (their geometry lives inside the assembly);
- the active display state, if it differs from the saved
swHidden; - the Parasolid geometry.
Mutation testing with cargo-mutants
Question: line coverage says which code the tests run, not which behaviour they check. A test that calls a function and asserts nothing, or asserts what the code happens to return today, keeps the coverage figure high and catches nothing. How does TeamCraft find such tests, at a cost the CI and a single developer machine can carry? Investigated on 2026-11-07.
What mutation testing does
A mutation tester makes small, deliberate changes to the source (a mutant), builds it and runs the tests. If a test
fails, the mutant is caught: some test depends on that piece of behaviour. If every test still
passes, the mutant is missed: the code could be wrong in exactly that way
and nobody would notice. A missed mutant is either a gap in the tests or an equivalent mutant (a change that does not
alter observable behaviour, e.g.
>= → > where the equal case cannot occur).
cargo-mutants (Martin Pool, source at
github.com/sourcefrog/cargo-mutants)
is the maintained tool for Rust. It needs no compiler plugin or nightly
toolchain: it rewrites the source text in a scratch copy of the tree (or in
place), then runs plain cargo build / cargo test. Mutations include:
- replacing a function body with a value of its return type (
Default::default(),0,1,true/false,String::new(),"xyzzy".into(),Ok(Default::default()),None,vec![], …); - swapping binary operators (
==↔!=,<→<=/>/==,&&↔||,+→-/*, bit operators) and deleting unary!/-; - deleting match arms (when a wildcard arm exists) and struct-literal fields that have a default.
Outcomes per mutant: caught, missed, unviable (the mutant does not compile – no information, cheap because only the build runs) and timeout (the mutant made a test hang – usually counts as caught, but shows a loop whose bound matters).
Licence
MIT (repository LICENSE, crate metadata). It is a developer tool, not a
dependency of TeamCraft: nothing is linked into the binary and deny.toml
does not see it. Installing it with cargo install cargo-mutants --locked
or the prebuilt binaries of taiki-e/install-action is the same as for
cargo-fuzz and cargo-llvm-cov.
The optional mutants crate (attribute #[mutants::skip], also MIT) would be
a new regular dependency of the server. TeamCraft does not add it:
exclusions go into .cargo/mutants.toml (exclude_re on the mutant name,
exclude_globs on files) where they are reviewed in one place.
Other tools
| Tool | Notes |
|---|---|
mutagen | compiles all mutations into the binary behind a switch (fast), needs nightly and an attribute on every function; unmaintained since 2021 |
cargo-mutants | source rewriting, stable toolchain, no code changes, --in-diff and sharding for CI; actively maintained (27.0.0 on crates.io at the time of writing) |
line coverage (cargo llvm-cov, already in CI) | complementary: cheap and broad, but blind to missing assertions |
fuzzing (cargo fuzz, already weekly) | complementary: finds panics and hangs on hostile input, not missing assertions on correct input |
How it fits TeamCraft
TeamCraft is one big library crate (src/, about 87 000 lines in 168 files) with three
kinds of tests:
- unit tests in the modules (
cargo test --lib, no database, seconds); - integration tests in
tests/that need PostgreSQL (CI has a service container; locallydocker compose up -d db) and take minutes; - desktop tests in their own Cargo project.
Every mutant costs one incremental rebuild of the test target plus the test
run. With the full suite (minutes, PostgreSQL) per mutant a run over the whole
crate would take days. The pure modules – domain, the parsers and readers (httprange, pagecontext, htmltext,
resultscan, bomtable,
cadformat, …) – are exactly where the rules live that unit tests are meant
to pin down, and their unit tests run in seconds. TeamCraft therefore starts
with:
--libonly (additional_cargo_args = ["--lib"]): no PostgreSQL, no integration tests, no service container in the workflow. It has to be a cargo argument, not a test argument (additional_cargo_test_args): the build step iscargo test --no-run, which otherwise compiles all ~70 test binaries for every mutant – the first trial took 2.5 minutes per mutant that way, against 30 s with--libon both steps;examine_globslisting those pure modules; services, handlers and templates stay out until a second phase decides whether DB tests are worth the runtime (they would need the PostgreSQL service and one database per test run, whichtests/commonalready creates);- a
mutantsbuild profile ([profile.mutants], inheritstest, no debug info), as the cargo-mutants book suggests: less to write and link for every mutant.
Runtime
Measured on the developer machine (12 threads, 15 GB, -j 1): baseline
23 s incremental build + 19 s for the lib tests; then 8–10 s build + 18 s
tests per mutant, about 30 s. The lib tests are more than half of it. The
whole configuration selects ~1 900 mutants: ~16 hours on one machine. On a
4-core GitHub runner a mutant takes longer; 16 shards of ~120 mutants each
stay within a few hours per job. Details per module: first runs.
Pull requests: --in-diff
cargo mutants --in-diff git.diff only tests mutants whose span overlaps a
changed hunk of the diff (git diff origin/<base>...HEAD). Together with
examine_globs a pull request that does not touch a pure module tests
nothing and finishes after the baseline. Pitfalls found in other projects’
workflows: the diff must have the a//b/ prefixes of git diff (no
--no-prefix), the checkout needs the base branch (fetch-depth: 0), and an
empty selection is not an error. Exit code 5 means the diff does not match
the tree.
Full runs: sharding
--shard k/n splits the mutant list deterministically; every shard must use
the same arguments and the same n. The book recommends at least ~10
mutants per shard and 8–32 shards. --baseline=skip saves the unmutated test
run per shard but then needs an explicit --timeout and a job that proved
the baseline green (the CI workflow already does). --in-place mutates the
checkout itself instead of copying it, which is fine on a throw-away runner
and incompatible with -j.
Timeouts
The default test timeout is 5 × the baseline test time, at least 20 s;
with a skipped baseline or --in-place it is 300 s. A build timeout is off by
default (build_timeout_multiplier). Every workflow job also gets a
timeout-minutes, as in the other workflows.
Exclusions
exclude_reon the mutant’s name (src/x.rs:12:5: replace f -> bool with true), e.g.impl Display/fmtbodies andDebugoutput, which no test should pin down;exclude_globsfor generated or trivial files.
Every exclusion carries a comment in .cargo/mutants.toml saying why the
mutants are equivalent or not worth a test.
Exit codes
0 all caught, 2 missed mutants, 3 timeouts, 4 the unmutated tests already
fail, 5/6 a bad --in-diff file, 1 usage, 70 internal. The pull request job
is report-only at first (continue-on-error), so code 2 shows up as a warning
and in the step summary, not as a red check.
How other projects run it
- cargo-mutants itself – sharded full runs on every push (
.github/workflows/tests.yml),--in-diffon pull requests, artifactsmutants.out. - vodozemac (Matrix
cryptography) –
--in-diffon pull requests tomain, path filter on Rust sources,--in-place. - ebman – PR diff limited to
src/**/*.rs,-- --lib, 120 s timeout, missed mutants block the merge. - safe-chains – guards against the silent empty selection: checks the
diff prefixes and counts the selected mutants with
--list --in-diffbefore the run.
Decision
- cargo-mutants (MIT) as a CI tool, no new crate dependency.
.cargo/mutants.toml:examine_globsover the pure, unit-tested modules,--libtests,mutantsprofile, emptyexclude_re(rules are added when a report shows one is needed).- Workflow
mutants.yml: pull requests run--in-diffover the touched examined files, report-only (missed mutants in the step summary and the artifact); a weekly and manual full run sharded over a matrix. No PostgreSQL service while only--libtests run. - Policy: a missed mutant in code a pull request touches should be fixed with
a test that checks the behaviour, or listed as equivalent with a reason.
When the examined modules are clean, the pull request job becomes blocking
and the next modules join (
docs/OPEN_WORK.md).
First runs
cargo-mutants 27.1.0, local, -j 1, --lib, 2026-11-07:
| Module | Mutants | Caught | Missed | Unviable | Time | Gap found |
|---|---|---|---|---|---|---|
httprange | 13 | 12 | 0 | 1 | 10 min (with the field mutants below) | none – its tests already pin every boundary |
util | 40 | 37 → 40 | 3 → 0 | 0 | 22 min | format_bytes was never tested beyond MiB: making the unit loop run one step further (<=, len() + 1, len() / 1) went unnoticed and would index past the unit table (a panic for ≥ 1 PiB); test byte_sizes_stop_at_tib |
resultscan (the 11 field mutants of TemplateInput::validated) | 11 | 0 → 7 | 7 → 0 | 4 | with the above | no test checked what validated returns: dropping the trimmed name, file pattern, method, pick, unit, load case or solver from the result (so ..self stored the raw form input) passed; test template_input_is_stored_normalised |
The tool did what it promises on the first try: two modules with good line
coverage, ten missed mutants, two real gaps – a latent panic and an unnormalised
stored value. Quirks of 27.1 noted on the way: --file adds to examine_globs
instead of narrowing it (use -F), and field-deletion mutants ignore -F/-E.
Sources
- cargo-mutants book: https://mutants.rs (configuration, CI,
--in-diff, sharding, timeouts, exit codes) - example workflow:
examples/workflows/in-diff.ymlin the cargo-mutants repository - vodozemac, ebman, safe-chains: their
.github/workflows/mutants.yml
3D preview of NX parts from their lightweight representation
2026-11-06
Question
Can TeamCraft show a native Siemens NX part (.prt) in the 3D viewer
without NX, a Parasolid licence or a translation service?
Findings
- An NX part is a stream container (
SPLMSSTR, seesrc/nxpart.rs). Next to the NX data (/Root/UG_PART/UG_PART, which embeds the exact geometry as a zlib-compressed Parasolid transmit block) every saved part carries/Root/FastLoad/JT: a short index followed by a complete JT 9.4 file (Version 9.4 JT DM 10.8.0.1) with the tessellation NX uses for fast loading. - Assemblies and drawings have no such stream (checked with the NIST
assembly and with a real project folder: 23 of 27
.prtfiles had one, the rest were assemblies, a drawing backup and one part saved without it). - The JT file holds three levels of detail: one segment of type 7 (LOD 0, the finest) and two of type 6. Each is a Tri-Strip Set Shape LOD element with topologically compressed data, the most involved encoding of the format: 24 integer packets (codecs bitlength, arithmetic, chopper) drive a dual-mesh coder that rebuilds the triangles; coordinates are quantised per axis, the per-corner normals use Deering’s normal code. Coordinates are metres.
- JT 9.5 is published by Siemens as the JT File Format Reference and was
standardised as ISO 14306 (2012); the encoding is documented there.
Open implementations to compare against:
oce-jt(C++, LGPL-2.1, an Open CASCADE fork),jt-java,JTSharp. TeamCraft (MPL-2.0) implements the algorithms from the format reference and copies no code. - The Parasolid block cannot be tessellated without a Parasolid kernel (commercial); no open reader exists.
- LOD 0 is coarse: 16–30 facets around a cylinder, up to 0.45 mm away from the true surface on a 60 mm part. But every corner carries the exact surface normal of the B-rep. With curved point-normal triangles (Vlachos et al. 2001) and creases handled through the cross product of the two face normals, a 3 × 3 refinement brings the deviation on the cylinders of a real part from 448 µm to 10 µm (mean 35 µm → 0.6 µm), and the mesh stays closed.
- A prototype decoded all NX parts of the NIST fixtures and of a real project folder (23 parts, 128 to 9 760 triangles): every mesh is closed (each edge shared by two triangles), consistently oriented, with a positive volume; the normals all agree with the triangle orientation.
Decision
Implement: src/mesh/jt.rs reads LOD 0 of the JT stream of NX parts (JT 9.x only), src/mesh/pn.rs refines it along
the normals up to 3 × 3 (about 500 000 triangles at most). NX parts get the format id nx-part in
the 3D preview; assemblies and drawings keep their preview image.
Not now: stand-alone .jt files (the NIST assembly’s is JT 10.5 with a
different element layout and a scene graph with transforms), the Parasolid
block.
Addendum 2026-11-07: assemblies and thumbnails
The JPEG NX stores in a file is small (about 240 × 180 px) and poor for assemblies. NX parts therefore get a thumbnail rendered from their 3D preview, and assemblies a 3D preview put together from their parts:
- An NX assembly has no
/Root/FastLoad/JTof its own. Its/Root/FastLoad/Structurestream is an NX object serialisation (“OM”: header, object table of offsets and tags). The largest object holds the placement records (two rotation rows and the translation in mm, numbers as a tag byte plus a shortened big-endian double, all divided by 4), the component names (the file names without.prt, upper case) and the name index of every instance. Another object is a hash map from instance to record; instances without a record sit at the origin. - Verified against the JT 10.5 export of the NIST assembly (scene graph, property table and transform attributes decoded for this): all 36 instances match in part, rotation and translation (differences below 0.00001 mm). A real assembly with two levels of sub-assemblies (a pintle injector, 59 placed parts, 0.63 million triangles) renders correctly.
- Sub-assemblies keep their placements in their own file (same format);
placements compose as row vectors (
p · R + t). - The component files are found by file name like file references; their
saved folders are in
/Root/FastLoad/ExternalReferences, but inside TeamCraft the name match is what counts. - Two of three exploration agents worked out the mapping independently (one from the JT ground truth, one from the
.prtalone); a third ruled out the other streams (RMFastLoad: attributes and mass properties,UG_PART: the same transforms keyed by internal object maps).
Decision: src/nxasm.rs reads the components, services::assemblies
composes the preview from the parts’ previews (cache key = content hashes
of everything used), the thumbnail of parts and assemblies is drawn from the
3D preview (mesh/render.rs).
Hidden components (layers)
NX leaves components out of its own picture when they sit on a layer the assembly hides:
- The
Structurestream stores the layer of every component that is not on the default layer, as an int map (instance → layer 1..=256). A scan of a large SVN (5 760.prtfiles) found such a map in exactly three assemblies. - The layer settings are in
/Root/UG_PART/UG_PART, an OM section like the structure stream: oneUGS::LAYER_parameterobject with four masks of 17 32-bit words (selectable, visible, two copies with an extra bit), the work layer and the constants 1, 1. Integers there are signed (one byte = value- 1; 13, 21, 29 or 32 bits). The same layout decoded in all 5 760 files.
- Checked against NX’s pictures: an assembly that hides layers 2–255 leaves out exactly its components on layers 9 and 19 (TeamCraft does the same); an assembly with every layer visible shows its components on layers 17 and 19.
Known gap: a component can also be invisible because the bodies inside its own part sit on hidden layers (a chamber whose own NX picture is empty). The lightweight data does not tell which layer a body is on, so such a component is still shown. Units of assemblies modelled in inches are not handled (all files seen are in mm).
Sources
- Siemens, JT File Format Reference, version 9.5 (2010), and ISO 14306:2012 Industrial automation systems and integration – JT file format specification for 3D visualization.
- A. Vlachos, J. Peters, C. Boyd, J. L. Mitchell, Curved PN Triangles, ACM Symposium on Interactive 3D Graphics, 2001.
- oce-jt, jt-java, JTSharp – compared against only.
- NIST CAD test files in
tests/fixtures/cad/nist(see Openly licensed CAD files).
3D preview of Rhino models (.3dm)
2026-11-08
Question
Can TeamCraft show a Rhino model (.3dm) in the 3D viewer without Rhino,
and what does it take?
Findings
- The
.3dmformat is publicly documented through McNeel’s openNURBS toolkit (C++ source on GitHub): a 32-byte header with the version (1, 2, 3, 4, 5, 50, 60, 70, 80), then nested chunks (u32type code and a 32-bit length, 64-bit from version 50; short chunks carry their value in the length field; chunks whose type code has bit0x8000end in a CRC-32). Tables (properties, settings, layers, block definitions, objects) are sequences of records; every object is a class-tagged record (class UUID + data) followed by its attributes. - What is stored for display:
- Mesh objects – the mesh itself (float vertices, quads/triangles; vertex arrays zlib-compressed with a CRC).
- Polysurfaces (B-reps) – one render mesh per face, saved after the exact geometry, when the file was saved with display data (Rhino 4 and later do this by default; “Save Small” and files written by scripts or the openNURBS examples do not). Most Rhino 2/3 files have none.
- Extrusions – a mesh cache since Rhino 6 (user data in Rhino 5).
- SubD objects (Rhino 7, 8) – never a mesh, only the control net (vertices with tags, edges with crease tags, faces as edge loops).
- Blocks are the assemblies of Rhino: a block definition lists the ids
of its objects (stored in the object table, flagged as definition
objects); a block instance holds the definition id and a 4 × 4 transform (column vectors,
p' = M p). Instances nest. - Visibility: objects can be hidden themselves or through their layer.
- A prototype read all 165 openNURBS and rhino3dm example files (Rhino 1
to 8) without an error. 104 contain surface geometry; 55 of them give a
preview, the other 49 have no stored meshes. All 317 meshes read are
identical to what McNeel’s own
rhino3dmlibrary returns (vertices and faces); units and object attributes agree too. 222 of 261 B-reps with render meshes are closed after welding coincident edge vertices. - SubD: Catmull-Clark subdivision of the control net with creases and corners, then projection onto the limit surface, reproduces the shape of the NURBS version of the same model.
- B-reps without render meshes could be tessellated with the
truckkernel TeamCraft already uses for STEP (its STEP curve and surface types cover NURBS), but that needs the whole B-rep topology and every curve and surface class of openNURBS; left for later.
Decision
Implement src/mesh/rhino.rs with the format id rhino: mesh objects,
B-rep render meshes, extrusion mesh caches, SubD control nets (subdivided),
block instances with their transforms, hidden objects and layers left out,
model units converted to millimetres. Thumbnails are rendered from the 3D
preview; a file without any stored mesh keeps no preview.
Not now: B-reps and extrusions without stored meshes (truck), free-standing NURBS surfaces, linked blocks (external files), colours and materials.
Licences
- openNURBS: the licence file states McNeel’s copyright and a disclaimer but grants no explicit right to modify and redistribute, so the sources were only read; TeamCraft’s reader is written from the format description and copies no code. File formats themselves are not copyrighted.
rhino3dm(MIT) served as a reference in a local prototype only; it is not a dependency.- Test fixtures: the twelve models of
rhino3dm/tests/modelsare MIT licensed and may be committed with their notice; the openNURBS example files have no clear licence and are only used locally. - “Rhinoceros”, “Rhino” and “openNURBS” are trademarks of Robert McNeel & Associates and are named descriptively only.
Sources
- openNURBS –
opennurbs_3dm.h,opennurbs_archive.cpp,opennurbs_mesh.cpp,opennurbs_brep_io.cpp,opennurbs_beam.cpp,opennurbs_subd_archive.cpp,opennurbs_instance.cpp,opennurbs_3dm_attributes.cpp(read only). - rhino3dm (MIT) – reference library and test models.
- E. Catmull, J. Clark, Recursively generated B-spline surfaces on arbitrary topological meshes, Computer-Aided Design 10(6), 1978; H. Hoppe et al., Piecewise smooth surface reconstruction, SIGGRAPH 1994 (crease rules).
3D preview of stand-alone JT files (JT 10)
2026-11-08
Question
Can TeamCraft show a stand-alone .jt file – the lightweight exchange
format of the automotive supply chain (ISO 14306) – in the 3D viewer,
parts and assemblies, in pure Rust? TeamCraft already reads the JT 9 stream
NX keeps inside its parts (3D preview of NX parts).
Findings
- Specification. Siemens publishes the JT File Format Reference free of charge: version 10.0 Rev B (2015) and version 10.5 Rev B (2023). Its licence terms allow writing software that reads and displays JT data and using the documented data structures for that, with a copyright notice; the document itself is not redistributed. Annex B of 10.5 has the codec code, Annex C the hash.
- Test data. The only openly licensed JT 10 file found is the one
already in
tests/fixtures/cad/nist/mtc-assembly/nx/– the NIST box assembly exported by NX (Version 10.5 JT DM 9.8.0.0): 36 instances of 8 parts. NIST names JT only in its test reports; the JT2Go samples and the JT downloads of robot makers have no clear licence. NIST’s STEP files of the box, cover and plate (dataset mds2-2146) serve as reference. - Container. As in JT 9 (80-byte version text, TOC, segments), but the
TOC offset and segment offsets are 64-bit, and segments that are
compressed as a whole (scene graph, metadata, B-rep, PMI) use
LZMA in the
.xzcontainer (CRC-64) instead of zlib. Shape LOD segments are not compressed as a whole. - Mesh. The Tri-Strip Set Shape LOD keeps the JT 9 topology coder
(dual VF mesh, the same 22 symbol arrays, Deering normals), so
mesh/jt.rsis reused. New are:- the third-generation Int32 compressed data packet: bitlength fields with “nibbler”-coded limits (4-bit nibbles plus a continue bit; run widths and lengths in 4-bit fields), a probability context with an escape flag per entry, a move-to-front codec (window of 16), and out-of-band values only when the context has an escape entry – this rule and the 4/4 constants are not in the text of the specification and were found by checking that the code text is consumed exactly;
- one packet for the upper 32 bits of the 8th attribute-mask context (JT 9: two packets of 30 and 4 bits) and the large masks as a plain word array;
- vertex coordinates as raw
f32bits (Lag-1 predicted) or quantised codes; normals as one packet of combined Deering codes (octant, sextant, θ, ψ from the top – as the text of the specification says; the sample code in its Annex B swaps octant and sextant); - NX writes two blocks the specification does not list (a length plus a constant 16-byte id after the vertex bindings, and five bytes before the first packet); the reader recognises them by the length and otherwise follows the specification.
- Validation. Every LOD carries hashes (Bob Jenkins’ lookup2 on the decoded symbols). With the layout above the composite topology hash and the vertex hash match in 24 of 24 LODs of the NIST file.
- Scene graph. The LSG segment holds the nodes (partition, group,
part, instance, range LOD, shape nodes), property atoms and the property
table. A shape node points to its LOD segment through the late-loaded
property
JT_LLPROP_SHAPEIMPL; transforms are 4 × 4 row-vector matrices stored sparsely (a 16-bit mask of the non-identity elements), composedp · A · Mdown the graph – the convention TeamCraft already uses for NX and SOLIDWORKS. Range LOD child 0 is the finest LOD. The part nodes carryJT_PROP_MEASUREMENT_UNITS(“Millimeters” in the NIST file), section and trace-line geometry is marked__PLM_PS_Hidden = 1. - Result. A prototype decoded all 8 parts as closed, consistently oriented meshes with a positive volume; the stored normals agree with the face normals (mean cosine ≥ 0.99). All 36 placements match NX’s own (translations within 6e-6 mm, rotations within 4e-6), the part sizes match NIST’s STEP files (box 152.4 × 101.6 × 73 mm, cover 152.4 × 101.6 × 25.05 mm, plate 120 × 71.16 × 5 mm).
- LZMA in Rust.
lzma-rsis pure Rust, MIT licensed and reads.xzstreams with CRC-64 checks; its output is written into a size-capped buffer so a damaged file cannot make it allocate more than the preview limit. - Prior art:
oce-jt/JT Assistant (LGPL/GPL) only read JT 8 and 9; no code was looked at or copied for JT 10.
Decision
Implement: src/mesh/jt10.rs reads stand-alone JT 10 files (format id
jt): container with LZMA, scene graph with transforms, units and hidden
nodes, LOD 0 of every tri-strip shape, refined like NX parts and placed per
instance; the hashes are checked. The thumbnail of a .jt file is rendered
from its 3D preview. Stand-alone JT 8 and 9 files get a clear message
(no openly licensed test file; their scene graph uses the older element
layout).
Not now: shattered files (a partition that names another .jt file),
polygon-set LODs, colours and materials, PMI, the B-rep segments (XT, ULP,
LWPA).
Addendum 2026-11-12: JT 9.5 assemblies
Until now a stand-alone JT 9 file went to the reader of the JT inside NX parts, which reads the LOD 0 segments without the scene graph: a JT 9.5 assembly showed its parts piled up at the origin.
- Format. The scene graph of JT 9.5 (JT File Format Reference 9.5
Rev A, 2010 = ISO 14306:2012) has the same elements, property atoms and
property table as JT 10; what differs are field widths: version numbers
are
I16(JT 10:U8), transform elementsF32(JT 10:F64), Base Attribute Data has no field final flags, Base Property Atom Data isI16+U32. The TOC has 32-bit offsets (28-byte entries), the scene graph is zlib-compressed with the same segment header as JT 10. - Checked on the JT 9.4 streams of all eight NIST NX parts: their scene graphs parse with this layout and end exactly at the end of the segment; each part has one tri-strip shape node whose late-loaded property points to its LOD 0 segment. They carry no unit property (NX writes metres).
- Test data. No openly licensed JT 9.5 assembly was found (the JT Assistant samples and vendor samples have no clear licence). The tests build JT 9.5 files by the reference around the real shape segment of the NIST hex nut: several instances with translation, rotation and mirror transforms, units, damaged bytes.
Decision: stand-alone JT 9 files with more than one shape occurrence or a
placed one are put together through the scene graph (Layout::Jt9 in
src/mesh/jt10.rs, shapes through the JT 9 reader), in metres unless the
graph names its units. A single part and any file whose scene graph cannot
be read go through the JT 9 reader as before, so their meshes do not
change; the JT inside NX parts is not touched. Known gap: verified only on
synthetic assemblies.
Sources
- Siemens, JT File Format Reference, version 9.5 Rev A (2010), version 10.0 Rev B (2015) and version 10.5 Rev B (2023); ISO 14306. JT is a trademark of Siemens; the JT data format is © Siemens.
- NIST, Design, Manufacturing, and Inspection Data for a Box Assembly
(NX and JT files in
tests/fixtures/cad/nist/mtc-assembly) and Reference Knowledge Graphs of STEP and QIF Data for a Three-Part Box Assembly (mds2-2146, STEP reference).
3D preview of SketchUp models
2026-11-08
Question
Can TeamCraft show a SketchUp model (.skp) in the 3D viewer without
SketchUp, the SketchUp SDK or a conversion service?
Findings
- SketchUp is a polygon modeller: a model is made of planar faces bounded by loops of edges (the first loop is the outer boundary, further loops are holes), plus component definitions and instances of them (groups are definitions with a single instance). There is no separate display tessellation as in NX or SOLIDWORKS – the faces are the geometry and only need to be triangulated. Units are inches, Z is up.
- Two unrelated containers exist, both opening with the same UTF-16 header
(
FF FE FF 0E "SketchUp Model", then the version such as{25.0.575}):- SketchUp 2021 and later (“VFF”): a ZIP archive behind the header.
model.datis a tag–length–value tree (u16 tag, u32 length; about forty tags are containers). Vertices (C409: id, 3 × f64), edges (B80B: start and end vertex), faces (AC0D: normal, loops of coedges referencing an edge and a direction), instances (6419: definition id and a 13-double transform), definitions (7C15) and layers (8C3C: name, hidden byte). Hide flags and layers sit in a commonD007record. The archive also carriesmeta/model_thumbnail.png. - SketchUp 3 to 2020 (“classic”): one uncompressed MFC
CArchiveobject stream with a single store map. Objects cannot be skipped without knowing their class layout, about 30 classes occur (vertices, edges, loops, faces, curves, layers, materials, textures, attributes, definitions, instances, groups, images, guides, section planes, text, dimensions, line styles), and several field sizes and quirks depend on the version.
- SketchUp 2021 and later (“VFF”): a ZIP archive behind the header.
- OpenSKP (github.com/iamahsanmehmood/openskp, MIT) is an open reader and
writer for both containers with ports in C++, Python, Rust, TypeScript, C#
and Dart, and a format description (
docs/BINARY_FORMAT.md, the docstring oflegacy.py). Its Rust port (packages/rust, crateopenskp1.5.0) has no dependencies but is not on crates.io. The crate published there under the same name (0.5.0,hew3d/openskp) is a different project under GPL-3.0 and was not used. - The OpenSKP repository ships 40
.skptest files (MIT) from SketchUp 3 to 2026: 31 classic, 9 VFF, with curves, nested components, scaled instances, layers and hidden geometry; several models are saved in more than one version. - A prototype (Python, outside the repository) with its own VFF decoder
and OpenSKP’s classic walker loaded all 40 files; 35 contain faces (47 495
faces, 247 972 triangles), the other 5 are empty templates or hold only
edges. On the 9 VFF files the own decoder produced exactly the faces,
instances, definitions and – after triangulation and placement – the same
world mesh triangle for triangle as OpenSKP. The same model saved in
several versions (
nesting,arcs,instance_scale_term,legacy_vN) gives identical meshes across both containers. - Instance transforms:
p' = s · (R p + t)with R’s rows(m0 m1 m2) (m3 m4 m5) (m6 m7 m8),t = (m9 m10 m11)ands = m12(SketchUp’s homogeneous scale term). Verified on the models with skewed rotations: with the transposed matrix the window frames of one model fall apart, and another model matches its stored thumbnail from the same view. A mirrored instance (negative determinant) reverses the triangle order. - Triangulation: a simple ear clipper with a hand-made hole bridge failed on about 400 real faces (holes, loops touching themselves). The earcut algorithm (mapbox/earcut, ISC) handles them; the triangle area matches the loop area within 1e-3 on all but a handful of faces whose loops overlap themselves in the source data. Inner loops that overlap each other do occur; then the outer loop is triangulated alone and triangles whose centre lies in an inner loop are dropped (as OpenSKP does).
- Visibility: hidden faces and instances (flag bit 0 of the display flags) and everything on a hidden layer are left out, an instance with its whole subtree; image definitions (textured rectangles) as well. OpenSKP itself reports these flags but does not filter.
- Most SketchUp models are not solids (single faces, open shells, faces turned inside out): only 4 of the 35 meshes are closed. The viewer and the thumbnail renderer have to draw both sides; closedness and volume are no test criteria here.
Decision
Implement src/mesh/sketchup.rs: the VFF container (SketchUp 2021+) with an
own TLV reader on the existing zip crate, an earcut triangulation, placement
of nested components, visibility filtering, units converted to millimetres.
.skp gets the format id sketchup in the 3D preview and a thumbnail rendered
from the mesh. The OpenSKP test files go into tests/fixtures/cad with
their MIT licence (all but one 5.5 MB file).
The classic container (SketchUp 3 to 2020) is decided separately: taking over
OpenSKP’s MIT Rust walker with attribution, or leaving it out for now. A git
dependency is ruled out by deny.toml, and writing an own reader for the
classic stream is not worth it.
Not now: colours, materials and textures, edges without faces, scenes.
Addendum 2026-11-08: SketchUp 3 to 2020
Decision: take over OpenSKP’s Rust port. Its packages/rust folder (crate
openskp 1.5.0, MIT, no dependencies) is kept unchanged in
vendor/openskp (origin, commit and licence in vendor/README.md, listed in
the licence table of the README) and used as a path dependency with a fixed
version – no git dependency, no crates.io package. src/mesh/sketchup.rs
calls openskp::parse_skp only for classic files, catches a panic as a
damaged file, and places and triangulates OpenSKP’s definitions, faces and
instances exactly like those of its own 2021+ reader (layers are matched by
name there). The tests expect the triangle counts and boxes of the prototype
for all 26 classic test files with faces, and the same triangles for a model
saved by SketchUp 2013, 2017, 2020 and 2021.
The crates.io package of the same name (openskp 0.5.0, hew3d/openskp) is
a different project under GPL-3.0 and is not used.
Sources
- OpenSKP, MIT licence, © 2024–2026 Ahsan Mehmood, commit
e7b4674:docs/BINARY_FORMAT.md,packages/python/src/openskp/{_core,legacy,scene}.py,packages/rust/src/(vendored unchanged invendor/openskpfor the classic container; Python modules run unchanged in the prototype for comparison). - earcut, mapbox, ISC licence (algorithm ported).
- No SketchUp installation, no SketchUp SDK; nothing decompiled.
Text from HTML reports (for the assistant, the search and templates)
Question: many FEA/CFD tools write their reports as HTML (a single file, often with embedded CSS and base64 images, or an HTML file with an assets folder). The result values sit in tables. How does TeamCraft get readable text with the table rows intact, in pure Rust, without a browser engine and without letting a hostile file hurt the server? Investigated on 2026-11-07 for #141.
What was wrong before
.html was indexed as plain text: the raw markup (tags, inline CSS, scripts)
went into the search text, and the plain-text rule dropped every decimal
number, so read_file_text returned markup without the values and
propose_result_values had nothing to quote. The extraction templates ran a
crude tag stripper over the raw bytes in the server process.
Candidates
| Option | Licence | Notes |
|---|---|---|
html5ever + markup5ever_rcdom or scraper | MIT/Apache-2.0 (html5ever), ISC (scraper) | the full HTML5 tokenizer and tree builder of Servo: exact, but builds a whole DOM in memory and pulls in a larger dependency tree; tables, hidden elements and the line layout still have to be written on top |
tl | MIT | fast zero-copy parser, not spec-complete; a DOM again |
lol_html | BSD-3-Clause | streaming rewriter (Cloudflare); built to rewrite, not to lay out text |
html2text | MIT | renders HTML to text with table drawing on top of html5ever; table output is box art, wider than needed and hard to quote |
| own bounded tokenizer | MPL-2.0 (ours) | a few hundred lines; only text, block breaks, table rows and the skip rules matter |
Decision
An own tokenizer (src/htmltext.rs) plus encoding_rs, which was already
a direct dependency – no new crate, nothing for deny.toml to judge. A full
HTML5 tree builder is not needed: TeamCraft needs the text, line breaks at
block elements and table rows as lines, and every input byte is hostile. An
own reader is small enough to read in a review, carries the no-panic lints of
the other untrusted-file readers, and is fuzzed (htmltext target).
Rules:
- a table row is one line, cells separated by
|(empty cells keep their column, nested tables give their own rows, block elements inside a cell become spaces); - entities: the named references reports use (units, symbols, Latin and Greek letters) and all numeric ones; invalid code points become U+FFFD;
- skipped:
script,style,iframe,noscript,template,svg,canvas,math,object, comments, and hidden elements (hidden,aria-hidden="true", inlinedisplay:none/visibility:hidden); text that a style sheet hides or colours invisible is not detected (no CSS engine) – the untrusted marker ofread_file_textand the human review of drafts stay the safeguard; - attribute values are passed over without being copied, except
alt(image descriptions are text) and the few that decide about hiding, each at most 1 024 characters – a multi-megabytedata:URI costs a scan, no allocation; - charset: byte order mark,
<meta charset>/http-equivin the first 1 024 bytes (a declared UTF-16 means UTF-8, as in the HTML standard), valid UTF-8, else Windows-1252; an unknown label is ignored; - nothing is fetched or followed (
src,href,@import,<base>,<meta http-equiv=refresh>); MHTML and ZIP-packed reports are out of scope.
Isolation as for PDF: the teamcraft text-extract --format html child reads
the file from stdin (the first 16 MB – more is cut, not refused, so a big
report keeps its first tables), with the time limit, the memory limit of the
previews, a cleared environment, kill_on_drop and the two shared slots. At
most 256 open elements are tracked (deeper ones are flattened) and the output
stops at 200 000 characters.
Templates: the per-group extraction templates of an .html file now run
on the text the child extracted (at most 200 000 characters), not on the raw
bytes –
Maximum stress \| ([0-9.]+) works, and a csv template reads the |
separated rows (| is always the delimiter of an HTML file, quotes are plain
characters, an empty cell keeps its column). The line of a value’s source (report.html:12) is the line in the
extracted text, not in the HTML file.
Plain text, fixed at the same time: text and CSV reports keep their lines
and decimal numbers in the stored text (read_file_text used to show the
search text without decimals). Only geometry stored as text – ASCII STL, OBJ,
KiCad boards and symbols – still drops decimal numbers, as coordinates are
noise in a search. Files uploaded before keep their old text until a new
version is uploaded.
Sources
- WHATWG HTML Living Standard: tokenization, named character references, “determining the character encoding” (prescan of 1 024 bytes).
- Encoding Standard (labels, Windows-1252 for ISO-8859-1) as implemented by
encoding_rs. - The crates’ pages on crates.io for their licences (checked 2026-11-07).
Text from PDF reports (for the assistant and the search)
Question: a CFD or FEA report is usually a PDF. For an agent to propose
result values from it (read_file_text, propose_result_values), TeamCraft
needs the text of a PDF – in pure Rust, with a licence compatible with
MPL-2.0, and without letting an untrusted file hurt the server. Investigated
on 2026-11-05.
Candidates
| Crate | Licence | What it is | Notes |
|---|---|---|---|
lopdf 0.36 | MIT | low-level PDF object model; extract_text is a helper on top | few dependencies; the text helper is basic |
pdf-extract 0.12 | MIT | text extraction with font and CMap handling, built on lopdf | long-established; panics on damaged input |
pdf_oxide 0.3 | MIT OR Apache-2.0 | parser and text extraction with recovery of damaged files, reading order | young (one maintainer), larger dependency tree (about 150 crates with default features, all MIT/Apache/Zlib/Unlicense/Unicode); optional features (OCR, rendering, signatures) are off |
pdf 0.10 (pdf-rs) | MIT | object model, no text extraction | not a text extractor on its own |
pdfium-render, mupdf | C libraries / AGPL | – | excluded: no C dependencies (pure Rust rule), AGPL |
oxidize-pdf | GPL | – | excluded: licence |
Test
Three real documents (LaTeX manuals of 0.3–0.8 MB, a 1.8 MB scanned-text NASA report) and, for each, a copy truncated to 60 %, three copies with 40 flipped bytes, plus 10 bytes of garbage. Release build, text of the first 50 pages, panics caught.
| Case | lopdf | pdf-extract | pdf_oxide |
|---|---|---|---|
| undamaged files (4) | 3 of 4 ok (1 error: ToUnicode CMap), 15–35 ms | 4 of 4 ok, 40–230 ms | 4 of 4 ok, 55–185 ms |
| truncated (4) | 4 errors (cross-reference table) | 4 errors | 4 of 4 ok (page tree rebuilt by scanning) |
| damaged (9) | 3 ok, 5 errors | 5 panics, 1 error, 3 ok | 8 ok, 1 error, no panic |
| garbage | error | error | error |
The text quality of the three agrees where all succeed (the character counts differ by a few per cent: ligatures, hyphenation, spacing).
Decision
pdf_oxide, with default features off except legacy-crypto (no ICC colour
management; the old RC4/MD5 encryption stays supported, because PDFs from
solvers and Office are sometimes encrypted with an empty user password). Reasons: it is the only candidate that returned
text from
truncated and damaged files without panicking – reports that were cut off by an
interrupted upload or a flaky export are exactly the files people have – and it
is as fast as the others. The price is a young crate and a bigger dependency
tree; that is why it does not run in the server process.
PDFs are untrusted input (a “report” can be hostile). The same rules as for the 3D previews apply:
- Never in the server process. The extraction runs in the hidden
teamcraft text-extractchild process with a time limit and the address space limit of the previews (TEAMCRAFT_PREVIEW_MAX_MEMORY_MB); a crash, panic or out-of-memory kill of the child fails that one extraction, nothing else. - Size before opening: a PDF above 32 MB is not opened (the text of a CFD report is small; large PDFs are scans).
- Bounded output: at most 100 pages and 200 000 characters, the same cap as every other extracted text; the result (or the reason for none) is stored with the file version, so a file is extracted once.
- No OCR: scanned PDFs have no text layer; they give an empty text with a note, an agent can still be given the pictures by a person.
- The text is shown to the assistant as data written by users (the existing
untrustedmarker ofread_file_text); proposals from it are never final values (see the simulation guide).
Sources
- crates.io pages and licences of the crates above (checked 2026-11-05).
- The comparison program and its output are described in the table; the documents were local PDFs of the author’s machine, not part of the repository.
One provider interface for the assistant (Anthropic and OpenAI-compatible)
Ask TeamCraft talks to exactly one LLM API today: the Anthropic Messages
API, hard-wired into src/assistant.rs. This page decides how the
assistant gets one provider-neutral API layer with an Anthropic backend
and an OpenAI-compatible backend, so an organisation can use its own key
with OpenAI, Azure OpenAI, Mistral, OpenRouter or Groq, or a local model (Ollama, vLLM, LM Studio). It also decides
which agent features come next and in which
order. Date: 2026-11-05.
Summary
- One API layer, two wire formats. A new module
src/llm/holds the provider-neutral types (conversation, tool specs, reply, stop reason, usage, errors) and one HTTP transport.llm/anthropic.rsholds the Messages API code thatassistant.rshas today (moved, not copied).llm/openai.rsadds Chat Completions (POST {base}/chat/completionswithtools/tool_calls). The tool loop inassistant.rsonly sees the neutral types. The MCP tool registry (mcp::tools) stays as it is. The assistant only consumes it. - Chat Completions first, Responses later. Chat Completions is the
format that every OpenAI-compatible server speaks. OpenAI’s newest models
need the Responses API for tool calling. A stateless Responses backend (
store: false) is therefore a planned third file insrc/llm/. It is not part of the first step. - Configuration stays backwards compatible. With only
TEAMCRAFT_ANTHROPIC_API_KEYset, everything works as before. New:TEAMCRAFT_ASSISTANT_PROVIDER,…_BASE_URL,…_API_KEY(alias of the Anthropic variable),…_MAX_TOKENSand the timeouts. Every variable also works as…_FILE. - The desktop app keeps no API key. Its Ask… dialog already calls
POST /api/v1/assistant, so it gets the new providers for free. ACP stays the path for an agent on the user’s own computer. No shared LLM crate for the desktop: one key on the server, one audit trail, one budget. - Egress: the base URL comes only from the operator’s configuration, never from users or tool output. Internal hosts are allowed (local LLMs are a real use case). Redirects are not followed, so the key cannot be forwarded to another host.
- Features next, in this order: per-user usage limits and feature switches; progress streaming; conversation threads; more page-context quick actions (impact, BOM diff, similar-part check, request summary, simulation triage); a change order drafted from a change request; reading extracted file text; a weekly digest. The rules do not change: the assistant acts with the user’s rights, never approves or releases, and only writes drafts when the user allows it.
What exists today
| Piece | Code | Talks to | Notes |
|---|---|---|---|
| Ask TeamCraft (server) | src/assistant.rs (1 720 lines incl. tests), src/services/assistant.rs, src/web/assistant.rs, POST /api/v1/assistant | Anthropic Messages API, reqwest, no SDK | Transport and Tools traits, fake transport in the tests, a single retry on 429/5xx, limits on steps, tokens and time, audit record assistant.run without content, taint rule for text write tools |
| MCP server | src/mcp/ (tools.rs: 24 tools, 6 prompts) | external MCP clients, teamcraft mcp stdio bridge | the tool registry; definitions() gives JSON Schema inputSchema and readOnlyHint |
| Desktop Ask… | desktop/ui/app.js → desktop/src/api.rs | POST /api/v1/assistant on the server | the dialog text says “sent to Anthropic” |
| Desktop Agent (ACP) | desktop/src/acp.rs | a local ACP agent (Claude Code adapter, Gemini CLI, …) | the agent brings its own provider and key; TeamCraft passes its MCP endpoint with the user’s token |
Everything that depends on Anthropic sits in about 400 lines of
assistant.rs: API_URL, API_VERSION, the fallback beta,
build_request, parse_response, tool_specs (MCP → input_schema),
the tool_result blocks inside run_loop, status_error and
HttpTransport. The error texts (“the Anthropic API rejected …”) and the
docs also name Anthropic. The rest (questions, quick actions, system prompt,
McpTools with its rights and taint rules, limits, the markdown renderer)
does not depend on the provider.
The provider APIs compared
Checked against the official documentation on 2026-11-05 (see Sources).
| Topic | Anthropic Messages | OpenAI-compatible Chat Completions | OpenAI Responses |
|---|---|---|---|
| Endpoint | POST {base}/messages, base https://api.anthropic.com/v1 | POST {base}/chat/completions, base e.g. https://api.openai.com/v1, https://<res>.openai.azure.com/openai/v1, http://localhost:11434/v1 | POST {base}/responses |
| Auth | x-api-key, anthropic-version header | Authorization: Bearer <key>; Azure v1 also takes api-key; Ollama ignores the key | Bearer |
| System prompt | top-level system | first message role: "system" (OpenAI also developer) | instructions |
| Output limit | max_tokens (required) | OpenAI: max_completion_tokens (max_tokens is deprecated and does not work with reasoning models); Ollama documents only max_tokens | max_output_tokens |
| Tool definition | {name, description, input_schema} | {type: "function", function: {name, description, parameters, strict?}} | {type: "function", name, description, parameters, strict?} (flat) |
| Tool calls in the reply | content[] blocks type: "tool_use" with id, name, input (an object) | message.tool_calls[] with id, function.name, function.arguments as a JSON string that may be invalid | output items type: "function_call" with call_id, arguments (string) |
| Tool results | one user message with tool_result blocks (tool_use_id, content, is_error) | one role: "tool" message per call with tool_call_id (Mistral also wants name); no error flag | function_call_output items with call_id |
| Assistant turn passed back | the whole content unchanged; thinking blocks must stay exactly as received | the assistant message with tool_calls | every output item, including reasoning items (encrypted_content) when store: false |
| Parallel calls | on by default; disable_parallel_tool_use | parallel_tool_calls (default true; not on every server or model) | parallel_tool_calls |
| Forced tool use | tool_choice any/tool rejected by Opus 5.5, Sonnet 5.5, Fable 5.1 | required on OpenAI and vLLM ≥ 0.8.3, any on Mistral, not supported on Ollama | required, allowed_tools |
| Stop reasons | end_turn, tool_use, max_tokens, stop_sequence, pause_turn, refusal | finish_reason: stop, tool_calls, length, content_filter; message.refusal | status + output item types |
| Usage | input_tokens (without cache), cache_read_input_tokens, cache_creation_input_tokens, output_tokens | prompt_tokens (includes cached), prompt_tokens_details.cached_tokens, completion_tokens (includes reasoning); local servers sometimes leave it out | input_tokens, output_tokens |
| Errors | JSON {type: "error", error: {type, message}, request_id}; 400, 401, 402, 403, 404, 413, 429 (+ retry-after, none for a spend cap), 500, 504, 529 overloaded | {error: {message, type, code}}; 401, 403, 404 (model), 429 (+ retry-after, x-ratelimit-*), 500, 503; context_length_exceeded as code | same as Chat Completions |
| State | stateless | stateless | stored by default (store, previous_response_id); stateless with store: false |
| Streaming | SSE message_start, content_block_delta (text_delta, input_json_delta), message_delta, message_stop, error | SSE chunks with choices[].delta; tool arguments arrive as string pieces per tool_calls[].index; usage only with stream_options.include_usage | SSE with typed events |
Third-party servers:
- Ollama implements
/v1/chat/completionswith tools, and since 0.13.3 a stateless/v1/responses.tool_choiceis not supported. The key is required but ignored locally. - vLLM needs
--enable-auto-tool-choiceand a--tool-call-parserfor the model. It supportsauto,required, named andnone. Parallel calls depend on the model. - Mistral uses the Chat Completions shape.
tool_choicetakesauto,anyandnone, the tool message carriesname, andparallel_tool_callsexists. - OpenRouter normalises tool calling across models to the OpenAI
shape. It offers both Chat Completions and Responses. Not every model
supports tools: OpenRouter’s model filter is
supported_parameters=tools. - Groq uses the Chat Completions shape. Parallel calls are model dependent (not on the GPT OSS models). Groq also lists a Responses API.
- Azure OpenAI v1: base URL
https://<resource>.openai.azure.com/openai/v1/, noapi-versionany more. Plain OpenAI clients work with the key. The model is the deployment name.
Why Chat Completions first and Responses only later
- Chat Completions is the one format that all the targets speak: OpenAI, Azure, Mistral, OpenRouter, Groq, Ollama, vLLM and LM Studio. It is stateless like the Messages API, so the loop stays the same.
- OpenAI says that Chat Completions “remains supported”, but Responses is
recommended for new projects. Two limits matter. GPT-6 Astra and GPT-6.1 Sol need Responses for tool calling. From
GPT-5.4 on, Chat
Completions supports tool calling only with
reasoning_effort: none. An organisation that wants OpenAI’s newest reasoning models with tools therefore needs Responses. - Responses brings state (
store,previous_response_id) that TeamCraft does not want. Conversation data would sit at the provider, and a follow-up could reach tool results that the user may no longer read. TeamCraft would use it stateless:store: false, all output items (alsoreasoningitems withencrypted_content) sent back unchanged, which is the same pattern as Anthropic’s thinking blocks. With the neutral layer this is one more file (llm/responses.rs, about 250 lines) and fixtures. It needs no change to the loop. - Decision: Phase 2 ships Chat Completions. Phase 3 adds Responses as
TEAMCRAFT_ASSISTANT_PROVIDER=openai-responsesfor OpenAI, Azure and OpenRouter. It is skipped if nobody asks for it by then.
Decision 1: one API layer
Module layout
src/llm/mod.rs provider-neutral types + Provider trait + Transport trait (no network, no DB)
src/llm/http.rs the one reqwest client: timeouts, no redirects, retry-after parsing, error body → LlmError
src/llm/anthropic.rs Messages API: encode/decode, cache_control, fallbacks beta, anthropic-version (moved from assistant.rs)
src/llm/openai.rs Chat Completions: encode/decode, arguments-string parsing, id synthesis, max_tokens field
src/llm/responses.rs (phase 3) Responses API, stateless
src/assistant.rs questions, quick actions, system prompt, run loop over llm::*, McpTools, limits, markdown
src/services/assistant.rs unchanged role: permits, budget, audit
src/llm/ knows nothing about TeamCraft (no Actor, no mcp). It could
become a crate later, but there is no second user for that today.
Neutral types (sketch)
#![allow(unused)]
fn main() {
pub struct ToolSpec { pub name: String, pub description: String, pub schema: Value }
pub enum Turn {
User(String),
/// What the model said. `raw` is the provider's own form and is sent back unchanged
/// (Anthropic thinking blocks, Responses reasoning items); the loop never edits it.
Assistant { text: String, calls: Vec<ToolCall>, raw: Value },
ToolResults(Vec<ToolResult>), // Anthropic: one user message; OpenAI: one tool message each
}
pub struct ToolCall { pub id: String, pub name: String, pub input: Result<Value, String> } // Err = unparsable arguments
pub struct ToolResult { pub call_id: String, pub name: String, pub text: String, pub is_error: bool }
pub struct Request<'a> { pub model: &'a str, pub system: &'a str, pub tools: &'a [ToolSpec],
pub turns: &'a [Turn], pub max_tokens: u32 }
pub enum Stop { Done, ToolCalls, Truncated, Refused, Paused }
pub struct Usage { pub input: u64, pub cached_input: u64, pub output: u64, pub reported: bool }
pub struct Reply { pub turn: Turn /* Assistant */, pub stop: Stop, pub usage: Usage }
pub trait Provider: Send + Sync { // pure: no I/O, so fixtures test it directly
fn encode(&self, req: &Request) -> HttpCall; // path, headers (without the key), JSON body
fn decode(&self, body: &Value) -> Result<Reply, LlmError>;
fn error(&self, status: u16, headers: &HeaderMap, body: &str) -> LlmError;
}
pub trait Transport: Sync { async fn send(&self, call: &HttpCall) -> Result<(u16, HeaderMap, String), LlmError>; }
}
The run loop becomes: encode → send (with retry) → decode → run
tools → push Turns. AssistantError keeps the run errors (Disabled,
Busy, Timeout, StepLimit, TokenLimit) and wraps LlmError for the
rest. The tests keep their fake Transport, which now returns recorded
provider bodies.
Rules that hide the provider differences
- Tool schemas. MCP
inputSchemabecomesinput_schemaorparametersunchanged.strictis not sent: OpenAI strict mode needs every property inrequiredandadditionalProperties: false, and the MCP schemas have optional fields. A unit test keeps every MCP schema inside a portable subset (type,properties,required,description,enum,items,minimum/maximum,default,additionalProperties). This subset is accepted by all the listed servers, and a new tool cannot break a provider by accident. tool_choiceis never sent:autois the default everywhere, and forcing a tool is rejected by Opus 5.5 and not supported by Ollama.parallel_tool_callsis not sent either: the default works, and some servers reject unknown fields. The loop runs the calls one after the other, as today, and returns the results in the order of the calls.- Arguments. OpenAI-style arguments are a JSON string. If they do not
parse, or are not an object, the call gets a tool result with
is_error: “The tool input must be a JSON object”. The run does not fail. Small local models get this wrong often, and the model gets a chance to correct itself. - Call ids. The provider’s id is sent back unchanged. If an
OpenAI-compatible server returns no id, the backend makes one up (
call<step><n>, 9 alphanumeric characters at most, because Mistral rejects longer ids). - Stop. A reply with tool calls counts as
ToolCalls, whatever itsfinish_reasonsays (some local servers answerstoptogether with tool calls).length/max_tokens→Truncated.refusal,content_filteror a non-emptymessage.refusal→Refused.pause_turn→Paused(sent again unchanged, Anthropic only). - Error flag. Chat Completions has no
is_error. The OpenAI backend putsError:in front of the text of a failed tool result. - Usage. Neutral
inputmeans all prompt tokens. For Anthropic that isinput_tokens+ cache read + cache creation, as today. For OpenAI it isprompt_tokens. If a server reports no usage,reported = false: the token budget then cannot count that run, the step limit still applies, and the audit record saysusage_reported: false. - Errors and retries (one table for both). 401/403 →
Auth. 402 and a 429 withoutretry-after(Anthropic spend cap) →Quota(no retry). 429 →RateLimited { retry_after }. 500/502/503/504/529 →Overloaded. 404 →Rejected(usually a wrong model name). A 400 withcontext_length_exceededor “prompt is too long” →TokenLimit. The single retry stays. It waitsretry-after(at most 20 s, otherwise no retry) or the fixed 3 s. User messages say “the AI service”, not “Anthropic”. - Anthropic extras stay in
anthropic.rs: automatic prompt caching (cache_control), thefallbacksbeta for the listed models, andanthropic-version. OpenAI caches prompts automatically, and the other servers do not cache at all.
Desktop
- Ask… in the desktop app calls
POST /api/v1/assistantand needs no change beyond the wording: “sent to the AI provider configured on the server” instead of “to Anthropic”.GET /api/v1/assistantreportsproviderandmodel, and the dialog shows them. - No direct API-key backend in the desktop. A key in the desktop app would be a second copy of the provider code (the desktop is its own Cargo project), and it would bypass the server’s audit, budget and concurrency limit. It would also put an organisation key on every laptop, or make each user bring their own. The server already reaches internal hosts, so a local model works through the server too.
- ACP stays as it is. It is for a coding agent on the user’s own computer that brings its own provider (several ACP agents can use OpenAI-compatible or local models themselves). Its contract with TeamCraft is the MCP endpoint, not an LLM API, so it is not part of this work.
Streaming
- Provider streaming (SSE from the LLM API): no. Answers are a few
hundred words, and most of the 10–60 s of a run go into model steps that
end in tool calls. Parsing the deltas of three wire formats (
input_json_delta, argument pieces perindex, typed Responses events) costs about 300 lines plus fixtures, with little gain.REQUEST_TIMEOUT(180 s) stays below the 10-minute limit for non-streaming calls that Anthropic recommends. - Progress streaming to the user: yes (phase 5). The loop sends events
as it goes:
step,tool(name, ok),answer,error.POST /api/v1/assistantwithAccept: text/event-streamreturns them as SSE. Without that header it answers in JSON, as today. The web page reads the stream withfetchand aReadableStreaminapp.js(no inline JS;EventSourcecannotPOST). Without JavaScript the form still works. The desktop reads the stream in Rust and forwards it as Tauri events, asacp.rsalready does. The user sees “looking up where-used of PRT-000012…” instead of a spinner for a minute. A closed connection still cancels the run through the existingCancelGuard.
Decision 2: configuration
| Variable | Default | Meaning |
|---|---|---|
TEAMCRAFT_ASSISTANT_PROVIDER | anthropic if an Anthropic key is set | anthropic, openai (OpenAI and Azure OpenAI v1: Chat Completions, max_completion_tokens), openai-compatible (any other Chat Completions server: max_tokens, key optional), later openai-responses |
TEAMCRAFT_ASSISTANT_BASE_URL | https://api.anthropic.com/v1 / https://api.openai.com/v1 | API base. The backend adds /messages or /chat/completions. Required for openai-compatible |
TEAMCRAFT_ASSISTANT_API_KEY | – | the key (secret, prefer _FILE). TEAMCRAFT_ANTHROPIC_API_KEY stays as an alias, and setting both with different values is an error. Optional for openai-compatible |
TEAMCRAFT_ASSISTANT_MODEL | claude-opus-5-5 for anthropic, none otherwise | model id or Azure deployment name; required for the OpenAI providers |
TEAMCRAFT_ASSISTANT_MAX_STEPS | 12 | unchanged |
TEAMCRAFT_ASSISTANT_MAX_TOKENS | 16000 | output tokens per model call (today a constant) |
TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT | 180 s | one HTTP call; local models on a CPU need more |
TEAMCRAFT_ASSISTANT_RUN_TIMEOUT | 240 s | a whole run; must be at least the request timeout |
TEAMCRAFT_ASSISTANT_MAX_CONCURRENT | 4 | runs at the same time on the server |
- Enabled means a key is configured, or the provider is set explicitly
to
openai-compatiblewith a base URL. The startup summary line showsassistant=<provider>:<model>@<host>, never the key. - Backwards compatible: an existing installation with only
TEAMCRAFT_ANTHROPIC_API_KEY(+_MODEL,_MAX_STEPS) behaves exactly as before. Theconfig.rstests cover that case. teamcraft assistant check(CLI, likevault check) sends a single small request with one tool. It reports reachability, authentication, whether the model exists and whether it returned a tool call. Many local models are served without tool support, and this is how an administrator finds out before the users do.
Egress and SSRF
- The base URL is operator configuration (environment or
_FILE). It is not set in the admin UI, by users, or by anything a model or a tool returns. That rules out the classic SSRF path, where a user makes the server request an address of the user’s choice. - Internal addresses are allowed:
http://ollama.internal:11434/v1is the use case. The webhook guard (TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE, allow-list) protects against URLs that users enter. It is not reused here. - No redirects (
redirect::Policy::none()): a 3xx is an error, so the key never goes to a second host. No user name or password in the URL. Onlyhttp/https. At startup, a warning is logged when a key would be sent over plainhttpto a host that is not loopback. - The client honours the usual
HTTPS_PROXY/NO_PROXY(reqwestdefault). The deployment docs say which host must be reachable, so firewalls with an egress allow-list can be configured. - Privacy text (guide, web dialog, desktop dialog): data goes to “the AI
provider configured by your administrator (
<provider>,<host>)”. With a local model it does not leave the organisation’s network.
Decision 3: agent features, in order
Rules for all of them, unchanged from today:
- The tools run with the rights of the signed-in user, checked again for every call.
- The assistant never submits, approves, releases, makes anything obsolete, triages or decides. Write tools stay limited to the four draft tools, and only when the user allows drafts and may edit. The taint rule for text write tools stays.
- Every run writes
assistant.runto the audit log (no question or answer text). New fields:provider,usage_reported, the thread id. - No CAD generation.
| # | Feature | What it does | Tools | Permission and audit notes |
|---|---|---|---|---|
| 1 | Usage limits and costs | daily token budget per user (TEAMCRAFT_ASSISTANT_DAILY_TOKENS, 0 = unlimited); admin page with tokens per user and month; “used X of Y today” in the dialog | – | counter table updated in the transaction of the audit record; budget checked before a run, a run that goes over the budget may finish |
| 2 | Feature switches | TEAMCRAFT_ASSISTANT_ACTIONS (which quick actions), TEAMCRAFT_ASSISTANT_ALLOW_DRAFTS=false (turns the draft tools off for everyone) | – | configuration, no admin UI (like the other settings) |
| 3 | Progress streaming | see Streaming | – | the same audit record; a cancelled stream is recorded as cancelled |
| 4 | Conversation threads | follow-up questions (“and which of those are released?”); a thread list per user; delete; automatic expiry (TEAMCRAFT_ASSISTANT_RETENTION_DAYS, default 30) | – | stores only the user’s and the model’s text, not tool results. A follow-up calls the tools again with the user’s current rights, so nothing they lost access to comes back. Only the owner sees a thread; admins see counts, not content. Deleting the user deletes the threads |
| 5 | Page context everywhere | the dialog knows the page: item, revision, change, change request, simulation, BOM comparison; shown as a chip that can be removed | existing get_* tools | the context is only a reference; the tools check the rights |
| 6 | Where-used impact (item page) | “What breaks if I change this?”: multi-level where-used, released parents, open changes, simulations based on it | where_used, replace_preview, list_changes, list_simulations | read-only |
| 7 | Explain the BOM diff (comparison page) | the BOM comparison in plain language: what was added, removed or changed in quantity, and what is unusual | compare_revisions, get_bom | read-only |
| 8 | Similar parts before creating an item (New item form) | “Does this part exist already?”, using the typed name, description and file names | find_similar_parts, find_duplicate_files, search_items | read-only; runs only when the user presses the button, never on typing |
| 9 | Change order from a change request | drafts a change order for an accepted request: affected revisions, impact on dependents, a description with the request number | get_change_request, where_used, compare_revisions, create_change_draft, add_to_change | drafts only (allow drafts + editor); the request must already be accepted by a person. Linking request and change stays a person’s action |
| 10 | Request discussion summary | summarises a long change-request discussion: positions, open questions, decisions so far | get_change_request | read-only |
| 11 | Simulation triage | quick action for the existing simulation_status prompt: which simulations are out of date, and why | list_simulations, get_simulation, compare_simulation_results | read-only |
| 12 | Release readiness, explained | the existing Check before release, but each blocker with the reason and the next step (who has it checked out, which child is unreleased) | release_check prompt, bom_health, get_revision | read-only |
| 13 | Read file text | answers from the text of attached PDFs, STEP headers and specs | new read tool read_file_text over the text that textindex.rs already extracts into the database (migration 0005) (limited to 50 000 characters, page or offset) | read-only; the file’s project group is checked as for downloads; a restricted file taints the run like other restricted reads |
| 14 | Weekly digest | “my week”: reviews waiting, my drafts, impacts, requests assigned to me | my_worklist, list_changes, list_change_requests | on demand first. Sending it by e-mail on a schedule is an opt-in per user, runs with that user’s rights and counts against their budget. Not before 1 and 4 are done |
| 15 | Natural-language search | “steel brackets released last month in ROVER” → a list of results | search_items | read-only. Lowest priority: better search filters help more people, and cheaper |
Features 6, 7, 10, 11 and 12 are mostly a new Action with its prompt, its
context and a button: they need no new tools. 9 and 13 are the only ones
that touch the tool registry. 13 adds one read tool there, which MCP
clients get too.
Evaluation tests
- Scenario tests with a fake provider (
tests/assistant_scenarios.rs, with the test database): a scripted model replies with recorded provider bodies. Each scenario runs for both wire formats. The tests check that the right tools run with the user’s rights. Examples: a viewer gets no draft tools; a restricted read blocks the text write tools; a follow-up in a thread re-reads instead of replaying; the budget stops a run; an unparsable argument string gives a tool error and the run goes on. - Optional live evaluation (
#[ignore], only withTEAMCRAFT_ASSISTANT_LIVE_EVAL=1and a configured provider): a few fixed questions against the fixture data, checked for tool use and key facts (numbers and states, not wording). It is for an administrator who tries a new local model, not for CI.
Decision 4: phases
Each phase is one small PR with tests, fmt and clippy, and docs updated in
the same PR (configuration, guide, README, CONTRIBUTING where relevant).
The direction in docs/OPEN_WORK.md is proofing and structure first, so
phase 1 must not add a parallel path. It moves the code that exists.
| Phase | PR | Content | Migration | Tests |
|---|---|---|---|---|
| 0 | this page | design and decisions | – | – |
| 1 | Extract src/llm/ | neutral types, Provider/Transport, http.rs (no redirects, retry-after), Anthropic code moved out of assistant.rs, run loop on Turns, error texts without “Anthropic”. Behaviour unchanged; assistant.rs shrinks by about 400 lines | – | existing assistant tests moved onto recorded Anthropic fixtures (tests/fixtures/llm/anthropic/*.json: answer, tool use, parallel tool use, pause, refusal, max tokens, errors 429/529/400) |
| 2 | OpenAI-compatible backend + configuration | llm/openai.rs, the configuration table above, teamcraft assistant check, provider and model in GET /api/v1/assistant, privacy wording in web, desktop and docs | – | fixtures for OpenAI, Azure, Mistral, Ollama, vLLM (tool call without id, finish_reason: stop with tool calls, invalid argument string, no usage, context_length_exceeded); config.rs tests for the old and the new variables; the whole loop test suite runs for each Provider |
| 3 | Responses backend (only if wanted) | llm/responses.rs, store: false, reasoning items sent back | – | fixtures |
| 4 | Budget and switches | features 1 and 2, admin usage page | assistant_usage (user, day, input, output, runs; primary key user+day) | budget scenario, admin page renders (every_template_renders) |
| 5 | Progress streaming | feature 3, web app.js + desktop | – | SSE event order with the fake provider; cancel path |
| 6 | Threads | feature 4 | assistant_threads (id, user, title, created, updated), assistant_messages (thread, seq, role user/assistant, text); expiry by a periodic clean-up like the webhook delivery retention | follow-up re-reads; another user gets 404; expiry |
| 7 | Context quick actions | features 5, 6, 7, 8, 10, 11, 12, one PR or two | – | scenario per action; template tests |
| 8 | Change order from a request | feature 9 | – | drafts only, request must be accepted, taint rule |
| 9 | read_file_text tool | feature 13 (MCP and assistant) | – | rights of restricted groups, size limit, taint |
| 10 | Digest | feature 14, on demand, then opt-in e-mail | per-user digest setting (in the notification settings) | – |
Migrations are numbered with the next free number when the PR is written. No existing migration is changed.
Open questions for the user
- Should
openai-responses(phase 3) be planned now, or only when an OpenAI user asks for it? - Default daily budget per user: unlimited (0) or a conservative value, for example 2 million tokens?
- Threads: is 30 days of retention right, and should users be able to turn history off?
Sources
- Anthropic: Messages API,
errors (status codes,
529, spend-cap 429 without
retry-after, thinking blocks passed back unchanged, forced tool use rejected by Opus 5.5/Sonnet 5.5/Fable 5.1, 10-minute guidance for non-streaming calls).
OpenAI: Chat Completions reference
(max_completion_tokens, finish_reason, usage details, arguments may be
invalid JSON, stream_options.include_usage),
function calling
(strict mode rules, tool_choice, Responses function items),
migrating to Responses
(Chat Completions remains supported; GPT-6 Astra and GPT-6.1 Sol need
Responses for tools; store: false with reasoning items).
- Azure OpenAI v1 API
(base URL
/openai/v1/, noapi-version, key or Entra token, Chat Completions for non-OpenAI models). - Ollama OpenAI compatibility
(tools supported,
tool_choicenot supported, key ignored, stateless/v1/responsessince 0.13.3). - vLLM tool calling, Mistral function calling, OpenRouter tool calling, Groq tool use.
- TeamCraft:
src/assistant.rs,src/services/assistant.rs,src/mcp/tools.rs,desktop/src/acp.rs, Ask TeamCraft, MCP server, Configuration.
Sharing UI code between the web UI and the desktop app
TeamCraft has two user interfaces. The web UI renders on the server (MiniJinja templates/, static/style.css,
static/app.js as progressive
enhancement). The desktop app (Tauri 2) renders in the client from REST API
JSON (desktop/ui/index.html, app.css, app.js, about 4,700 lines).
They share only what desktop/build.rs copies at build time. This page
records what is duplicated or diverging today, where a top-level shared/
folder should draw the line, and how to get there in small,
behaviour-preserving pull requests. Date: 2026-11-05.
What exists today
| Concern | Web UI | Desktop app | Shared today? |
|---|---|---|---|
| Design tokens, skins | static/skins.css | same file, copied by desktop/build.rs | yes (copy) |
| Fonts (Montserrat, Oswald, OFL texts) | static/fonts/ | copied | yes (copy) |
| Logos, state icons | media/logos, media/icons | copied | yes (copy) |
| 3D viewer (WebGL2, TCMESH01) | static/viewer.js, auto-mounts on #viewer-canvas[data-src] | same file, TeamCraftViewer.mount(canvas, {load}) | yes (copy), but one classic script with web-only glue at the end |
| Component CSS (badge, menu, table, card/panel, stat, empty, notice, thread, impact group, lifecycle track, assistant text) | static/style.css (1,400 lines) | desktop/ui/app.css (1,000 lines), re-implemented | no – two copies that drift |
| Density | 14/20 px text; --ctl-h, --row-h from skins.css (32 px, data-density="compact" 28 px) | 13/18 px text; own --d-ctl, --d-ctl-sm, --d-row in app.css | tokens shared, but the desktop uses its own set and both set text sizes literally |
| State badges | macros badge, glyph_char, state_glyph: text glyph (✓ ○ × – ◐ ▲) plus label | badge() in app.js: SVG images (Green_tick.svg …) for some states, no glyph for in_work | no – diverging |
Labels (in_work → WIP) | filter_label (Rust) | label() (JS) | no – two implementations of one table |
| Dates, sizes, numbers | filter_dt (UTC, then app.js rewrites <time> into local time), filter_age, filter_num, filter_fixed, filter_filesize | fmtDate (local), fmtSize, fmtNum | no – same rules, two languages |
| Lifecycle stepper | ol.lifecycle-track with done / current / future and screen-reader text | ol.lifecycle-track, only current marked, every state a coloured badge | no – diverging |
| BOM tree | server-rendered table, .lvl, .indent-N, .tree-guide | client tree with twisties, expand-all | no |
| Markdown-lite (assistant answers) | assistant::render_markdown (Rust, escapes everything) | markdownLite() (JS, DOM nodes, never innerHTML) | no – two parsers of one dialect |
| Menus and keyboard | <details class="menu"> + app.js (one open, Esc, ↑/↓), / and Ctrl+K focus search, ←/→ on tabs | moreMenu() with its own keyboard handling, Ctrl+F search | no – same behaviour written twice |
| Line icons | inline SVG in templates and macros (eye, star, lock) | ICONS path table in app.js | no |
| Skin list | domain::SKINS (Rust) | SKINS in app.js | checked by a unit test (src/domain.rs) |
Guards that already exist and keep working:
assets::tests::css_tokens_are_definedreadsskins.css,style.cssanddesktop/ui/app.cssand fails on an undefinedvar(--x).domaintests: skin ids inskins.cssand in the desktopSKINSlist, contrast of every token, identical dark-token blocks..github/workflows/desktop.yml, jobui:node --checkoverdesktop/ui/*.jsandstatic/*.js. The runner’s Node.js only parses the files; the product has no Node.js dependency.- Both apps already use the same URL layout: the server serves
/static/skins.css, and the desktop’sfrontendDistisdesktop/ui, so/static/skins.cssthere isdesktop/ui/static/skins.css(git-ignored copy). A shared file can keep one URL in both apps.
How others do it
- Design-system packages (GitHub Primer, GOV.UK Frontend): one package of tokens plus component CSS with a documented markup contract; server templates (Rails ViewComponents, Nunjucks macros) and client components emit the same class names. The CSS is the shared artefact, not the rendering code. GOV.UK Frontend ships its JS as plain ES modules that enhance server-rendered markup.
- Web components / Lit (used by many Tauri and Electron apps): one component implementation for both, but a server-rendered page then needs client-side rendering or declarative shadow DOM – a second rendering model for the web UI, and shadow DOM fights a shared global stylesheet.
- Tauri apps that load the server’s pages (remote URL in the webview): one UI, but loses offline state, local files and native dialogs, and makes the desktop app’s CSP depend on the server. Not an option for TeamCraft’s check-out workspace.
TeamCraft follows the first model: one CSS, one markup contract, two renderers, and shared JS only where the code is the same in both apps (formatters, widgets that enhance existing markup, the viewer).
Decision: the boundary
Shared (shared/)
shared/
README.md the rules below, the markup contract per component
css/
skins.css tokens and skins (moved from static/, unchanged)
components.css badge, state glyph, lock, priority, button, input,
card/panel, table, tree rows, lifecycle track, stat,
empty, notice/flash, menu pop-up, dialog surface,
thread entry, impact group, assistant text, viewer bar
and legend
fonts/ Montserrat, Oswald + OFL texts (moved from static/fonts)
icons/sprite.svg line icons as <symbol id="…">, used with <use href>
js/
format.js dt (local), age, num, fixed, filesize, label
states.js state → glyph, badge class, tone; priority glyphs
dom.js h(), append(), clear() – text only, never innerHTML
markdown.js markdown-lite → DOM nodes (from desktop markdownLite)
menu.js menu keyboard model: one open, Esc, ↑/↓, outside click
tabs.js ←/→ between tabs
tree.js expand / collapse / expand-all over tree rows
viewer.js WebGL2 TCMESH01 viewer, `export function mount`
test-vectors/ JSON input → expected output, read by Rust and JS tests
Rules for everything in shared/:
- Plain ES modules, no bundler, no transpiler, no Node.js packages.
<script type="module">is allowed byscript-src 'self'in both CSPs (serversrc/lib.rs, desktoptauri.conf.json). No inline scripts or styles, noinnerHTMLwith data, noeval. - No knowledge of where data comes from. Shared JS never calls
fetchor Tauriinvoke; it takes data and callbacks (viewer.mount(canvas, { load })already works like that). So the web’s cookie session and the desktop’s Rust commands stay app-specific. - Enhance, don’t own. Widgets (
menu.js,tabs.js,tree.js) attach to markup that exists – server-rendered on the web, built bydom.json the desktop – and the web keeps working without JavaScript. - Density by tokens. Components use the existing
--ctl-h,--ctl-h-sm,--row-h,--tree-row-hplus new--fs-bodyand--lh-body; the web keeps the defaults (14/20/32), the desktop setsdata-density="compact"and 13/18 px text instead of its own--d-*tokens. One component CSS, two densities. - Tolerant of version skew. The desktop app ships its copy of
shared/and talks to servers of other versions: an unknown state gets the generic label and no glyph, never an exception.
Stays in the web UI
Server-rendered pages and the MiniJinja macros that emit the shared markup;
forms with POST → redirect → GET and flash cookies; CSRF origin check;
chunked uploads in static/app.js; admin pages, setup, OIDC sign-in, the
desktop authorization page (templates/desktop/); the server-rendered
assistant panel; print styles; page layout (top bar, page head, grids).
Stays in the desktop app
The shell (rail, navigator, object tabs, toolbar, status bar, splitter);
workspace, check-out / check-in, local files and the migration dialog;
native file dialogs and progress for packages; the ACP agent panel; offline
and connection state, server switching and token sign-in; Tauri commands (invoke); dialogs with their own focus
management.
Rendering model
The web UI keeps server templates; the desktop app keeps rendering from
JSON. Neither is forced onto the other. What makes them look identical is
the markup contract: shared/README.md lists, per component, the DOM the
CSS expects (for example span.badge.badge-<state> > span.glyph[aria-hidden]
- label). A MiniJinja macro produces it on the web, a small builder in the
desktop (
dom.js+states.js) produces it on the desktop.
Rejected alternatives: desktop views rendered by the server (loses offline work and the desktop CSP), web pages rendered client-side from the API (breaks the no-JavaScript web UI and doubles the API surface the web depends on), web components with shadow DOM (needs the second rendering model on the web and isolates the shared stylesheet).
Rust and JS side by side
Some rules have to exist twice, because the server renders without JS and
the desktop renders without the server: labels, glyphs, number and date
formats, markdown-lite. They are not merged; they are pinned to the same
test vectors in shared/test-vectors/*.json (for example
{"label": [["in_work", "WIP"], ["in_review", "In Review"], …]}):
- Rust unit tests (
include_str!) assertfilter_label,glyph_char,format_bytes,render_markdownagainst the vectors, incargo test --lib. - The CI job that already runs
node --checkalso runsnode --test shared/js/*.test.jswith the same vectors. The test files are not served and not copied into the desktop app.
The skin list already follows this pattern (a Rust test reads
desktop/ui/app.js); it moves to shared/js/states.js-style data checked
the same way.
Serving and cache busting
Server. src/assets.rs embeds shared/ under /static/shared/…. To
avoid a hand-written list going stale, the server build.rs (which already
watches static and media) walks shared/ and writes the asset table for
it into OUT_DIR; assets.rs includes it next to the explicit static/
entries. Test files (*.test.js), test-vectors/ and README.md are
skipped.
Cache busting today appends ?v=<asset hash> in the templates. ES modules
import each other with relative URLs (import { label } from "./format.js"),
which drop the query string. An inline import map would need a CSP hash.
So shared modules are served under a versioned path:
/static/shared/v/<asset version>/js/viewer.js. Relative imports resolve
inside the same versioned directory and are cacheable as immutable; a
request for any other version is served as today with no-cache + ETag,
so an outdated page still works. CSS and the sprite keep ?v=.
Desktop. desktop/build.rs copies shared/ recursively to
desktop/ui/static/shared/ (already git-ignored as part of
desktop/ui/static/), skipping the same test files. No versioning – the
files are bundled. index.html links /static/shared/css/… and loads
app.js as a module.
Workflows. ci.yml builds on any non-doc change, so it covers
shared/**; desktop.yml adds shared/** to its paths filters.
Tests that guard the split
- Same files in both apps: a server unit test walks
shared/and asserts every servable file is an asset at/static/shared/…and nothing else is; a desktop test (cargo test --manifest-path desktop/Cargo.toml) assertsui/static/shared/mirrorsshared/byte for byte after the build script ran. - Tokens:
css_tokens_are_definedgetsshared/css/*.cssadded, and a new check thatcomponents.cssuses no literal colours or sizes outside the density tokens. - Markup contract: a Rust test renders each macro in
macros.htmlwith the vectors and checks the class structure the contract lists; the JS test does the same for the builders with a string-buildingh(). - Parity of rules: the test vectors above, run by
cargo testandnode --test. - Syntax:
node --checkovershared/js/*.js. Node treats.jsas CommonJS without apackage.json, so the check runs asnode --input-type=module --check < "$f"(to be confirmed in the first PR;.mjsis the fallback, which then needs thetext/javascripttype inassets.rs). - No inline code: the existing CSP stays the test – any inline script or style in shared markup fails in both apps.
Licence headers
The repository has no per-file headers today; LICENSE covers it. Files in
shared/ start with a one-line /* SPDX-License-Identifier: MPL-2.0 */
because they are copied into another program’s bundle, where the origin is
otherwise lost. The fonts keep their OFL texts next to them (shared/fonts/OFL-*.txt), which the desktop About dialog
already cites.
A unit test checks the SPDX line on every .css, .js and .svg in
shared/.
Migration plan
Small pull requests, each behaviour-preserving unless it says otherwise, each with before/after screenshots of both apps in the description.
- Folder and plumbing. Create
shared/withREADME.md; moveskins.css,fonts/andviewer.js(still a classic script) into it. Server serves/static/shared/…, build-script asset table,desktop/build.rscopies the tree, CI paths, the “same files” tests, SPDX test. The@font-faceURLs inskins.cssbecome/static/shared/fonts/…; old/static/skins.cssURLs are no longer linked anywhere. No visual change. - Density tokens. Add
--fs-bodyand--lh-body, replace the desktop’s--d-ctl/--d-ctl-sm/--d-rowwith the shared--ctl-h/--ctl-h-sm/--row-hunderdata-density="compact", and replace literal sizes in both app stylesheets. No visual change. components.css, one family per PR: badge, state glyph, lock and priority first; then tables and tree rows; card/panel, stat, empty, notice; menu pop-up; lifecycle track; thread entry, impact group, assistant text, viewer bar and legend. Each PR deletes the duplicate fromstyle.cssandapp.css. The badge PR is the one visible change: the desktop adopts the web’s text glyphs instead of the SVG state icons, so colour plus glyph means the same in both apps; the lifecycle-track PR gives the desktop thedone/current/futuresteps.- Formatters and states.
shared/js/format.js,states.js,dom.js, test vectors with the Rust tests andnode --test. The desktopapp.jsbecomes a module and imports them; the webapp.jsusesformat.jsfor the local-time rewrite of<time>. - Viewer as a module.
viewer.jsexportsmount; the web auto-mount glue moves tostatic/app.js; versioned module path on the server. - Markdown-lite. The desktop
markdownLitemoves toshared/js/markdown.js; Rustrender_markdownand the JS version sharetest-vectors/markdown.json. - Widgets and icons.
menu.js(one keyboard model for<details class="menu">and the desktop “More” menu),tabs.js,tree.js(the web BOM page gains collapse as an enhancement),icons/sprite.svgreplacing inline icon paths in templates and the desktopICONStable.
Not planned: sharing page layouts, the desktop shell, forms, or any data access code.
Open questions
- The desktop’s SVG state icons (
media/icons/Green_tick.svg…): drop them for the text glyphs (recommended, step 3) or keep them as a desktop-only option? - Should a living component gallery exist (a docs page or a hidden
/uipage rendering every component through the macros), so reviewers compare both apps without starting the desktop app?
Sources
- Repository state at
f8f5322:src/assets.rs,src/templates.rs,templates/macros.html,static/*,desktop/build.rs,desktop/tauri.conf.json,desktop/ui/*,.github/workflows/desktop.yml. - GOV.UK Frontend – Nunjucks macros and ES modules over one component CSS: https://frontend.design-system.service.gov.uk/
- Primer (GitHub) – shared CSS for Rails view components and React: https://primer.style/
- MDN – JavaScript modules,
<script type="module">, import maps and CSP: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules - Tauri 2 –
frontendDistand the CSP configuration: https://v2.tauri.app/security/csp/
Supersede, replace and alternate parts
Feature 6 of the work plan asks for supersede/replace and alternate parts next to the item copy and the multi-level where-used that are already done. This page records how other systems model these relations and what TeamCraft adopts. Date: 2026-11-05.
How other systems do it
| System | Interchangeable parts | Replacement of an outdated part | Notes |
|---|---|---|---|
| PTC Windchill | alternate (global: B may be used instead of A in every assembly) and substitute (local: only on one BOM usage) | replacement links and mass change “replace in where-used” | alternates can be one-way or two-way |
| Aras Innovator | Part Alternate relationship on the part, BOM Substitute on the BOM line | supersession through a new part plus change process | alternates are not revisioned with the BOM |
| ERPNext | Item Alternative document with a two-way flag; a BOM line opts in with Allow Alternative Item | none built in | the alternative is offered when material is short |
| SAP S/4HANA (MM/PP) | alternative items in the BOM (groups with priorities and usage probability) | discontinuation with a follow-up material: MRP switches to the successor when the stock is used up | discontinuation is a property of the outdated material |
Common ground:
- Two different relations. Alternate means “form, fit and function are the same, either may be built in”; it is a statement about the two parts and does not change any BOM. Supersede means “do not use A for new designs any more, use B”; it has a direction, and an outdated part has at most one successor.
- Global vs. local. A global alternate is valid wherever A is used; a substitute is valid only in one assembly. Global alternates cover the common case (second source of a purchased part, an equivalent standard part) and are cheap to keep; local substitutes need a relation per BOM line.
- Replacing in assemblies is a mass change on the where-used list. Assemblies that are still in work are changed directly; released assemblies keep their history and need a new revision (usually through a change order).
- Alternates and supersession are item-level and not frozen with a revision: the knowledge “B can replace A” stays true for old assemblies too.
What TeamCraft adopts
- Alternates as an item-level relation
item_alternates(A → B, with a note). Adding one creates both directions by default (“two-way”); a one-way alternate is possible. Shown on the revision page of both items, in the REST API and the MCP item details. Editing needs edit rights in the group of A. - Supersession as
item_supersessions(A superseded by B, note, who, when): at most one successor, no chains that lead back to A. The pages of A and B show it, the BOM marks superseded children, and adding a superseded item to a BOM warns. Superseding does not change the lifecycle state of A – making its revisions obsolete stays the change process’s job. - Replace in assemblies: from the where-used page of A, replace A by B in every parent that is in work and not checked out by somebody else (one transaction, quantity, find number and notes kept, audit per line). Released parents and parents in review are listed as “needs a new revision”; parents checked out by others and parents in hidden groups are reported, not changed. A preview shows the plan before anything is written.
- Multi-level where-used in the web UI: a where-used page with a level
selector (the API and MCP had
levels=Nalready), the starting point for the replace action.
Not adopted (possible follow-ups)
- Local substitutes per BOM line, priorities and usage probabilities of alternative groups (SAP).
- Automatic switching to the successor when stock runs out – TeamCraft keeps no stock.
- Replacing inside released assemblies by revising them automatically; this stays a decision of the change process.
Sources
- ERPNext manual, Item Alternative (docs.erpnext.com, manufacturing module).
- PTC Windchill help, Alternate and substitute parts and replacement parts.
- Aras Innovator documentation, ItemTypes Part Alternate and BOM Substitute.
- SAP Help Portal, Discontinuation of materials / follow-up material in MRP.
Jira connector for change requests and change orders
Many engineering teams plan and track their work in Jira. This page looks at what Jira’s REST APIs offer, how other PDM/PLM systems connect to Jira, and how TeamCraft could mirror its engineering change requests (ECR) and change orders (ECO) as Jira issues. Date: 2026-11-05.
Summary
- Both Jira Cloud (REST API v3) and Jira Data Center (REST API v2) offer
everything a connector needs: create and update issues, run workflow
transitions, add comments, labels and remote links back to TeamCraft.
A plain HTTPS client is enough (
reqwest+serde, which TeamCraft already uses). There is no SDK to license, and no Atlassian app is needed. - Authentication: Cloud uses an e-mail address plus an API token (HTTP Basic), ideally the token of a dedicated service account. Data Center uses a personal access token (Bearer). Both are a single secret per connection.
- Recommended: one-way first (TeamCraft → Jira), sent from an outbox
shaped like
webhook_deliveries. In TeamCraft the change process (reviews, four-eyes release) stays the single source of truth. Jira only shows it. Jira → TeamCraft (webhooks) comes later and is limited to comments and links, never approvals or releases. - Each connection gets an explicit project-group scope. Only changes and requests of the allowed groups go to that Jira project, so data from restricted groups never leaves TeamCraft by accident.
Jira REST APIs
| Topic | Jira Cloud (v3) | Jira Data Center (v2) |
|---|---|---|
| Base URL | https://<site>.atlassian.net/rest/api/3/…; scoped API tokens go through https://api.atlassian.com/ex/jira/<cloudId>/… | https://jira.example.internal/rest/api/2/… |
| Auth for a server-side integration | Basic e-mail:API token (passwords are deprecated); OAuth 2.0 (3LO) or Forge/Connect apps are meant for distributed apps | personal access token as Authorization: Bearer (Jira 8.14+), optional expiry, can be disabled by the admin |
| Create / edit issue | POST /issue, PUT /issue/{key} | same paths under /rest/api/2 |
| Status change | GET /issue/{key}/transitions (IDs depend on the workflow), then POST …/transitions {"transition":{"id":…}}; 204 on success; screen fields go in fields | same |
| Link back | POST /issue/{key}/remotelink with globalId. A second POST with the same globalId updates the link (upsert), status.resolved shows it as done | same (available since Jira 5.0) |
| Rich text | description, comments and textarea custom fields in ADF (JSON document). API v2 on Cloud still takes wiki markup | wiki markup strings |
| Search | GET/POST /search/jql with nextPageToken. The old /search (offset paging, total) has been removed | /search with startAt/maxResults |
| Custom fields | customfield_NNNNN; IDs and required fields differ per project and issue type (GET /issue/createmeta/{project}/issuetypes/{id}) | same |
| Own metadata on an issue | issue entity properties (PUT /issue/{key}/properties/{k}, up to 32 KB of JSON; searchable by JQL only when indexed by a Connect/Forge app) | same |
| Idempotency | no idempotency key on POST /issue; a retry after a timeout can create a duplicate | same |
Rate limits (Cloud). Jira Cloud enforces three independent limits: an
hourly points quota (1 point per request plus points per returned object),
per-second burst limits per endpoint (100 GET/POST, 50 PUT/DELETE per second),
and per-issue write limits (20 writes per 2 s, 100 per 30 s). The quota
enforcement that started on 2026-03-02 applies to Forge, Connect and OAuth
apps, not to API-token traffic. API-token traffic is still subject to the
burst and per-issue limits. A 429 response carries Retry-After and
RateLimit-Reason. Atlassian recommends waiting at least Retry-After,
then exponential backoff with jitter, and retrying only idempotent requests.
At TeamCraft’s volume (a few events per change) this is not a real limit.
The outbox just has to honour Retry-After.
Webhooks from Jira.
- Cloud admin webhooks: a Jira administrator registers them in the UI or
through
/rest/webhooks/1.0/webhook. They can carry a JQL filter and an optional secret. Requests are then signed withX-Hub-Signature: sha256=<HMAC of the raw body>, the same scheme TeamCraft uses for its own webhooks. - Dynamic webhooks (
/rest/api/3/webhook) are available only to OAuth and Connect apps. They are limited to 5 per OAuth app and user, and expire after 30 days unless refreshed. - Jira retries failed deliveries up to 5 times, roughly 5–15 minutes apart.
Duplicates can happen; deduplicate them with
X-Atlassian-Webhook-Identifier. - Data Center: an administrator registers webhooks under System → WebHooks,
also with an optional secret (HMAC-SHA256 in
X-Hub-Signature). Since Jira 10 they are sent asynchronously.
Either way, a Jira administrator has to set up the incoming direction by hand. An API token alone cannot do it.
Lifecycle of Data Center. Atlassian ends Jira Data Center on 2029-03-28 (end of sale 2028). Self-hosted users will
nevertheless run it for years, and
the API is almost the same. Supporting both costs one flavor switch (v2/v3 path, wiki markup vs ADF, Basic vs Bearer,
search paging).
How other PDM/PLM systems integrate
| System | What is synced | Direction | How |
|---|---|---|---|
| Duro | creates a Jira ticket for every new change order | one-way (Duro → Jira) | built-in, via the integration platform Prismatic |
| Arena (PTC) | ECOs and quality processes; Jira issues are created and updated as the workflow progresses | Arena → Jira (templates in “Arena Connect”, 2026.2) | built-in connector; also the commercial PROSTEP OpenPDM connector (two-way) |
| PTC Windchill | change requests, problem reports, change notices ↔ issues | two-way | commercial third-party tools: OpsHub (copies fields, comments, attachments) or Sodius OSLC Connect (live links, no copies) |
| PTC Codebeamer (ALM) | work items ↔ issues, polling as often as every 2 minutes | two-way | PTC integration plus third-party connectors (OpsHub, Nanga) |
| Aras Innovator | items ↔ issues with field mapping, comments, attachments | two-way | commercial OpsHub app; community posts describe custom code |
| Onshape | no documented native Jira integration; release candidates can wait for open tasks, webhooks notify external apps of release state changes | – | public webhooks and REST API for custom integrations |
| OpenBOM | no documented Jira integration found; change requests and change orders are built in | – | – |
Observations:
- The built-in integrations (Duro, Arena) are one-way: the PLM system’s change object creates and updates a Jira issue. Two-way sync is mostly sold as separate middleware (OpsHub, PROSTEP), with conflict policies per field. That alone shows how much work it is.
- The link-based approach (Sodius/OSLC) avoids duplicate data. Jira shows a link and a status, and the details stay in the PLM system.
- Pitfalls reported or implied by these tools are workflow transition IDs that differ per project, required custom fields per issue type, duplicate issues after retries, conflicting edits in two-way sync, and permission leaks when Jira users see data they could not see in the PLM system.
Licensing and feasibility
- The REST APIs are publicly documented. Calling them requires no Atlassian licence or SDK beyond the customer’s own Jira. An MPL-2.0 client of a documented HTTP API raises no licence question.
- No new heavy dependencies. TeamCraft already has
reqwest,serde_jsonand HMAC-SHA256 (webhooks::hmac_sha256/verify_signature, which can verify Jira’sX-Hub-Signaturedirectly). ADF for plain paragraphs, a link and a short bullet list is a few dozen lines ofserde_json::json!. - OAuth 2.0 (3LO) would require every self-hosting admin to register their own app in the Atlassian developer console, and to handle a callback and rotating refresh tokens. That is not worth it for a server-to-server connection. The connector uses an API token or PAT instead. Atlassian recommends OAuth for distributed apps, but TeamCraft is software the customer runs themselves, with a token the customer’s own admin issues for a service account. Forge/Connect apps are hosted on or registered with Atlassian, so they are out of scope.
- Testing needs no live Jira: a fake HTTP server in the tests (like the existing webhook delivery tests) is enough.
Recommended design for TeamCraft
What maps to what
| TeamCraft | Jira |
|---|---|
change request ECR-000123 (services/requests.rs) | issue of a configurable type (e.g. Task or a custom ECR type) |
change order ECO-000045 (services/changes.rs) | issue of a configurable type; optionally linked to the request’s issue (issue link “relates to”), or a sub-task |
| title, description, reason, priority, requester | summary ECR-000123: <title>, description (ADF / wiki markup), priority (mapping), label teamcraft + teamcraft-ecr-000123 |
| affected items / revisions | bullet list in the description (number, revision, name), each linking to TeamCraft. Only the items of the allowed groups are listed |
| TeamCraft page | remote link with globalId = teamcraft:<instance>:change/<uuid>, title = number + status, status.resolved when released, implemented, rejected or cancelled |
| status change | Jira transition by target status name, mapped per connection |
| reviews, decisions | a comment (“Approved by …”, “Rejected: |
Default status mapping, editable per connection:
| TeamCraft state | Jira status (default) |
|---|---|
ECR new, in_triage, deferred | To Do |
ECR accepted | In Progress |
ECR implemented | Done |
ECR rejected, withdrawn | Done (+ resolution “Won’t Do” if the transition screen allows it) |
ECO draft, rejected | To Do / In Progress |
ECO in_review | In Review (falls back to In Progress) |
ECO released | Done |
ECO cancelled | Done (“Won’t Do”) |
The connector looks up the available transitions of the issue and picks the one whose target status has the mapped name. This keeps it independent of workflow-specific transition IDs. If none exists (the workflow is different), it writes a comment and records a warning on the delivery instead of failing forever.
Events
These are the existing WebhookEvents, so the same emit call sites are
reused:
change_request.created→ create the issue + remote link.change_request.decided→ transition + comment.change.submitted/change.approved/change.rejected/change.released→ create the issue on first contact, transition and comment.- Still missing for complete mirroring (to be added as webhook events too): ECR comment added, request linked to a change order, change created, change withdrawn, reopened or cancelled, request implemented. Adding them also benefits the generic webhooks.
Delivery: building on the webhook outbox
- New tables
jira_connections(name, flavorcloud|datacenter, base URL, Jira project key, issue types for ECR/ECO, status and priority mapping, label, active flag, project-group scope) andjira_links(connection, TeamCraft object id, Jira issue id + key, last synced status). jira_deliveries, written in the event’s transaction likewebhook_deliveries(same claim withFOR UPDATE SKIP LOCKED, lease, exponential backoff with jitter,MAX_ATTEMPTS, retention). Deliveries of one object are processed in order: claim only the oldest open delivery per (connection, object), so a transition never overtakes the create. The per-issue write limit makes this sensible anyway.- The sender does not post the stored payload. It reads the current state when it sends, so retries and late deliveries converge on the latest state, and an item moved to a group outside the scope is no longer sent.
- Idempotent create:
- Before
POST /issue, look upjira_links. - After a timeout or 5xx, search by the unique label (
labels = "teamcraft-eco-000045") before creating again. - Remote links are upserts by
globalId.
- Before
- A 429 response reschedules the delivery after
Retry-After. A 401/403 response pauses the connection and notifies the admins (the existing error notification). A 400 response (missing required field) is a permanent error with Jira’s message stored, and admins can retry it as with webhooks today. - SSRF guard: reuse
TargetPolicyand the checking resolver. An on-prem Jira is usually on a private address, so the admin adds its host toTEAMCRAFT_WEBHOOK_ALLOWED_HOSTS(or a separateTEAMCRAFT_JIRA_ALLOWED_HOSTS, an open question). Redirects are not followed, and the connection base URL is fixed per connection, never taken from payloads.
Configuration and secrets
- The connection is configured by an admin in the UI and stored in the
database. The secret is not stored there. Each connection names an
environment variable (via
config.rs, also asNAME_FILE, e.g.TEAMCRAFT_JIRA_<NAME>_TOKEN_FILE=/run/secrets/jira_token, plus…_EMAILfor Cloud). This fits the rule that secrets live only insecrets/, and nothing secret is ever shown in the UI or written to the audit log. - An alternative is to store the token encrypted in the database (like the
webhook secret). That needs a key in
secrets/, so it only moves the problem. The env/file approach is simpler for the MVP. - Document everything in
docs/src/admin/configuration.mdand a newdocs/src/admin/jira.md.
Admin UI
The page /admin/jira lists the connections. An admin can:
- edit a connection, with the project-group scope as checkboxes;
- use Test connection, which calls
GET /myselfand the project’s createmeta and shows missing required fields and unknown status names; - see each connection’s deliveries with status and error, and retry them (same layout as the webhook deliveries);
- use Sync now for one ECR or ECO;
- read the Jira issue key, which is shown as a link on the ECR and ECO pages.
Read permissions and project-group scope
- Each connection holds an explicit list of project groups, with no “all” default. A change request or order is synced only when all its affected items belong to allowed groups. Mixed changes are either skipped (the default) or synced with only the allowed items listed. This is a per-connection setting, and the open question is which default to use.
- A request without items (possible for an ECR) belongs to the groups chosen by the requester or is not synced until it has items.
- The rule is checked again at send time, because items can move to other groups and the read-permission feature being added can change who may see what. Once an object falls out of scope, no more updates are sent. The existing Jira issue stays, and the admin is told about it.
- Payload contents are deliberately small: number, title, description, status, item numbers and names, links. No file contents, no file names of other groups, no user e-mail addresses.
Phases
MVP (one-way, TeamCraft → Jira):
jira_connections(Cloud and Data Center), secrets via*_FILE, admin page with Test connection.- Explicit project-group scope per connection, checked at queue time and at send time.
- ECR and ECO → issue creation (configurable issue types), summary with number, short description with affected items and link.
- Remote link back to TeamCraft, upserted by
globalId, and the Jira key shown in TeamCraft. - Status mapping by target status name, configurable per connection.
- Comments for decisions, approvals and rejections.
- Outbox
jira_deliverieswith SKIP LOCKED, ordered per object, backoff,Retry-After, SSRF allow-list, idempotent create via label. - Audit records for connection changes; delivery list and retry in the UI.
- Tests with a fake Jira HTTP server, plus docs.
Later:
- Incoming Jira webhooks (admin-registered, HMAC-verified, deduplicated): Jira comments appear on the ECR, and resolving the Jira issue is shown as a hint. Jira never approves or releases anything.
- Raise an ECR from Jira (a webhook on a label or issue type, or a link in Jira opening the TeamCraft form prefilled).
- Link existing Jira issues to an ECR by key; JQL-based import.
- Custom field mapping (e.g. ECO number into a custom field).
- Sync attachments only on explicit request.
- Several Jira projects per group, e.g. routed by item type.
- OAuth 2.0 (3LO) only if a hosted or multi-tenant use case appears.
Open questions for the user
- Cloud, Data Center or both from the start?
- Should change orders become their own issues, or sub-tasks or comments of the request’s issue?
- What should happen to changes that mix allowed and restricted groups: skip them, or sync only the allowed items?
- Should the Jira host go into the existing webhook allow-list or a separate one?
- Is one-way enough for the MVP, or are Jira comments in TeamCraft needed early?
Sources
- Jira Cloud: Basic auth for REST APIs
- Jira Cloud REST API v3
- Jira Cloud: rate limiting
- Jira Cloud: webhooks
- Jira Cloud: entity properties
- Atlassian Document Format
- Run JQL search query using Jira Cloud REST API (KB,
/searchremoved,/search/jql) - Atlassian Community: scoped API tokens via api.atlassian.com/ex/jira
- Jira Data Center: personal access tokens
- Jira Data Center: managing webhooks
- Jira Data Center: REST API for remote issue links
- Atlassian: Data Center end of life
- Duro changelog: Jira integration, Duro product update (Prismatic)
- PTC: Arena Connect announcement, PROSTEP: Arena and Jira
- OpsHub: PTC Windchill integration for Jira, Sodius OSLC Connect for Windchill and Jira
- PTC Codebeamer integrations, Codebeamer and Jira (Marketplace)
- OpsHub: Aras integration for Jira
- Onshape: tasks and release management, Onshape: sync releases and revisions
- OpenBOM: change management
Previews of native CAD files (Inventor, Solid Edge)
Question: how can TeamCraft show previews of Autodesk Inventor (.ipt,
.iam, .idw, .ipn) and Siemens Solid Edge (.par, .psm, .asm,
.dft, .pwd) files – the way other PLM systems do? Investigated on
2026-11-04.
How other PLM systems do it
| System | 2D thumbnail | 3D view |
|---|---|---|
| Autodesk Vault | Inventor writes a thumbnail into the file when saving (512 × 512 since Inventor 2023); Vault does not render anything, it extracts that image at check-in | visualization files (DWF/DWFx) published at check-in by the Inventor add-in, or later by the Job Processor, which runs a licensed Inventor |
| Siemens Teamcenter with Solid Edge | the preview stored in the file | the Solid Edge integration (SEEC) writes JT files through Solid Edge at check-in – Siemens’ recommended practice; the alternative are server-side translation services (Dispatcher) with licensed translators |
| SolidWorks PDM, PTC Windchill (for comparison) | the preview stream of the file | conversion tasks or CAD workers that run the licensed CAD program |
Open-source tools, e.g. cad-preview (MIT, JavaScript) | read the preview image embedded in the file: Inventor files are OLE compound documents with a PNG in a stream, Solid Edge files OLE compound documents with a Windows DIB thumbnail; old SolidWorks files have a PreviewPNG stream, newer ones a 640 × 480 PNG in their own container | none |
The common pattern:
- the thumbnail is the image the CAD program stores in the file – the PLM only reads it;
- a 3D view is always produced by the CAD program itself or by the vendor’s translator – on the user’s computer at check-in (Inventor add-in, SEEC) or on a conversion server that holds a licence (Job Processor, Dispatcher).
What is possible for an open-source project
| Option | Feasible? | Decision |
|---|---|---|
| Read the embedded preview image on the server | yes – the container is the OLE compound file format documented by Microsoft (MS-CFB, MS-OLEPS); only the image is read (PNG, or a DIB turned into a BMP), no proprietary geometry | implement – pure Rust with the cfb crate (MIT) |
| Read the native geometry on the server for a 3D view | no – the formats are undocumented; readers exist only as vendor SDKs (Inventor Server / Design Automation, Parasolid, Solid Edge SDK) under commercial licences that cannot be redistributed with an open-source project, and reverse engineering is excluded by the project rules | skip – impossible due to licensing |
| Conversion server with a licensed CAD program (like the Job Processor or the Dispatcher) | needs a licensed CAD installation that converts for everybody; student and education licences are personal and meant for learning, not for a shared conversion service | skip for the open-source project; an organisation with commercial licences can still do it outside TeamCraft (upload the STEP) |
| Export through the user’s own CAD installation in the desktop app (like the Inventor add-in for Vault or SEEC for Teamcenter) | yes – on Windows, when every user has Inventor or Solid Edge installed under their own licence (student licences included, within their terms) | implement – the desktop app asks the local CAD program for a STEP export next to the native file; the STEP is checked in with it and shown by TeamCraft’s pure-Rust 3D preview |
How the desktop export works:
- Inventor (COM automation, the documented API also used by iLogic):
open the document invisibly and
Document.SaveAs("…\part.stp", True)– Inventor picks its STEP translator by the extension (for options the STEP translator add-in{90AF7F40-0C01-11D5-8E83-0010B541CD80}withSaveCopyAs). - Solid Edge:
SolidEdgeTranslationServices.exe -i part.par -o part.stp -t step, the command-line translator installed with Solid Edge; or COM automation (SolidEdge.Application,Documents.Open,SaveAswith a.stpname, like the interactive Save As → STEP). - TeamCraft contains no Autodesk or Siemens component and redistributes nothing; it only starts features the user’s own licence already includes.
Licence notes
- Autodesk education licences are for learning, training and research – no commercial, professional or for-profit use. Universities read club use differently (the University of Freiburg, for example, lists club and association use as not covered). Each team has to check its own case with the current Autodesk terms or its university.
- Solid Edge Student Edition is free for active students and intended for academic course work; its files cannot be opened in commercial versions of Solid Edge.
- Inventor Read-only Mode (part of every Inventor installation) and Inventor View (with Vault or standalone) open files without consuming a licence, but they are viewers – they cannot be automated for exports.
Status
- Embedded preview images – implemented:
src/thumbnail.rs(OLE compound files through thecfbcrate, PNG or DIB → BMP, cached inpreviews/),GET /files/{id}/thumbnail(web and REST API), shown on the revision page and in the desktop app. Tested with generated files in the layouts described above, not yet with real Inventor, Solid Edge or SolidWorks files. Update: tested with the NIST files intests/fixtures/cad– Inventor 2021, NX 1980 and SOLIDWORKS 2018. In SOLIDWORKS 2015+ files the PNG is not stored as such but in a Deflate-compressedPreviewPNGsection of their own container; it is inflated bysrc/swpart.rs. - STEP export through the local CAD program – implemented in the desktop
app (
desktop/src/cadexport.rs): STEP button in the workspace and Create STEP exports … first at check-in; Inventor and Solid Edge through COM automation from Windows PowerShell. The Windows code compiles forx86_64-pc-windows-gnu, but it was not run against a real Inventor or Solid Edge – that needs a Windows computer with one of them.
Sources
- Vault thumbnails come from the CAD application, Job Processor and DWF: Autodesk Community – Vault ideas
- Inventor thumbnail as a property of the summary information: Autodesk Community, Inventor help – Thumbnails iProperties
- Embedded previews of Inventor, Solid Edge and SolidWorks files: cinderblock/cad-preview
- SEEC creates JT at check-in, Dispatcher alternative: Siemens support PL8016354
- Inventor STEP export through the API: Autodesk Community – Save as STEP using iLogic, STEP translator add-in
- Solid Edge STEP export: Translating Solid Edge files from the command line, Solid Edge help – Save As other formats
- Licences: Autodesk education plan FAQ, University of Freiburg – Autodesk, Solid Edge Student Edition, Inventor Read-only Mode
- Container formats: MS-CFB Compound File Binary File Format, MS-OLEPS Object Linking and Embedding Property Set Data Structures (Microsoft Open Specifications)
Importing existing CAD folder trees
Question: how do PLM/PDM systems bring an existing folder of CAD data – often hundreds of files, with project files – into the vault, and what should TeamCraft’s import do? Investigated on 2026-11-04.
How other systems do it
| System | Tool | What it does |
|---|---|---|
| Autodesk Vault | Autoloader, or Check In Project in Inventor | scans the folder with the Inventor project file (.ipj), reports missing references, uploads in batches (up to 20 000–30 000 Inventor files), keeps the folder structure, works on a copy – the source files stay untouched; DWF creation can be switched off for speed |
| SolidWorks PDM | copy/paste of folders, scan tools, VAR import programs | migration guides: scan first, map folders and properties, fix broken references and duplicates before the import, decide whether files come in at their existing revision |
| Teamcenter with Solid Edge | SEEC Data Preparation: Analyze, then Add to Teamcenter (with dry run) | expects a flat source folder without duplicate file names; Analyze reports duplicate names, duplicate document numbers and broken links; item IDs are generated when the document number is empty; Design Manager can flatten folders and rename duplicates |
The common pattern:
- Prepare – one project per product (Inventor: gather the files and the
.ipjinto one project, e.g. with Pack and Go), fix broken references, make file names unique. - Analyse first (dry run) – a report of what will be imported, duplicate names, broken references; nothing is changed yet.
- Import – in batches, from a copy (the source stays untouched), with generated or property-based numbers; viewables optional.
- Afterwards – check the structure, release the data through the normal workflow.
Decision for TeamCraft
TeamCraft matches CAD references by file name, like Teamcenter – so file names must be unique within an import. The desktop app gets an Import folder… wizard:
- Scan (dry run): walks the folder, skips lock/backup files and
OldVersionsfolders, sorts the files into models (parts, assemblies), drawings, exports (STEP, STL, PDF, …), project files (.ipj, …) and other files, and reports duplicate file names. Nothing is uploaded yet. - Plan: one item per model file (assembly or part); a drawing and exports with the same base name go into the same item (the item’s datasets); project files and other documents can go into one document item named after the folder. Numbers are generated, or taken from the file names when they are valid item numbers.
- Import: item by item with progress and stop, from the original files (read only); every new item is checked in at the end, so nothing stays locked; the server scans the assemblies for the files they reference.
- Structure: optionally BOM lines from the references found in the
assemblies, with the instance count the server read from the assembly (
instancesofGET …/references: SOLIDWORKS 2015+ component tree, NX structure); where the format does not tell (Inventor, Solid Edge, …) quantity 1, to be checked – exact quantities come from the BOM export of the CAD program (Import BOM…). - Release afterwards with one change order, like every other release in TeamCraft (no import “as released” past the lifecycle).
An in-app tutorial (desktop Help → Import a CAD folder) explains the preparation (Pack and Go, project file, unique names), the wizard and the check afterwards.
Sources
- Autoloader and Inventor projects: Vault help – Autoloader, Check in Project with Inventor, Modify an Inventor project
- SolidWorks PDM migration: TriMech – data migration best practices, GoEngineer – manually importing CAD data, Javelin – how to prep for a PDM migration
- Solid Edge and Teamcenter: SEEC Data Preparation tools, Analyze, Add to Teamcenter, Design Manager – flatten folder structure
Status
- Implemented in the desktop app: Tools → Import CAD Folder…
(
desktop/src/folderimport.rsfor the scan, the plan and the import indesktop/ui/app.js) and the tutorial Help → How to Import a CAD Folder. Imported items are remembered per folder (browser storage of the app), so an interrupted import continues where it stopped. - Tested: the folder scan (unit test) and the planning with a sample tree (items, duplicates, Creo iterations, project files). Not yet tried with a real Inventor or Solid Edge project of hundreds of files – the BOM lines depend on the heuristic reference scan of the server.
Popular features of Teamcenter
TeamCraft is meant as an open-source alternative to Siemens Teamcenter. This page lists the features its users rely on most, what TeamCraft already has and what is implemented next. Date: 2026-11-04.
Siemens Teamcenter
| Kind | PLM for large manufacturers (rich client, Active Workspace web client) |
| Core objects | items, item revisions, datasets (files), BOM lines, change objects |
| Typical users | design, manufacturing and change engineers |
Most used features
| Feature | Teamcenter | TeamCraft |
|---|---|---|
| Check-out / check-in with locks | ✓ | implemented |
| Versions and revisions, rollback | ✓ (revisions, dataset versions) | implemented |
| Lifecycle states, release workflow, reviews | ✓ (workflows, signoffs) | implemented (change orders, four-eyes review) |
| Search by attributes and full text | ✓ | implemented |
| Saved searches | ✓ (Saved Queries) | implemented now |
| Favorites / personal folder | ✓ (Favorites, Home, “Newstuff”) | implemented now |
| Inbox / worklist | ✓ (My Worklist, tasks) | implemented (dashboard, notifications) |
| Subscriptions / notifications | ✓ | implemented (watch, bell, e-mail, Mattermost) |
| Multi-level BOM, revision rules, where-used | ✓ (Structure Manager) | implemented |
| BOM comparison between revisions | ✓ (structure compare) | implemented now |
| Save As (copy with files and BOM) | ✓ | implemented now |
| References between CAD files (xrefs) | ✓ | implemented (reference scan, change propagation) |
| Impact analysis of changes | ✓ (Change Manager) | implemented |
| 3D viewing | ✓ (JT visualisation) | implemented (STEP, meshes, point clouds; embedded previews of native files) |
| Export (Excel/CSV, packages) | ✓ | implemented (CSV, Pack and Go ZIP) |
| Classification / custom attributes | ✓ (Classification) | planned (OPEN_WORK #5) |
| Configurable numbering schemes | ✓ | planned (OPEN_WORK #4) |
| Transmittals (issue documents to externals with a record) | – (common in document management systems) | planned |
| Markup / redlining | ✓ | planned |
| Effectivity, multi-site, ERP integration | ✓ | not planned for now |
Decision
Implement the four most used features TeamCraft is missing and that fit its
data model without new concepts: favorites, saved searches, Save As (a new item from an existing revision:
attributes, files and BOM)
and BOM comparison between two revisions. Numbering schemes, custom
attributes, transmittals and redlining stay in docs/OPEN_WORK.md.
Sources
The Teamcenter column reflects the generally documented standard functions of its rich client and Active Workspace (items, datasets, Structure Manager, Saved Queries, Favorites, workflows, Change Manager, Classification); the Teamcenter documentation itself was not read in this investigation.
Openly licensed CAD files for end-to-end tests
Question: where can TeamCraft get real native files of the common CAD programs – parts and assemblies – that may be redistributed in a public MPL-2.0 repository, to test the import, the format detection, the preview images, the reference scan and the STEP preview end to end? Investigated on 2026-11-04.
Sources checked
| Source | What it offers | Licence | Usable |
|---|---|---|---|
| NIST MBE PMI validation and conformance testing | test parts CTC 01–05, FTC 06–11, STC, HTC in native CATIA V5, Siemens NX, Autodesk Inventor, PTC Creo and SolidWorks plus STEP AP203/AP242 and PDF; the MTC box assembly in NX (with JT) and SolidWorks (with ACIS .SAT) | “The test cases, CAD models, and STEP files can be used without any restrictions.” NIST asks for an acknowledgement and does not allow its logo in promotional material | yes |
| GrabCAD and similar community libraries | many native files | no licence per file, terms of the site only | no |
inventor-kit (PyPI) | reads .ipt/.iam, files not distributed | PolyForm Noncommercial since 0.3.0 | no |
| SolidWorks repositories of open hardware projects | .SLDPRT/.SLDASM | mostly no licence, CC BY-SA or unverified “MIT” claims | no (not verifiable / share-alike) |
| Solid Edge community samples | automation code samples | no model files with a stated licence found | no |
Solid Edge is the gap: no openly licensed native Solid Edge files were found. Options: create small test parts and an assembly with Solid Edge (Community Edition) and publish them under CC0, or ask the Solid Edge community for donated files.
Decision
A curated subset of the NIST files goes into tests/fixtures/cad/nist/
(about 30 MB), with the NIST terms, the acknowledgement, the source of every
file and its SHA-256 in tests/fixtures/cad/README.md:
- CTC 01 in every native format: Inventor 2021
.ipt, NX 1980.prt, SolidWorks 2018.SLDPRT, Creo 10.prt.5(a Creo iteration), CATIA V5.CATPart, and STEP AP203 and AP242; - FTC 11 as small files for fast tests: Inventor
.ipt, SolidWorks.SLDPRTand a 7.6 kB STEP AP203; - the MTC box assembly in SolidWorks (
.SLDASM, parts,.SAT) and in NX (assembly and part.prt,.jt) – real assemblies for the reference scan and the folder import.
A test (tests/cad_fixtures.rs) checks every file against its SHA-256 and
the expected TeamCraft format, and records what the heuristics really find
in these files: preview images (Inventor, SolidWorks), referenced part files (SolidWorks and NX assemblies) and the STEP
3D preview.
Sources
- NIST – Download free CAD models and STEP files (terms quoted from the Disclaimers section of that page), project page, data catalog entry, MTC: Design, Manufacturing, and Inspection Data for a Box Assembly
- inventor-kit on PyPI
- SolidEdgeCommunity/Samples
Results with the files (2026-11-04)
What TeamCraft found in the NIST files (tests/cad_fixtures.rs):
- Format detection: all 32 files are recognised correctly – NX parts by
their
SPLMSSTRheader, the Creo iteration.prt.5, CATIA V5, JT and ACIS included. - Preview images: Inventor 2021 parts carry a PNG of about 26 kB, which is found. SolidWorks 2018 files carry none that can be found: since 2015 SolidWorks keeps its streams in compressed segments of its own container.
- Reference scan: the NX assembly names all eight part files. The
SolidWorks 2018 assembly names none (compressed, see above). False
references: NX parts report earlier file names of their save history and
binary noise, the STEP header its export name (
FILE_NAME), JT files noise. - 3D preview: the small STEP files are tessellated.
The gaps are ignored tests in tests/cad_fixtures.rs and fixes in
docs/OPEN_WORK.md.
Read permissions for project groups
Until now every signed-in TeamCraft user could read every item, file and change. Project groups only limited editing. This page records how other systems scope read access and what TeamCraft adopts. Date: 2026-11-05.
How other systems do it
| System | Unit of access | Default | Read grant | Notes |
|---|---|---|---|---|
| Onshape | document (and folders); teams and the company/enterprise as grantees | private to the owner | share a document with users, teams or the whole company; permission levels view / copy / export / edit | a document shared “view only” can still forbid export or copy; enterprise admins see everything |
| GitHub | repository | public or private chosen at creation; organisation base permission (read / none) | membership of the organisation or a team that has the repository | a private repository answers 404, never 403, to outsiders, so its existence is not confirmed |
| Typical PDM/PLM (Teamcenter, Windchill, Vault Professional) | project, context, folder or library; access control lists per object type and lifecycle state | everything inside a context is visible to its members | context membership plus role; rules per state (“released documents visible to all, in-work only to the team”) | powerful, but rule sets become hard to audit; most installations use “project = visibility boundary” |
Common ground:
- The boundary is a container (document, repository, project), not the individual object. Objects inherit it.
- Outsiders get “not found” – the existence of private containers is hidden.
- Administrators bypass the rules.
- Links from a visible object into a hidden one (an assembly using a part of another project, a fork of a private repository) are shown without the hidden content, or not at all.
What TeamCraft adopts
- Project groups are the boundary. A group is either
internal(today’s behaviour: everybody reads, members edit) orrestricted: only its members – of any role, viewers included – and admins read anything in it. - No per-object ACLs and no per-membership access levels for now. A read-only member is a member with the global viewer role. Per-object rules (Teamcenter-style) are hard to audit and not needed for the request “not everybody may see every repository”.
- 404 like GitHub. A hidden item, file, change or group answers exactly like a missing one.
- Links across the boundary follow the PDM practice: an outgoing link (BOM line, reference, simulation basis) stays as a placeholder row (“restricted item”, quantity and find number kept, identity removed); incoming links (where-used, referrers) are dropped and only counted.
- Change orders and requests have no group of their own. They are visible to their author, reviewers or assignee, admins and anyone who can see every affected item, because title and description are free text about all of them.
- Side channels – notifications, e-mail, Mattermost, webhooks, the assistant and the MCP server – apply the same rule per recipient.
- Enforcement in the service layer, not in PostgreSQL row-level security: RLS can only hide rows, while TeamCraft needs placeholders and counts, and per-request session state on a shared connection pool is risky. Every read service takes the actor; a single module writes the SQL guard; a route-complete leak test checks every web route, REST route and MCP tool.
Not adopted (possible follow-ups)
- Onshape’s view / copy / export levels and per-membership read/write levels.
- Per-item ACLs and field-level rules.
- Token scopes.
- Keeping a group’s data away from the assistant’s model provider.
Sources
- Onshape help: “Sharing documents”, “Teams”, “Company and Enterprise permissions” (cad.onshape.com/help).
- GitHub docs: “About repositories – repository visibility”, “Repository roles for an organization”, and the 404 behaviour for private repositories in the REST API documentation.
- Teamcenter “Access Manager” and Windchill “context and access control” documentation (vendor manuals, conceptual overview only).
As-built records: hardware that differs from the CAD
Real hardware is rarely built exactly to the released design: a bore ends up 0.03 mm too large and is accepted, a purchased part is replaced by a second-source part, a weld is reworked, a different alloy is used because the specified one was not in stock. TeamCraft so far only knows the as-designed side (items, revisions, BOMs). This page records how PLM and adjacent systems model the as-built side and what TeamCraft adopts. Date: 2026-11-06.
How other systems do it
| System / practice | Physical unit | Link to the design | Departures from the design |
|---|---|---|---|
| PTC Windchill (MPMLink, Service) | serialised part instance / as-built and as-maintained structures | instance of a part version; the as-built structure starts from the designed BOM | deviations and waivers as change objects; actual installed parts recorded in the instance structure |
| Siemens Teamcenter (Manufacturing / MRO) | physical part with serial or lot number | as-designed revision the unit was built to | as-built structure records the actually installed physical parts; deviation and nonconformance objects with disposition |
| Aras Innovator | Product Instance / serialised part (as-built, as-maintained) | refers to the part revision and its BOM | quality items (nonconformance, CAPA) linked to the instance |
| ERP (SAP, Odoo, ERPNext) | serial numbers and lots on stock, installed base | material number, sometimes revision level | quality notifications; not a structure comparison |
| AS9100 / ISO 9001 practice | serial-numbered article with a build record (traveller) | drawing and revision built to | nonconformance report (NCR) with a disposition: use as is, rework, repair, scrap (often also return to supplier); repair and use as is need engineering (MRB) approval |
Common ground:
- The unit is its own object, identified by item and serial (or lot) number, not a revision of the design. Many units point to one design revision; a unit keeps pointing to the revision it was built to after the design moves on.
- The as-built structure starts as a copy of the designed BOM and is then edited to what was really installed; comparing the two shows the differences.
- Each departure is a record of its own with what was designed, what was built and a disposition. A unit with an undecided departure is not accepted/delivered.
- As-maintained (repairs and replacements in service) is the same structure over time: a new state of the unit’s record, the old state stays for traceability.
What TeamCraft adopts
- Item type
as_built(numbersAB-000001): one item is one physical unit (or one lot). It has the normal lifecycle, revisions, files (photos, measurement reports, a 3D scan, the traveller) and change orders. A new revision of the as-built item is a new state of the same unit (rework, repair, retrofit = as-maintained); the old revision stays. - Build record per as-built revision: the design revision it was built to (a part, assembly or software revision), serial or lot number, build date, note. The serial number is unique per design item. The design revision may be in work, in review or released; an obsolete one is refused. Like the basis links of a simulation, the record belongs to the revision: editable only while it is checked out, copied by Revise, frozen at release.
- As-built BOM = the normal BOM of the as-built revision. Record a built unit on a part or assembly creates the as-built item with the design’s BOM copied in; the existing cross-item BOM comparison shows design → as-built.
- Deviations per as-built revision: kind (dimension, material,
component, process, other), feature, as designed, as built,
disposition (open, use as is, rework, repair, scrap), note.
Values are text (
12.000 +0.018/0→12.035), because tolerance notations vary too much for one numeric model. - Release rule: a change order with an as-built revision cannot be submitted while that revision has no build record or an open deviation. The engineering decision (MRB) is the change order’s review itself.
- Design side: a Built units card on the design revision lists the units built to any of its revisions, with serial, built-to revision, open deviations and a marker when a newer design revision is released. A design item that units were built to cannot be deleted.
- Later, not now: change propagation to built units (retrofit campaigns), serial numbers on as-built BOM lines (installed serialised components), numeric tolerance checks, import of measurement reports.
Sources
- AS9100D, clause 8.7 Control of nonconforming outputs (dispositions use-as-is, repair, rework, scrap; customer approval of use-as-is/repair).
- Vendor documentation of PTC Windchill, Siemens Teamcenter and Aras Innovator on serialised/as-built/as-maintained product structures (public product overviews; no licensed material was used).
Fluent, STAR-CCM+ and Simcenter 3D files
Question: how should TeamCraft’s simulation management (FEA/CFD) handle the files of ANSYS Fluent, Simcenter STAR-CCM+ and Simcenter 3D (NX CAE with Simcenter Nastran)? What makes each file recognisable, what can be read cheaply and safely, what must never be parsed, how a project folder maps to TeamCraft items, and how the result values come in. Investigated on 2026-11-07. The investigation used public documentation only. No proprietary SDKs were used, and no files of these programs were available for testing. Every byte-level rule below is a hypothesis until it is checked with real files (see Open questions).
Starting point
src/cadformat.rsrecognises.cas,.dat(only with Fluent’s(0 …)section syntax),.cas.h5and.dat.h5(Fluent) and.sim. A.simfile is STAR-CCM+ whenstar-ccmorstarccmappears in its first 1024 bytes and NX in every other case..sim,.femand.afmare “NX simulation / FE model”. It also recognises.bdf/.nas(Nastran deck) and.op2/.f06(Nastran results). Format detection never reads more thanHEAD_LEN= 1024 bytes.- Result values come from the
teamcraft-results.json|csvexchange file, from per-group extraction templates (src/resultscan.rs: regex or CSV column, run over the first 2 MB of a small text file), from assistant drafts quoted from PDF reports (src/pdftext.rs), from manual entry, CSV/Excel import or the REST API. Large result files are never parsed fully. That is a standing decision, and this page keeps it. - 3D previews exist for VTK/VTU/VTP and ASCII CalculiX
.frd(src/simmesh.rs). Not one of the three programs’ native formats is previewed, and that will not change (see below). - HTML reports are indexed as raw markup, and their decimal numbers are lost
(#141). Plain-text files
lose their decimal numbers in the search text as well, and that search text
is what
read_file_textreturns.
What the programs write
ANSYS Fluent
| File | What it is | Content | Role in TeamCraft |
|---|---|---|---|
.cas.h5, .dat.h5, .msh.h5 | case, data, mesh in the Common Fluids Format (CFF, the default since 2020 R1) | HDF5 | case/mesh = input, data = result (large) |
.cas, .dat, .msh (+ .gz) | legacy case, data, mesh | (index …) sections: (0 "comment"), (1 "header"), (2 dims), (10 …) nodes …; binary sections use the prefixes 20…/30… (single/double precision) and end with End of Binary Section 2010 | input / result |
.cdat | legacy data interpolated to the nodes, for CFD-Post | binary | result (large) |
.flprj + <name>.cffdb/ | Fluent project (“managed project”): groups related case/data files and stores relative paths | project file + folder | input (the folder holds the CFF files) |
<name>-<n>-<iter>.dat.h5 … | autosaves of a run | HDF5 | result series: keep only the selected step |
.jou | journal: TUI/Scheme commands (/file/read-case …, /solve/iterate 500) | text | input (run script) |
.trn | transcript of a session or batch run: banner with the version, residual table per iteration, messages | text | log |
report-file-*.out | report file of the report definitions: a quoted title line, a header with quoted column names ("Iteration" "report-def-0" …), then one row per iteration or time step | text, whitespace-separated | result values (last row) |
exported XY plots, .csv from Export | quantities and plots | text | result values |
cleanup-fluent-<host>-<pid>.sh / .bat | written by every Fluent session to kill stray processes | script | ignore |
Simcenter STAR-CCM+
| File | What it is | Content | Role |
|---|---|---|---|
.sim | the simulation: geometry, mesh, setup and solution in one file | undocumented binary – no public description of the header; Siemens documents only starccm+ -info file.sim (prints the version used to create the file, iterations, serial/parallel) | input and result in one, often GBs |
.sim~ | backup of the previous save | as .sim | ignore (the desktop app already skips names ending in ~) |
.simh | solution history (fields over time steps) | binary | result (large, never parsed) |
.java | macros (recorded or written) | text; recorded macros begin with a comment that names the macro and the STAR-CCM+ version that wrote it | input (run script) |
plot and table exports .csv | monitors, reports, XY plots | text, comma-separated, quoted headers | result values |
scene/plot hardcopies .png | pictures | image | gallery on the Results card (exists) |
simulation report .html/.pdf | exported report | HTML / PDF | report (PDF works today; HTML: #141) |
batch log (stdout redirected, .log/.txt) | banner with version, iteration table, stop messages such as Stopping criterion … satisfied | text | log |
.ccm, .dmprj | STAR-CCM+ export / Design Manager project | binary / project | input; not read |
Simcenter 3D (NX CAE) with Simcenter Nastran
| File | What it is | Content | Role |
|---|---|---|---|
<model>.prt | master CAD part | NX part (SPLMSSTR container since NX 1980) | the basis: a part item of its own |
<model>_fem1_i.prt | idealized part (defeatured, split copy of the master) | NX part | input of the FE model |
<model>_fem1.fem | FE model: mesh, materials, physical properties | NX part file with a CAE extension (same container – to be verified) | input (can be a Mesh basis) |
<model>_sim1.sim | simulation: solutions, loads, constraints, references the .fem | NX part file (same container – to be verified) | input |
.afm | assembly FEM (references several .fem) | NX part file | input |
<sim>-<solution>.dat | solver deck that Simcenter 3D writes for Nastran | text, bulk data (SOL, CEND, BEGIN BULK, $ comments with the translator version) | input |
.f06 | print output: tables, messages, FATAL / END OF JOB | text, can be hundreds of MB with full printout | log / result |
.f04, .log, .diag | execution summary, solver log | text | log |
.op2 | binary results (Fortran records, the first records hold a date and the label NASTRAN FORT TAPE ID CODE) | binary | result (large, never parsed) |
.h5 | HDF5 results (MSC Nastran; newer Simcenter Nastran releases – to be verified) | HDF5 | result (large, never parsed) |
.pch | punch output | text | result |
scratch (.MASTER, .DBALL, .SCRATCH, …) | solver work files | binary | ignore |
The _fem1, _fem1_i and _sim1 suffixes are what Simcenter 3D proposes
by default. They are a convention that users can change, so TeamCraft may use
them as a hint and never as a rule.
Collision: Altair OptiStruct/HyperMesh also use .fem, but for a text
Nastran-style deck ($$ comments, BEGIN BULK). A text .fem is therefore
not an NX FE model.
Decisions
1. Recognition (format detection, within the 1024-byte head)
| Rule | Why |
|---|---|
.sim / .fem / .afm starting with SPLMSSTR → nx-simulation | NX writes all its part files in this container, so this is a positive NX test. The current rule only has a negative test (it falls back to NX) |
.sim naming STAR-CCM+ in the head → starccm-sim (unchanged) | the only cheap clue; unverified |
.sim with neither → keep the current fallback (NX), and mark the detection as uncertain (format note “content not recognised”) | older NX releases (before 1980) and STAR-CCM+ files without the marker both end up here; a wrong STAR-CCM+ label is worse than an honest “uncertain” |
text .fem → new optistruct-deck (or unclassified), binary non-NX .fem → unclassified | collision with OptiStruct |
.dat with Nastran bulk-data markers (SOL , CEND, BEGIN BULK, INIT MASTER, a $ comment naming Nastran/Simcenter) → nastran-bulk | Simcenter 3D writes its decks as .dat; today they stay unclassified because .dat only counts for Fluent |
.f04, .pch → Nastran (log/result), text only | complete the Nastran set |
.cas.h5, .dat.h5, .msh.h5 by name and the HDF5 signature \x89HDF\r\n\x1a\n at offset 0 or 512 (HDF5 allows a user block in front) | name alone accepts any file |
.cas.gz, .dat.gz, .msh.gz by name and the gzip magic 1f 8b | legacy compressed Fluent files are common in archives |
.msh with Fluent sections (0 , (1 , (2 , (10 → fluent-mesh; $MeshFormat → Gmsh (if listed); other → unclassified | Gmsh also uses .msh |
.flprj → fluent-project; .cdat → Fluent result; .jou → Fluent journal (text); .trn → Fluent transcript (text); .simh → STAR-CCM+ solution history; .op2 only when not text | complete the sets |
report-file-*.out and other .out files whose first line is a quoted title and second a quoted header → Fluent report file | for the template support below |
HDF5 files are never opened as HDF5 by the server, not even to read
attributes. Pure-Rust HDF5 readers exist under MIT/Apache-2.0 (hdf5-pure, rust-hdf5, rustyhdf5-format), but they
are young (first
releases in 2026) and an HDF5 object header parser is a large attack surface.
The signature is enough for recognition. Reading the Fluent version from the
CFF attributes is not worth that surface. Revisit this only if a concrete need
appears, and then only in the isolated child process.
2. Cheap metadata (bounded, text files only)
A small solver summary per file version is worth having for the following, all read from text and all bounded (head and tail of at most 64 KiB each, never the middle of a large file):
| File | Summary |
|---|---|
Fluent .trn | solver version (banner), last iteration / time step, “solution is converged” yes/no, last residuals line |
Fluent report-file-*.out | column names, number of rows, the last row (iteration and values) |
Fluent .jou | files read and written (/file/read-case…, /file/write-…) – used as references, like cadrefs |
STAR-CCM+ .java | the version from the header comment; .sim files the macro loads or saves (resolvePath("….sim")) as references |
| STAR-CCM+ batch log | version, last iteration, stopping criterion message |
Nastran .f06 | solver name and version (page header), run status from the tail: END OF JOB, number of FATAL / WARNING messages (counted only in the head/tail windows, so the counts are labelled “in the last 64 KiB”) |
Nastran .dat/.bdf | SOL number, the translator/solver version comment |
NX .sim/.fem/.afm | the referenced files from the ExternalReferences streams of the SPLMSSTR container (the reader in src/nxpart.rs, if these streams exist in CAE parts – to be verified) |
Things such as max stress from the .f06 are not read automatically.
The relevant tables sit in the middle of a file that can be hundreds of MB,
and their layout depends on the solution sequence and the output requests.
These numbers come in through templates (bounded, per group, chosen by the
team) or from the report.
Implementation (step 3, src/solversummary.rs): the summary is stored per file
version as JSON (file_versions.solver_summary) and shown as one line. The
patterns (Fluent banner Fluent 2023 R1, solution is converged, iteration rows
and time step = N, Nastran END OF JOB and FATAL, SOL n, the STAR-CCM+ macro
header comment and Stopping criterion … satisfied) are unverified guesses until
tested with real files.
3. Never parsed
.dat.h5, .cas.h5 (beyond the signature), legacy binary .dat/.cdat,
STAR-CCM+ .sim (beyond the head) and .simh, Nastran .op2 and .h5, and
NX CAE parts beyond their stream directory. None of them is converted for the
3D viewer either: the formats are undocumented or too large, and the way to a
3D view stays an export to VTU/VTK (Fluent: Export → EnSight/CGNS/VTK;
STAR-CCM+: Export → VTK/EnSight; Simcenter 3D: post-processing export),
which the viewer already reads.
4. Project folders → items
One simulation item = one analysis (unchanged). For each program:
- Simcenter 3D: the
.simwith its.fem,.afmand idealized_i.prtfiles and the solver files of its solutions (<sim>-<solution>.*) form one simulation item. The master.prtis not copied into it. It is the Geometry basis, pinned to the file version that was simulated. If several.simfiles in a folder share one.fem, the.femand its idealized part become their own item (type simulation, role Mesh basis of each simulation). The reference chain.sim → .fem → _i.prt → master .prt(from the NX stream directory) lets TeamCraft propose the basis links instead of making the user type them. - Fluent: the case (
.cas.h5), the journal, the transcript, the report files and the final data file form one simulation item. Autosave series go in only for the selected steps. A shared mesh (.msh.h5) can be a Mesh basis. A.flprjwith its.cffdbfolder is taken along as it is (the relative paths must survive), so it belongs to the item as a folder. The case does not reference CAD files, so the geometry basis is set by the user or by New simulation from this revision…. - STAR-CCM+: the
.simfile is model and result, so it is the simulation’s main file. Macros, exported CSV tables, scene PNGs and the report go into the same item. The geometry basis is set by the user (a.simimported from CAD keeps no file references we can read).
Roles of files inside the item (shown as a small label, used by the desktop
import and by retention): model/input, result, log, report, picture, script. The role follows
from the format (kind
Simulation / Result plus the new log/script/report formats). It is not
stored per file.
5. Desktop folder import and check-in
- Ignore
cleanup-fluent-*, Nastran scratch files and solver lock files, in addition to the existing backup rules (.sim~is already ignored). - Group by the rules in section 4: the
.sim/.femchain for Simcenter 3D (from the stream directory, falling back to the_simN/_femNname convention), the case stem for Fluent, the.simstem for STAR-CCM+. - Size: show the size of each result file, and leave results above a
threshold (default: the preview limit, 64 MB) unticked in the check-in.
Autosave series are unticked except for the last step. STAR-CCM+
.simfiles stay ticked (they are the model) with a hint that saving after Clear Solution makes them small, if the team does not need the solution in the vault. - Basis: for Simcenter 3D the import proposes the master part’s item as Geometry basis when it is in the vault (or in the same import). The user confirms it.
- Check-in of large results needs the open retention rule (
files.retention = 'latest') and uses the chunked resumable upload (already built).
6. Result values
In order of value:
- Exchange file written by the run. Document short, tested snippets that
write
teamcraft-results.csvat the end of a run: a STAR-CCM+ Java macro (report values → CSV), a Fluent journal/PyFluent script (report definitions → CSV) and a Nastran/pyNastran post-script. These are documentation only, and they are the most reliable path because the team decides which numbers matter. - Templates over text outputs, with two small extensions to
resultscan:pick = lastreads the tail (last 2 MB) instead of the head. Today a long transcript or report file has its final values beyond the first 2 MB, where templates never look.- a
tablemethod for whitespace-separated tables with quoted headers (Fluentreport-file-*.out): column by header name, last (or first) row. STAR-CCM+ CSV exports already work with thecsvmethod. They need a last row pick when there is no key column. - add
trn,f06,f04,pch,jou,javatoresultscan::scannableand to the plain-text kinds oftextindex.
- Reports: PDF works today; HTML reports (STAR-CCM+, Simcenter 3D,
Fluent’s report export) come with #141. Plan both together with the
plain-text
read_file_textfix: the assistant should read the real text (with decimal numbers, bounded, head and tail) of.out,.trn,.f06and CSV files, sopropose_result_valuescan quote them like PDFs. - Assistant proposals stay drafts with a quote, and a CAD editor accepts or rejects them (unchanged).
Not done, and why
- No parsing of
.op2,.h5,.dat.h5,.sim,.simh: size, undocumented formats, attack surface. Exports to open formats are the way to 3D and to numbers. - No use of vendor tools on the server (
starccm+ -info, Fluent, Nastran): licences and installation. A later desktop feature could runstarccm+ -infoon the user’s machine with the user’s licence, as the STEP export already does with local CAD programs. - No pyNastran-style OP2 reader in Rust: an F06/OP2 table reader is a project
of its own. A template on the
.f06or a script-written exchange file covers the key numbers.
Open questions
- Real files for fixtures: the head (first 1–4 KB is enough) of a
STAR-CCM+
.simfrom two or three versions, of NX 1980+.sim/.fem/.afmand of an older NX release, a Fluentreport-file-0.outand.trn, a Simcenter 3D Nastran.datand.f06header. Openly licensed or self-made, as for the CAD fixtures. - Do NX CAE parts carry the
ExternalReferencesstreams thatnxpartreads, and do they name the.fem/_i.prt/ master part? - Does Simcenter Nastran write
.h5results in the versions teams use, and under which name? - Should a
.simwithout either marker be labelled “uncertain” in the UI, or is the NX fallback good enough? - Does a shared FEM become its own item by default, or only when the user asks?
Sources
- Ansys Fluent User’s Guide 2024 R2, Legacy Case and Data File Formats
(section indices, header example
(0 "fluent24.2.0 build-id: 0"), binary prefixes20/30,End of Binary Section): https://ansyshelp.ansys.com/public/Views/Secured/corp/v242/en/flu_ug/flu_ug_app_form_case_data.html - Ansys CFD-Post, Ansys Fluent Files (
.cas.h5,.dat.h5,.msh.h5, legacy.cas/.dat/.msh(.gz),.cdat,.flprj): https://ansyshelp.ansys.com/public/Views/Secured/corp/v252/en/cfd_post/CJAEEJBJ.html - Fluent, Working With Project Files (
.flprj, managed projects in a.cffdbfolder, relative paths): https://ansyshelp.ansys.com/public/Views/Secured/corp/v252/en/fluent_beta_doc/fltg_sec_param_stu_prj.html - PyFluent settings API, report definitions: https://fluent.docs.pyansys.com/version/stable/api/solver/_autosummary/settings/report_definitions.html
- Siemens knowledge base KB000033094 (
starccm+ -info model.sim): https://support.sw.siemens.com/en-US/okba/KB000033094_EN_US/Could-I-check-the-architecture-used-in-creating-a-STAR-CCM-file-without-opening-it/index.html - CFD Online,
.sim~backup files and the unanswered question whether.simis a known archive format: https://www.cfd-online.com/Forums/star-ccm/66804-file-sim.html - Siemens knowledge base KB000069296, Simcenter 3D result files and their extraction for storage: https://support.sw.siemens.com/en-US/okba/KB000069296_EN_US/Reducing-contents-of-result-file-by-extraction-for-more-efficient-storage/index.html
- pyNastran OP2 writer (start record
NASTRAN FORT TAPE ID CODE): https://pynastran-locr.readthedocs.io/en/latest/_modules/pyNastran/op2/writer/op2Writer.html - crates.io:
hdf5-pure(MIT OR Apache-2.0),rust-hdf5(MIT),rustyhdf5-format(MIT), checked 2026-11-07.