A schedule that cannot leave the system it was built in is a demo. For Connect, the way out is a Primavera P6 .xer file: the scheduler downloads it, imports it into Syncify or P6, recalculates, and carries on in the tools the firm already uses. That makes the exporter one of the most consequential pieces of code in the product. If it writes a file the downstream importer rejects, the scheduler loses an afternoon. If it writes a file that imports but quietly reads differently, nobody notices until the dates are wrong in front of an owner.
This article covers how we emit P6 19.12 files and how we check them before they leave: a structural re-read, a comparison against the schedule's original inputs, a TypeScript port of the downstream importer's rules, independent readers, and a separate round-trip comparison for files that come back from a real import. It also covers what none of that proves. The product view is in how the Schedule Builder works, and the calculation the file carries is in our CPM engine article. Both are part of our Connect architecture series.
What an XER file actually is
XER is P6's interchange format: one tab-delimited text file that holds many relational tables. It is simple to write and easy to get subtly wrong. The grammar has five record types:
| Record | Meaning |
|---|---|
ERMHDR | Header line: format version, export date, a few labels |
%T | Start of a table, followed by its name |
%F | The field names for that table, in order |
%R | One row, with cells in the same order as %F |
%E | End of file |
A minimal illustrative fragment (tabs shown as spaces):
ERMHDR 19.12 2026-10-10 Project ...
%T TASK
%F task_id proj_id wbs_id task_code task_name target_drtn_hr_cnt
%R 1000 1 100 A1000 Excavate 40
%R 1001 1 100 A1010 Footings 24
%T TASKPRED
%F task_pred_id task_id pred_task_id pred_type lag_hr_cnt
%R 1 1001 1000 PR_FS 0
%E
Three properties cause most of the trouble. Keys are integers, so string IDs have to be mapped. Durations and lags are stored in hours, so everything depends on hours per day. And there is no escaping: a stray tab in an activity name shifts every later cell one column right, which is how a finish date ends up in a float column.
Emission: deciding what the importer will believe
The emitter writes CURRTYPE, OBS, CALENDAR, PROJECT, SCHEDOPTIONS, PROJWBS, TASK and TASKPRED, under a 19.12 header, which is the P6 release the operator's databases run and the first thing an importer checks. The decisions that matter:
- Stable integer IDs and deterministic GUIDs. GUIDs are derived from a hash of each record's own ID, so two exports of the same schedule diff cleanly.
- Hours at eight per day. Durations and lags are written as working days times eight. Dates are
YYYY-MM-DD HH:MM, with starts at 08:00 and finishes at 17:00. - A real status date, always. The downstream importer treats
last_recalc_dateas the schedule's data date and silently skips an empty cell. An empty cell would not fail; it would leave the data date wrong. So it is always populated. - Foreign keys that resolve. Every PROJWBS row points at an OBS node. One enterprise root OBS row satisfies that, at the cost of a table the target may never read.
- Every calendar, with its holidays. Each calendar an activity runs on is written under the operator's P6 calendar names, so an import maps it onto the existing global calendar. Holidays become P6 calendar exceptions inside the
clndr_datablob, so a recalculation lands on the same working days our CPM did. Each activity carries its ownclndr_id. - Scheduling options that reproduce the dates. A SCHEDOPTIONS row states how the dates were calculated: lags on the predecessor's calendar, total float measured as finish float, retained logic. Without it, pressing F9 in P6 recalculates under whatever defaults that database has.
- Milestone semantics. A milestone that something finishes into is written as a finish milestone, using the same rule the CPM engine uses to date it.
Then there are the defensive rules. Cells collapse tabs and newlines to a space. Name columns (task_name, wbs_name) double any quote character, because the importer un-doubles quotes on exactly those two columns and reads codes verbatim. And every date is validated as a real calendar date before it is written: 2026-02-30 matches a shape regex, but a strict parser downstream rejects the whole file, not just the row.
Verification layer one: re-read the file as an adversary
The verifier never trusts the serializer. It parses the emitted text from scratch and checks it in stages:
- Required tables are present.
- Every
%Rrow has exactly as many cells as its%Fheader. This is the column-shift check, and if it fails, the verifier stops there, because every later check would only add noise. - Every
*_datecell isYYYY-MM-DD HH:MM, or empty only in the few columns P6 legitimately leaves blank, such as late dates or constraint dates. - Non-milestones have a positive duration; milestones have zero.
- WBS parents, activity WBS assignments and relationship endpoints all resolve.
- The project's planned start is not after its finish. There must be exactly one default calendar, positive calendar hour fields and a non-empty project short name, which the importer uses as the schedule code.
- Our own importer reads the file back to the expected counts of activities, relationships and WBS nodes.
Layer two: compare against the original inputs
A file can be perfectly well formed and still say the wrong thing. So when the original schedule content is available, which it is on every export, approval and evaluation, the verifier recalculates it with the CPM engine and compares the file against that result:
- Every activity's early, late and target dates and times, and its total and free float in hours.
- The project's start, finish and data date.
- Full WBS paths (code and name of every ancestor) and each activity's placement, not just node counts.
- Every relationship as a tuple of predecessor code, successor code, type and lag.
- Durations, milestone flags, constraint types and dates, and each activity's calendar.
- Each calendar's workweek, shift hours and holiday exceptions inside the schedule window.
Generated IDs and row order may differ; meaning may not. One rule here is about honesty rather than format. The forecast engine schedules whole days, so a fractional-day duration or lag fails verification with a message to review it, rather than being rounded into a file whose dates it no longer matches.
Layer three: a port of the downstream importer
Layers one and two share a blind spot: our own importer reads files the way our own emitter writes them. If both get quotes wrong in the same way, both agree, and the bug is invisible.
So we wrote a self-contained model of how the downstream importer reads a .xer, transcribed from its Go source rather than inferred from observation. It reproduces three kinds of outcome:
| Outcome | Example | Effect |
|---|---|---|
| Reject | Header export date that is not a bare, valid date; an integer column that does not parse | The whole import aborts |
| Drop | A column the importer does not know; a task whose project ID does not resolve | Data silently disappears |
| Misread | Quote handling on name columns; a cell that reads as the literal string "null" | A value is silently changed |
It includes the importer's column-type map for every table we write, its date and duration parsing (including the integer-minute ranges durations are stored in), and a verbatim port of its calendar parser, which gives us a second, independent reader of clndr_data. The verifier runs it as a final stage: every %F field must be a known column of its table, numeric cells must be bare (no commas, exponents, NaN or Infinity), the importer's calendar parser must read back each calendar's workweek, and every activity and WBS name and code must survive import unchanged.
A property test drives this with hundreds of seeded random schedules built from an adversarial alphabet: tabs, newlines, lone and doubled quotes, non-ASCII text, the literal word "null", padding and over-length strings. For each seed, our verifier must pass, the importer model must not reject, names and codes must decode to the originals, and counts must match. The important property is that the first implies the second. A seed our verifier accepts but the importer model rejects is a new gap in our verifier, and the test says so.
Independent readers outside the test suite
Two optional checks read our emitted bytes with MPXJ, a widely used open-source library for project files: one exercises its calendar parser across five-, six- and seven-day weeks, and one reads complete files across a matrix of calendars and relationship types, asserting names, durations, WBS paths, constraints, signed lags and day-by-day working time. They run by hand in a pinned, network-isolated container and add no Java dependency to the product. They show how a strict third-party reader parses the bytes; they recalculate nothing.
Round-trip comparison: when a file comes back
The real question is what happens after an import and recalculation in the target system. That cannot be answered inside Connect, so we built a separate command for evidence gathered outside it. A person imports the exported file, recalculates, exports again, and runs:
check:xer-round-trip --source original.xer --returned recalculated.xer
--observation observation.json --out NEW_DIRECTORY
The comparison deliberately uses a raw reader, not Connect's importer. Our importer simplifies (it collapses calendars, drops external links, ignores actuals), and using it here would hide exactly the losses under test. The raw reader enforces the grammar strictly (header first, %E last, no duplicate tables or fields, row width equals header width) and builds a snapshot keyed by meaning rather than database ID:
- Activities by code, with names, types, durations, all date fields including actuals, floats, both constraints, WBS path and fully resolved calendar, inheritance included.
- WBS nodes by their code path.
- Relationships by predecessor, successor, type and lag, with external endpoints resolved through their project's short name.
IDs, export order and equivalent spellings of numbers are normalized. Time of day is not discarded, and calendar payloads are compared exactly. An unfamiliar serialization is flagged for review rather than assumed equivalent. Fields and tables the comparison does not examine are listed by name, so silence never reads as coverage.
The observation file has a strict schema: who ran the import, the Connect commit and schedule revision, the target application and version, ordered past timestamps for import, recalculation and export, and the scheduling settings used. The command writes both files, the observation and the comparison into a new directory it refuses to overwrite, records SHA-256 hashes, and labels the observation "operator-reported, not independently verified". Exit codes separate matched, different and invalid evidence.
Every comparison carries its own list of limitations, starting with this one: a match is a local file comparison, and import and recalculation must be observed and recorded independently.
Approval freezes the bytes
Verification is a hard precondition of approval. Approval reruns CPM, generates the file, verifies it against the original inputs, and only then stores an immutable approval record containing the exact XER bytes along with the calculation version and the dated output. Exporting an approved revision serves those stored bytes rather than regenerating them, so what the reviewer approved is what leaves.
The stored file is still checked against today's format rules on every export. If the rules have tightened since approval and the old file fails, the export is refused and the original record is preserved untouched. The fix is a new draft and a new review, never a silent regeneration under an old signature.
The import side: refuse before saving, keep human decisions
Revised schedules come back too: a scheduler adjusts the baseline in P6 and drops the new .xer on Connect. The importer reads fields by their %F names, never by position, because real exports carry extra tables and columns in any order. It converts hours to days using each activity's own calendar day length, and lags using the predecessor's calendar.
Calendars get the strictest treatment. The importer follows each selected calendar's base-calendar chain, keeping its own workweek and merging inherited holidays. A missing base calendar, a duplicate calendar ID, a cycle, or a date-specific partial working shift the model cannot represent all reject the file before anything is persisted. Our Playwright suite exercises exactly this: it uploads a file with a partial working shift on a holiday, then one with a missing base calendar, and asserts that each shows a clear error, that the upload control recovers, and that the schedule's version list is unchanged afterwards, at phone and desktop widths in both themes.
What the format cannot carry is kept from the prior revision instead of being lost. XER has no place for the human decisions on conflicting sources, required milestones, requirement inventories or activity evidence. An import carries them forward from the prior revision, re-attaching activity evidence by activity code and clearing any link whose activity no longer exists, and says plainly that they were retained but not re-reviewed against the imported changes. Lossy simplifications, such as progress being imported as baseline inputs or a secondary constraint being dropped, come back as warnings rather than being hidden.
What local verification does and does not establish
| Check | Establishes | Does not establish |
|---|---|---|
| Structural re-read | The file is well formed and internally consistent | That any importer reads it the same way |
| Original-input comparison | The file says what Connect calculated | That Connect's calculation matches another engine |
| Importer port | The bytes pass a faithful model of the importer's rules | Acceptance by the live importer |
| Independent readers | A strict third-party parser reads the bytes as intended | Recalculated dates |
| Round-trip comparison | Two files agree on the compared fields | That the import was performed as reported, or that nothing was omitted from both |
Acceptance by the downstream system is a separate, observed step with its own record. We keep that line visible in the code, in the comparison output and in the product copy.
Why this matters if you're building something similar
- Verify the bytes, not the object. Re-parse what you emitted with a reader that shares no code with the writer.
- Model the consumer's reader. Your own round trip cannot catch a mistake your reader and writer share. Port the target's reject, drop and misread rules from its source when you can.
- Fuzz with hostile strings. Delimiters, quotes and sentinel words like "null" find bugs that realistic fixtures never will.
- Compare against the inputs, not just the file. A parseable file can still have dropped a relationship.
- Freeze what was approved. Store exact output bytes with the approval, and refuse rather than regenerate when old output fails new rules.
- Write down the limits. Put the "this does not prove" list in the output itself, where a reader cannot miss it.
Where to go next
- The CPM engine, schedule health and Monte Carlo for the calculation the file carries.
- Schedule Builder engineering: Build Spec and gates for where export sits in the build loop.
- Primavera P6 MCP server for reading XER and P6 data through MCP.
- Architecture tests that enforce codebase boundaries for other invariants we pin in tests.
- The Connect architecture pillar for the platform as a whole.
If your product has to hand files to a system you do not control, talk to us about your build.
Frequently asked questions
What is the structure of a Primavera P6 XER file?
An XER file is tab-delimited text. It starts with an ERMHDR header line, then holds table blocks: %T names a table, %F lists its field names, and each %R line is one row with cells in field order. The file ends with %E. Durations and lags are stored in hours and dates as YYYY-MM-DD HH:MM.
Why is re-parsing your own XER output not enough?
If your importer and emitter share an assumption, for example about quotes or calendar grammar, a round trip through your own code will agree with itself and hide the bug. Connect also runs a model of the downstream importer's rules, transcribed from its source, plus hundreds of fuzzed schedules built from hostile strings.
Does passing verification mean P6 or Syncify will recalculate the same dates?
No. Local verification shows the file is well formed, matches Connect's calculation and passes a model of the importer's rules. Import and recalculation in the target system is a separate step that has to be observed and recorded, which is what the round-trip comparison tool is for.
How are holidays and calendars carried in an XER export?
Each calendar is a CALENDAR row whose clndr_data field holds a structured record of the working week and shifts. Connect writes every calendar an activity uses, under the operator's P6 calendar names, with holidays as calendar exceptions, so a recalculation lands on the same working days.
What happens when a revised XER is imported back into Connect?
Fields are read by name, not position. Calendar problems the model cannot represent, such as a missing base calendar or a partial working shift on a specific date, reject the file before anything is saved. Human decisions and evidence that XER cannot carry are kept from the prior revision, with a warning that they were not re-reviewed.
Next step
Thinking about a scheduling pilot?
Start with one exported schedule and an agreed set of checks. We'll discuss your format, method, and approval process.
Prefer email? charley@buildflows.ai
Get the next guide in your inbox
Field Notes: practical guides and new walkthroughs, about once a month.
Field Notes
Practical guides and new walkthroughs on construction data and automation, roughly monthly.
Keep learning
Scheduling & P6 · October 10, 2026
CPM Engine, Schedule Health and Monte Carlo Risk
A walkthrough of the deterministic engines behind Connect's schedules: CPM passes across calendars, gut checks, a DCMA-inspired health score, a grader that resists gaming, and seeded Monte Carlo risk, plus why the LLM never does the math.
Scheduling & P6 · October 9, 2026
Engineering an AI Schedule Builder: Build Specs, Gates and Repair Loops
The engineering behind the Connect Schedule Builder loop: the model edits a declarative Build Spec, code expands and gates it, reviewers and bounded repair passes drive it to done, and approval freezes verified evidence.
Scheduling & P6 · May 25, 2026
Building an MCP Server for Oracle Primavera P6
A walkthrough of P6 MCP: a governed MCP server that turns 585 Primavera P6 REST operations into a small discovery, planning, and execution surface, plus offline XER analysis.
