Reference EPDs & Constraints
A Reference EPD is the fully populated instance that proves an EPD Template and anchors a product family. It assigns a concrete value to every field and becomes the parent from which variant EPDs are derived — "ground zero" for the family.
What it does not own is the rulebook. The locked-field rules and the constraint/bound definitions that decide which inputs are valid belong to the EPD Template — the verified modeling envelope resolved from the specification inheritance chain (Standard spec → PCR spec → verifier review → Template). The Reference EPD is governed by that template and instantiates it with real numbers; it is the first instance validated against the template's constraints, not the place those constraints are defined.
This page fixes the three-way distinction first (Template vs Reference EPD vs Published EPD), then covers the operational mechanics that follow from it: constraint types, conditional application, each reference EPD's own versions of its constraints and when they can be edited, the reference EPD's own input values, validation and the submit rules, the Constraint Explorer, locked fields, the model lock recorded at approval, revising an approved reference, and how everything flows into variants.
Constraint definitions (range / statistical / formula), their severity, their conditional applies_if_json gating, and the spec-level locked fields are all properties of the Template — the resolved output of the specification inheritance chain (Standard spec → PCR spec → verifier review → Template). The Reference EPD does not define them — it supplies the concrete values those rules are checked against. Throughout this page, "the reference's constraints" is shorthand for the template's constraints, resolved onto the reference and inherited by every variant.
Three entities, precisely separated
These three concepts are routinely conflated. Hold them apart along four axes — what each one is, what it contains, when it is verified, and whether it can be published.
| EPD Template | Reference EPD | Published EPD | |
|---|---|---|---|
| What it is | The verified modeling envelope and reusable form — the resolved output of the spec inheritance chain. | A fully populated EPD instance that proves the template and anchors a family. It may describe a real product or a representative/synthetic instance deliberately constructed to exercise the template's edge cases and boundary conditions in a single populated record. | A publication state, not a separate record type — any Reference EPD or variant EPD that has been registered and declared publicly. |
| What it contains | The set of locked fields (inherited verbatim), the set of varying fields (user inputs), the constraints/bounds on those inputs, and the declared external input fields (ext__*) the constraints can read. | Concrete values for every field, including its own values for the template's external input fields (reference_inputs); variant lineage; the authoring_policy.locked_fields list pinned onto its own variants. | Nothing new — it is a Reference EPD or a variant EPD that has been registered. |
| When verified | In tandem with its Reference EPD — a template cannot be verified in the abstract (nothing to check until values exist). | In tandem with its template — approving the reference simultaneously confirms the locked fields, the input bounds, and that a complete EPD comes out valid. | Already verified before publication; publishing does not re-open verification. |
| Can it be published? | No — a template is a form + rulebook, not a declaration. | Yes, conditionally — if it describes a real product and the team chooses to. Otherwise it stays an unpublished but still-verified anchor. | It is the published artifact, by definition. |
The compressed analogy:
The Template is the blank form plus the rulebook for what values are allowed. The Reference EPD is that form filled in with representative numbers. The Published EPD is whatever you actually declare to the world — the reference itself, or its variants.
What a Reference EPD is (and is not)
A Reference EPD is:
- The single instance where every field carries a concrete value — nothing left as a range or a rule.
- The parent that variant EPDs inherit from. Variants change only the product-specific values; everything else is inherited from the reference.
- The artifact the verifier reviews to confirm the template behaves correctly end-to-end.
A Reference EPD is not:
- The owner of the constraint definitions. Those live on the template; the reference is checked against them.
- Necessarily a real product. It can be a representative, synthetic instance (see below).
- Necessarily a public declaration. It can stay an internal, verified anchor while its variants are published.
Verified in tandem: template + Reference EPD
A template defines bounds, locked values, and a form shape — but none of that can be verified in the abstract, because there is nothing concrete to check until values exist. The Reference EPD supplies those values, so the template and the reference are reviewed and approved together, in a single verification pass.
Approving the Reference EPD therefore confirms three things at once:
- The locked / inherited fields — every value variants will inherit verbatim is correct.
- The input bounds — the
range/statistical/formulaconstraints behave as intended against a real, fully populated EPD. - A complete EPD comes out valid — the populated instance is itself a conformant declaration.
The verifier confirms the input bounds one constraint at a time: every constraint the reference answers to is a row on its review's Constraints Review tab, and each row needs its own determination before the reference can be approved (see How the verifier reviews the constraints). Approval also locks the model the reference was submitted with — the EPD tool and its version, the PCR spec version, and the template's definition — so variants can be held to it (see Submission snapshots and the model lock).
This ordering is enforced operationally: the Reference EPD must be approved before any variant can be approved. See the Verification Workflow approval gates.
Two independent questions
A Reference EPD answers two questions that are completely independent of each other. Conflating them is the most common source of confusion.
Question 1 — Real or representative?
A Reference EPD can describe:
- A real product — an actual product the manufacturer makes, with its true values.
- A representative (synthetic) product — an instance deliberately constructed to exercise the template's full range of boundary conditions and edge cases at once. It is not a product you can buy; it is a single populated instance built to stress every corner of the modeling envelope, so that verifying it verifies the whole family's allowable space.
Question 2 — Published or not?
Independently of the above, a Reference EPD can be:
- Published — a real declaration registered publicly.
- Unpublished but verified — a still-verified reference whose only job is to prove the modeling envelope behaves correctly and to seed variants. It never goes public; the variants derived from it become the public artifacts.
These two axes are orthogonal. "Real vs. representative" describes what the reference models; "published vs. unpublished" describes whether it gets declared. Publication is orthogonal to the reference/variant distinction — being a reference says nothing about whether it is published, and being published says nothing about whether the published record is a reference or a variant.
The 2×2 matrix
Crossing the two axes gives four cases. Exactly one is impossible.
| Published | Unpublished (verified) | |
|---|---|---|
| Real | ✅ Flagship real product that also anchors the family | ✅ Real product used as the anchor but not declared publicly |
| Representative (synthetic) | ❌ Impossible | ✅ Built to span the family's boundary conditions; verified as proof, never declared |
One-line gloss per cell:
- Real + Published — A real, flagship product is both declared publicly and the parent for the family's variants. The reference is itself a published EPD.
- Real + Unpublished — A real product anchors the family and is verified, but the team chooses not to declare it publicly. Its variants are what get published.
- Representative + Unpublished — A synthetic instance is constructed to hit the template's edge cases and verified as proof the envelope behaves. It is never published; the published artifacts are its variants.
- Representative + Published — Impossible. You cannot publish a declaration for a product that does not exist. A synthetic instance can be verified as a reference, but it can never be the public declaration; it publishes only through its (real) variants.
"Reference" is about lineage (the parent of a family). "Published" is about disclosure (what goes public). The only hard rule connecting the two axes: a synthetic instance can never be the published artifact, so a synthetic reference always publishes through its variants.
Standard vs Reference EPDs
| Aspect | Standard EPD | Reference EPD |
|---|---|---|
| Purpose | Standalone declaration for a single product | Parent declaration for a product family |
| Variants | Cannot generate variants | Can generate variant EPDs |
| Constraints | Template constraints validated against the document at submission | Template constraints checked at submission against the document and the EPD's own reference values, reviewed row by row by the verifier, and re-checked on every derived variant |
| Conversion | Can be morphed to reference | Already reference |
Converting to a Reference EPD
Any draft standard EPD can be converted:
POST /api/v2/epds/{epd_id}/morph-to-reference/
This changes epd_type from standard to reference. The operation is irreversible.
How constraints flow
Constraints originate in the specification chain and are resolved into the project template. The Reference EPD is then validated against that template, and the same constraints flow down to every variant — the reference is the instance the resolved template is applied to, never the place the constraints are defined:
Note the ownership: constraints resolve into the Resolved Template (red), and the Reference EPD (indigo) is the populated instance checked against them.
Constraint types
| Type | Description | Example parameters |
|---|---|---|
range | Numeric min/max bounds with configurable inclusivity | {"min": 0, "max": 1000, "bounds_mode": "inclusive"} |
statistical | Z-score bounds against a population distribution | {"distribution": "normal", "mean": 50, "std_dev": 10, "threshold": 2} |
formula | Expression computes a number, compared against a bound | {"expression": "a1 + a2 + a3", "operator": "lte", "threshold": 1000} |
allowed_values | Value must be in a fixed list of allowed strings | {"values": ["Ecoinvent 3.9", "GaBi 2023"]} |
The formula bound key is threshold (or min/max with the between operator) — not value, which is silently ignored. For each type's full parameter reference and the formula expression grammar, see EPD Templating.
Constraint severity
- error — blocks variant approve and auto-approve. When the template enforces its constraints, it also blocks a reference EPD's submission unless the author explicitly acknowledges the failure (see Submitting a reference EPD).
- warning — flagged but does not block
- info — informational only
When the template's constraints_enforced flag is off, nothing blocks a reference EPD's submission: its rulings are informational, and the verifier still reviews each constraint.
Conditional constraints (applies_if_json)
Any constraint type can be made conditional. The applies_if_json field specifies when the constraint should be evaluated:
{
"constraint_type": "range",
"parameters_json": {"min": 0, "max": 500},
"applies_if_json": {"field": "product_type", "operator": "eq", "value": "concrete"},
"severity": "error"
}
Supported applies_if_json operators are eq, neq, in, gt, gte, lt, lte, present, and absent — see the conditional application grammar for the full shape, including all/any combinators.
When applies_if_json is null, the constraint is always evaluated.
Reading the template to understand expected data
Before filling EPD sections, fetch the template to see what fields exist and what constraints apply. This is the template's job — it is the authoritative source for the form shape, the locked values, and the bounds the Reference EPD must satisfy:
GET /api/v2/epds/{epd_id}/template/
This is the public template endpoint. It requires a reference EPD (epd_type: "reference") — standard EPDs return 400, and an EPD with no attached template returns 404. It returns the reference EPD's current constraint version (see Constraint versions and the edit lock): the project's shared working template while the EPD is a never-submitted draft, and the EPD's own version from its first submission on. (A project-scoped GET /api/v2/projects/{project_id}/template/ exists but is internal — it is not part of the public API surface; build against the EPD-scoped route.)
The response contains, alongside the template's id, status and version:
locked_fieldsandunlocked_fields— the resolved input field definitions, classified by whether variants may change them. Each entry is an object withfield_id,label,type(text, number, decimal, select, table, etc.),help_text,placeholder,group_id,order,is_required,default_value,options_json(valid choices for select fields),show_if_json(conditional display logic),locked, andlocked_value(the pre-filled value when locked). Unlocked entries additionally carryconstraint_defs— the spec-level constraints on that field.template_constraints— the template-level constraint list. Each entry hasconstraint_type,parameters_json,severity,label,reference_field_id(the anchor field violations are attributed to), and optionalapplies_if_json.constraints_enforced— whether the constraints gate submission. Whentrue, a reference EPD needs a value for every external input its constraints need, and error-severity failures block unless acknowledged. Whenfalse, violations are surfaced but nothing blocks.external_input_fields— declared tool-side inputs (IDs prefixedext__) that constraint expressions can reference in addition to EPD document fields. Each has afield_id,label,data_type(number,string,boolean,arrayorobject) andrequiredflag. Each reference EPD supplies its own values for them (see Entering the reference values).sample_external_payload— a what-if scratchpad for trying constraints (the editor's Test Constraints panel). It is not what a reference EPD is submitted or verified with, and it is not part of a version:PUT /api/v2/epds/{epd_id}/template/sample-payload/with{"sample_external_payload": {...}}saves it in any status.sections— the template's narrative section list (id,title,content,is_required,order,spec_field_id). These are prose sections, not the input field definitions above — don't conflate the two.- The version and its edit lock —
reference_epd_id(nullfor the project's working template, else the reference EPD that owns this version),supersedes_id(a revision: the approved version it revises),forked_from_id(av1: the working template it was copied from),editable,locked_reasonandlocked_message(whether aPUTwould be accepted now, and if not, the refusal it would get),shared_draft_count(working template only: how many draft reference EPDs share it;nullon a reference's own version) andapproved({id, version, status, approved_at}of the version the EPD's variants answer to, ornullbefore its first approval).
Constraint versions and the edit lock
The verifier approves a reference EPD together with the constraints it was submitted with, so those constraints cannot keep changing afterwards. Each reference EPD therefore owns versions of its constraint definition (its constraints, external input fields, enforcement flag and AI sanity instructions), numbered v1, v2, and so on:
- Draft reference EPDs share the project's working template. Until a reference EPD is first submitted, it edits the project's working template, and every other never-submitted draft reference EPD in the project sees the same edit. This keeps "edit once for every draft" working for a family of references. The editor says so: Shared by N draft reference EPDs.
- The first submit gives the EPD its own
v1. In the same step that moves the EPD into review, the working template is copied into the EPD's ownv1and frozen. Its spec-level constraints are resolved with the EPD's pinned PCR spec version and spec parameters, and those parameters are pinned onto the version, so a later project or PCC change does not alter it. From then on the EPD never reads the working template again: later edits by sibling drafts do not reach it. - Approval puts the version in force. Approving the reference EPD approves its version and records it as the EPD's
approved_template_id, the version its variants are validated against. It also freezes a copy of the reference's locked-field list and document as approved, which is what variants are held to. - A published reference keeps its version. See Revising an approved reference EPD.
Whether the definition can be edited depends on the EPD's status. The server decides, and every read reports it (editable, locked_reason, locked_message):
| EPD status | Its constraints | PUT /epds/{epd_id}/template/ |
|---|---|---|
draft, never submitted | The project's working template, shared with the other draft references | Accepted |
in_review | Its own version, frozen for review | 409 template_locked, action in_review |
flagged | Its own version, back in draft so the author can fix what the verifier flagged | Accepted; frozen again at resubmission |
approved | Its approved version, in force for its variants | 409 template_locked, action start_revision |
draft or flagged, during a revision | The revision's own draft version | Accepted |
published | Its approved version | 409 template_locked, action start_republishing |
Staff get no exception: a frozen version is what a verifier judged. The refusal names the way forward in details:
{
"error": {
"code": "template_locked",
"message": "Constraints v2 are approved and in force for this EPD's variants. Start a revision to change them; v2 stays in force until the revision is approved.",
"details": [{"field": "action", "message": "start_revision"}]
}
}
A flagged reference EPD edits the version it was submitted with, in place: nobody else uses that version, and a verifier who flags a constraint needs the author to be able to fix it. The verifier's rows show what changed at the resubmission (see How the verifier reviews the constraints).
Saving from a stale copy
The working template is shared by every draft reference EPD in the project and can be edited from each of their editors and from the project's template editor, and a save replaces whole lists. To make sure a save never undoes an edit made since you loaded the constraints, send the updated_at you loaded as base_updated_at with PUT /api/v2/epds/{epd_id}/template/ (or the project template PUT). If the version has been saved since, nothing is written and the save is refused:
{
"error": {
"code": "template_conflict",
"message": "These constraints were changed since you opened them. Reload to see the latest version before saving.",
"details": [{"field": "updated_at", "message": "2026-09-26T15:31:02.118412+00:00"}]
}
}
details carries the version's current updated_at. Reload, reapply your change and save again with the new value. Every successful save returns the new updated_at, sections-only saves included. base_updated_at is optional: a save without it is not checked. The same-EPD race with a submit is handled for you: a save that arrives while the EPD is being submitted waits for the submit and then gets 409 template_locked, so a save never reports success while the submitted version lacks it.
GET /api/v2/epds/{epd_id}/template/versions/ lists the EPD's own versions, newest first, with the same fields as a template (id, version, status, reference_epd_id, supersedes_id, forked_from_id, approved_at, …). A never-submitted draft has none, so it returns []. A version's status is draft, in_review, approved, superseded (an approved version replaced by an approved revision) or archived (a discarded revision).
Every EPD response also carries the version summary for a reference EPD, as template_version (it is null for other EPD types), and approved_template_id:
{
"id": "…",
"version": 3,
"status": "draft",
"is_working_template": false,
"editable": true,
"locked_reason": null,
"locked_message": "",
"approved": {"id": "…", "version": 2, "status": "approved", "approved_at": "2026-09-20T09:12:44+00:00"},
"revises": {"id": "…", "version": 2},
"revision_open": true,
"can_start_revision": false,
"next_revision_version": null,
"can_discard_revision": true
}
can_start_revision and can_discard_revision say what the EPD's state allows. Whether the caller may write the EPD at all is a separate permission check. next_revision_version is the version number Start revision will create, or null when a revision cannot be started. Numbers are never reused: after a discarded v3, the next revision of an approved v2 is v4.
The project-wide submit, POST /api/v2/projects/{project_id}/template/submit/, is retired and returns 400 endpoint_retired: each reference EPD is submitted on its own. POST /api/v2/templates/{template_id}/approve/ and /reject/ refuse a reference EPD's own version with 400 use_epd_review, because approving the reference EPD approves its version. These project and template endpoints are internal.
Entering the reference values
A reference EPD's constraints and external input fields come from its constraint version, which draft reference EPDs share (see Constraint versions and the edit lock). The values for those external inputs are never shared. Each reference EPD carries its own, in reference_inputs, keyed by the full field ID (ext__cement_content). They are kept apart from the EPD's document, because external inputs are not form fields, and from the template's sample_external_payload, which is only a scratchpad. Submission, the verifier's review and the Constraint Explorer all read reference_inputs.
In the editor, open the reference EPD's Constraints tab. When the template enforces its constraints and declares at least one external input field, a Reference EPD values card, marked this EPD only, sits above the External Input Fields and Constraints cards. A banner above those cards names the constraint version and whether it can be edited: shared with the project's other draft reference EPDs, the EPD's own draft, frozen for review, or approved. When the version is locked, the definition cards are read-only. The card has one row per external input field, with an input matching the field's data_type. Beside each input is a live ruling for every constraint that reads the field: passes, fails, warning, doesn't apply, or needs a value. Constraints on the EPD's own form fields are listed under Checked against form values and ruled on the last saved form. The rulings come from the server's constraint engine, the same one submission uses, and they follow your unsaved edits to both the values and the constraint list. Save stores the values.
The values can be edited while the EPD is draft, flagged or republishing, the same statuses as the document. From submission onward they are frozen with the rest of the EPD, and the card shows them read-only.
A reference EPD's constraints are ruled on its reference values, which the form never sees. The editor's validation-issues banner therefore does not list template constraints for a reference EPD; the Reference EPD values card is where they are ruled. Document-shape issues, such as a missing required form field or a value of the wrong type, still show there. Variant EPDs are unaffected.
Over the API, replace the values with PUT /api/v2/epds/{epd_id}/reference-inputs/:
curl -X PUT https://sandbox.smartepd.xyz/api/v2/epds/$REF_EPD_ID/reference-inputs/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reference_inputs": {"strength_class": "C30/37", "cement_content": 320}}'
- The body is a full replacement, not a merge. A key left out is removed, and so is a key sent as
nullor as blank text. - Keys may be given with or without the
ext__prefix. They are stored with it. - A key that is not one of the template's
external_input_fieldsis rejected with400 validation_error. - Each value must match its field's
data_type.numbertakes a finite number (nottrue/false),stringtakes text,booleantakestrue/false,arraytakes a list, andobjecttakes an object. A mismatch is a400 validation_errornaming the field, and so is a number too large for a double-precision float ("out of range"). - At most 200 values and 64 KB.
- Only a reference EPD with a template accepts values; any other EPD returns
400. So does a reference EPD outside the editable statuses.
The response is the EPD detail. Every EPD response carries reference_inputs ({} on EPDs that are not references) and constraint_failures_acknowledged_at (see Submitting a reference EPD).
Validating against constraints
Constraint validation checks input values against the template's constraint rules. Three public endpoints run it:
- Reference values —
POST /api/v2/epds/{epd_id}/reference-inputs/evaluate/(reference EPDs only). Read-only. It rules every constraint the reference EPD answers to, spec-level and template-level, against its document and its storedreference_inputs, and says whether submission would refuse them. This is the one evaluation that submission, the editor's Reference EPD values card and the verifier's constraint rows all use. See Evaluating a reference EPD's values. - Template dry-run —
POST /api/v2/epds/{epd_id}/template/test-constraints/(reference EPDs only). Read-only: feed arbitrary candidateepd_field_values/external_payloadand see which constraints would fire, without saving anything. It never reads the reference EPD's stored values, so it is a what-if tool, like the editor's Test Constraints panel. The response also reports the template'sconstraints_enforcedflag, so a client can say "would fail, but enforcement is off" instead of claiming the value will be rejected. Passinclude_report: trueto additionally receive arulingsentry for every constraint — passes andapplies_ifgate-skips included — so you can tell "passed" from "didn't apply". - Per variant —
POST /api/v2/epds/{epd_id}/variants/{variant_id}/validate/— validates the variant EPD's document together with the variant's parameters (merged in under theext__prefix) against the reference's template.
(A project-scoped POST /api/v2/projects/{project_id}/template/validate/ exists but is internal — not part of the public API surface.)
The dry-run and variant responses both carry a top-level valid flag, has_blocking_errors, and a violations list — but note valid means different things on the two endpoints: the dry-run sets valid: false when any violation exists (warnings and info included), while the variant endpoint sets valid: false only for error-severity violations (a warnings-only result is valid: true). Key logic off has_blocking_errors and per-violation severity, not off valid. Violation entries look like:
{
"field_id": "gwp_a1a3",
"constraint_type": "range",
"severity": "error",
"message": "1500.0 is outside [0, 1000]",
"valid_range": "[0, 1000]",
"actual_value": 1500.0
}
The same six keys appear in both, but the schemas differ in which are required: the template dry-run guarantees message and valid_range (with severity defaulting to "error"), while the variant endpoint guarantees severity (with message and valid_range defaulting to ""). actual_value is a number, string, or null on the variant endpoint and untyped on the dry-run. A shared parser should treat message, valid_range, and severity as optional-with-defaults on both. field_id is always the constraint's anchor field (reference_field_id for template-level constraints), including for multi-field formula expressions.
validate vs constraint validationPOST /api/v2/epds/{epd_id}/validate/ is a separate, requirements-level check — it returns { is_valid, requirement_results, summary, ... }, not constraint violations. Use the variant or template validate endpoints above when you want the range / statistical / formula results.
Evaluating a reference EPD's values
POST /api/v2/epds/{epd_id}/reference-inputs/evaluate/ takes an optional body:
{
"reference_inputs": {"cement_content": 450},
"template_constraints": null
}
- Omit
reference_inputs(or sendnull) to evaluate the stored values. Pass values to preview them without saving; preview values are not type-checked or limited to declared fields, because the engine rules on whatever it is given. - Omit
template_constraints(or sendnull) to use the saved template list. Pass a list to evaluate unsaved constraint edits in its place: at most 50 entries, and aformulaexpression at most 500 characters and 32 brackets deep. Only a caller who can edit the EPD may pass one; anyone else, the assigned verifier included, gets403 forbiddenand evaluates the saved template. Spec-level constraints are always included. sourceis"live"by default: the EPD's current constraint version and stored values, the author's view. Send"submission"to rule the definitions, enforcement, input field definitions and values of the EPD's current submission snapshot instead, which is what the verifier's constraint rows show. That is its latest submission, except for an approved or published reference, where it is the submission approval locked. Edits made since, such as a flagged author's unsaved changes, do not reach it.reference_inputsgiven with it are a what-if over the snapshot's definitions."submission"cannot be combined withtemplate_constraints(400), and any other value is a400. The response'ssource_usedsays which was ruled: an EPD with no submission snapshot falls back toliveand says so. When it issubmission,snapshot_idnames the snapshot.
The response:
{
"enforced": true,
"rulings": [
{
"key": "TMPL-CONSTRAINT-ext__cement_content-0",
"source": "template",
"field_id": "ext__cement_content",
"label": "Cement content within the verified range",
"constraint_type": "range",
"parameters": {"min": 250, "max": 400},
"severity": "error",
"status": "failed",
"is_violation": true,
"actual_value": 450,
"value_provided": true,
"value_source": "reference_inputs",
"message": "450.0 is outside [250, 400]",
"valid_range": "[250, 400]",
"gate_description": ""
}
],
"missing_inputs": [{"field_id": "ext__strength_class", "label": "Strength Class"}],
"blocking_failures": [{"key": "TMPL-CONSTRAINT-ext__cement_content-0", "...": "the failing ruling above"}],
"would_block": true
}
| Key | Meaning |
|---|---|
rulings[].key | Stable ID of the constraint: CONSTRAINT-{field_id}-{idx} for a spec-level constraint, TMPL-CONSTRAINT-{field_id}-{idx} for a template constraint (idx is its position in template_constraints). It is also the ID of the constraint's row on the verifier checklist. |
rulings[].source | spec or template. |
rulings[].status | passed, failed, missing_value, skipped_gate (its applies_if_json gate is not met), not_evaluable or misconfigured. Every status except passed and skipped_gate is a violation (is_violation: true). |
rulings[].actual_value, value_provided | The value the constraint read, and whether there was one. |
rulings[].value_source | reference_inputs for an ext__ field, document for any other field. |
missing_inputs | Enforced templates only. External fields with no value that are marked required, or read by a constraint that applies, as its anchor or as a name in its formula expression, or compared by a constraint's applies_if_json gate while the values given leave that gate undecided. A blank gate field is not needed when the gate's outcome is already decided without it: an all with a branch that is already false, an any with a branch that is already true, or a present / absent test on the same field, which reads a blank as its answer. For example, {"all": [{"field": "product_type", "operator": "eq", "value": "steel"}, {"field": "ext__grade", "operator": "eq", "value": "A"}]} on an aluminium product needs no ext__grade, and neither does {"all": [{"field": "ext__coating", "operator": "present"}, {"field": "ext__coating", "operator": "gt", "value": 0}]} when ext__coating is blank. A constraint whose gate is not met does not make its own fields required. Listed in external_input_fields order. |
blocking_failures | Enforced templates only. Error-severity violations, other than those that only fail because of the missing values already listed. |
would_block | true when the template is enforced and there is a missing value or a blocking failure, meaning a plain submit would refuse these values. |
source_used, snapshot_id | live or submission (see source above), and the submission snapshot ruled, when there is one. |
input_fields | The external input field definitions the rulings were made with, as {field_id, label, data_type, required}: the current constraint version's for live, the submission's own copy for submission. A flagged author who renames, retypes or removes a field changes the live list only. |
Submitting a reference EPD
POST /api/v2/epds/{epd_id}/submit/ first freezes the EPD's constraint version for review: at a first submit it copies the shared working template into the EPD's own v1; for a flagged EPD or a revision it freezes the EPD's own draft version. It then runs the same evaluation on the stored reference values, against that frozen version. What happens next depends on the version's constraints_enforced flag:
| Enforced | Not enforced | |
|---|---|---|
A value is missing — an external field marked required, one an applicable constraint reads, or one a constraint's gate compares while the other values leave that gate undecided (see missing_inputs above) | Blocks with 400 reference_inputs_missing. This cannot be acknowledged: without the value there is nothing for the verifier to judge. | Does not block |
| An error-severity constraint fails | Blocks with 400 constraint_failures, unless the submit carries acknowledge_constraint_failures=true | Does not block |
| A warning or info constraint fails | Does not block | Does not block |
Document-shape errors (shape_validation_error) are checked first, as for every EPD. Whatever the outcome, every ruling reaches the verifier as a constraint row on the review checklist.
A missing value names the fields:
{
"error": {
"code": "reference_inputs_missing",
"message": "Enter this reference EPD's value for: Strength Class, Cement Content (kg/m3).",
"details": [
{"field_id": "ext__strength_class", "label": "Strength Class"},
{"field_id": "ext__cement_content", "label": "Cement Content (kg/m3)"}
]
}
}
A failing constraint returns the failing rulings as details, in the same shape as the evaluate endpoint's rulings:
{
"error": {
"code": "constraint_failures",
"message": "1 constraint(s) fail with this EPD's reference values. Fix the values, or submit anyway and the verifier will decide.",
"details": [
{"key": "TMPL-CONSTRAINT-ext__cement_content-0", "status": "failed", "severity": "error", "actual_value": 450, "...": "..."}
]
}
}
To submit anyway, repeat the call with the acknowledgement. It is a query parameter, false by default, so a bodiless submit still works:
curl -X POST "https://sandbox.smartepd.xyz/api/v2/epds/$REF_EPD_ID/submit/?acknowledge_constraint_failures=true" \
-H "Authorization: Bearer $TOKEN"
The acknowledgement is stamped on the EPD as constraint_failures_acknowledged_at and shown to the verifier, who still gives every failing constraint its own determination. A submit with no failing error-severity constraint sets the timestamp back to null. The flag does nothing for missing values.
In the editor, a submit with missing values shows a message naming the fields and switches to the Constraints tab. Failing constraints open a dialog that lists each one with its submitted value, rule and message, and offers Go back and fix or Submit anyway.
The same rules apply when the author resubmits a flagged reference EPD. The values and the EPD's own constraint version may have changed while it was flagged, so the version is frozen again and the values are checked again, with the same codes and the same acknowledge_constraint_failures flag. A new acknowledgement, or none, replaces the previous round's.
Reference EPDs are always submitted one at a time: the project-wide template submit that moved every draft EPD into review at once is retired.
On a reference EPD, constraint_violations holds only non-blocking document-shape entries; constraint rulings live on the verifier's constraint rows. Every submit rewrites constraint_violations for every EPD type, setting it to null when there is nothing to report, so entries from an earlier editor save never carry into review.
Exploring the input space
The Constraint Explorer shows where a reference EPD's constraints pass, warn or fail as its inputs change. Open it with Explore input space: authors find it on the Reference EPD values card, verifiers on the toolbar of the Constraints Review tab. An author explores the EPD's current constraints from their draft values. A verifier explores the constraints as submitted (source: "submission"), starting from the submitted values, so a flagged author's unsubmitted edits never change what the verifier sees; the explorer says when an EPD has no submission on record and the current constraints are used instead.
- Every numeric field a constraint reads gets a slider and a number input. The default range comes from the constraint bounds.
- Pick one field as the X axis to get a pass/fail strip, or two fields to get a heatmap. Cells show pass, warning, fail or doesn't apply, each with a pattern or glyph as well as a colour, and the legend is always visible. The current point is a marker you can drag or nudge with the arrow keys. Hovering a cell lists its values and the constraints that fail there.
- A side list gives every constraint's verdict at the current point, and a headline reports what share of the explored space passes.
- An author can copy the explored point into the Reference EPD values card with Use these values. It is stored only when the EPD is saved. Exploring never changes a submitted EPD.
Every cell is evaluated on the server by the same constraint engine submission uses; the explorer never approximates it in the browser. The grid comes from POST /api/v2/epds/{epd_id}/reference-inputs/explore/, an internal endpoint that is not part of the public API contract. It accepts the same source as the evaluate endpoint and reports source_used, snapshot_id and input_fields the same way. It takes one or two axes. Each axis must be a numeric field that some constraint reads: as its anchor, in its applies_if_json gate, or by name in a formula expression. It needs a finite min below max and 2 to 81 steps, for at most 6,561 cells in all. Whole-number axis values come back (and are evaluated) as integers, exactly as evaluate and submission read a value the editor sends. A grid is refused with 400 before any work when cells × the constraints an axis can change exceeds 150,000, and after 10 seconds of computing; use fewer steps. template_constraints follows the evaluate endpoint's rules. Each cell comes back as p (everything passes), w (a warning or info constraint fails), f (an error constraint fails) or s (no constraint applies, because every one's gate is unmet). Nothing is written.
How the verifier reviews the constraints
While a reference EPD is in review, its verification checklist has one constraint row for every constraint its submission was judged against, spec-level and template alike, keyed by the same key as the evaluate rulings. There is also one variant governance row covering the locked-field envelope: the locked fields with their values, and the fields left free to vary. Each constraint row stores the constraint's definition and its evaluation on the submitted values: the status, the submitted value and the engine's message.
The engine suggests a determination for each row:
| Ruling | Enforced | Not enforced |
|---|---|---|
| Passes | Y | Y |
| Gate not met | N/A | N/A |
| Error-severity violation | N | OFI |
| Warning / info violation | OFI | OFI |
| No reference value submitted | N (error) or OFI | No suggestion |
The codes are the checklist's; the review page shows them as C / NC / OFI / N/A. A suggestion is never a verdict. The verifier sets each row's determination on the Constraints Review tab, one row at a time. Bulk determination, "Accept all AI suggestions" and Copy Review never set constraint rows. A reference EPD whose template defines any constraint cannot be approved without a checklist, and cannot be approved until every constraint row has the verifier's determination and none is N. See Verification Workflow.
The rows mirror the submission, not the live template. Every submission and resubmission freezes the full rulings (each constraint's definition, its gate and its evaluation) into the EPD's submission snapshot, and the rows are built from that snapshot. The EPD's constraint version is frozen while it is in review. While it is flagged, the author can edit that version and the values, but neither reaches the rows until the EPD is resubmitted. When a resubmission changes things:
- a constraint that is new at this submission gets a new row;
- a row whose constraint definition or submitted value changed loses its determination, because the verifier never judged what it now shows. Its notes stay, and the row records
constraint_source.verdict_reset={"previous_status": "Y", "reason": "definition_changed" | "value_changed", "at": "<time>"}so the review page can say why. Rows that did not change keep their determinations; - a row for a constraint the submission no longer has is removed. The checklist's history still records it;
- the variant governance row shows the lock envelope the submission carried. When a resubmission (a flagged round or a revision) changes the locked fields, a locked value or the fields left free, the row is rewritten and loses its determination (
verdict_reset, reasondefinition_changed), because approval freezes exactly that envelope for the variants. The row is never removed: a submission that locks nothing rewrites it to an explicit "no locks" envelope (locked_fields: []), so a revision that lifts every lock still needs the verifier to confirm it. Only a reference that has never had locks has no governance row; - a signed-off checklist that needs any of these is reopened. So is the signed-off checklist of an approved reference EPD when a revision of it is submitted: the rows carry their determinations into the revision's review, and every row the revision changed loses its determination as above.
The review page's own checklist read (GET /api/v2/checklists/{checklist_id}/, and GET /api/v2/checklists/by-epd/{epd_id}/) brings the rows in step with the latest submission and writes nothing when they already are. It does this only while the EPD is submitted, in_review or flagged. The approve gate does the same first. If that adds a row or clears a determination, approval is refused with a message asking the verifier to reload the review page. Once the reference EPD is approved the rows stop updating: they are the record of what was approved, and the model lock is that same submission snapshot. A read of an approved or published EPD's checklist never reopens it or clears a determination. A reference EPD submitted before rulings were recorded has no snapshot to mirror, so its rows are ruled on its current values while it is in review.
Impact on variants
When a variant EPD is created from a reference, the variant's parameter values are validated against the reference's approved constraint version (approved_template_id), never against a version still being drafted or reviewed. Error-severity violations block auto-approval. A variant is also held to its reference's locked model; see Variant conformance below and Variant EPDs for details.
A variant's EPD responses carry variant_envelope:
template_idandversionof the reference version it is validated against (nullwhen the reference has no approved version);reference_revision_open(see While a revision is open);locked_field_ids, the fields that approved version locks;locked_values, those fields' values as approved, keyed by field ID.
The variant editor renders its locked fields from locked_field_ids and locked_values, not from the variant's own authoring_policy. That copy was taken when the variant was created, and an approved revision of the reference can change it. A document save must carry the locked_values for the locked fields (see Enforcement).
Locking fields so variants can't change them
Reference EPDs can declare a set of fields that every variant derived from them must inherit verbatim. This is distinct from the template-level locked field on individual spec field definitions (which is fixed by the PCR spec — i.e. owned by the template) — the reference-level lock list is set by the reference author and only applies to EPDs derived from that specific reference.
The typical use case is locking methodology fields (LCA software, primary data year, declared unit, reference standards) so every variant inherits the same methodological baseline. Variants then differ only in product-specific values like impacts and plant data.
Set the lock list when you save the reference EPD's document:
curl -X PUT https://sandbox.smartepd.xyz/api/v2/epds/$REF_EPD_ID/document/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_name": "...",
"document": { /* reference EPD fields */ },
"authoring_policy": {
"locked_fields": [
"lca-software",
"lca-software-version",
"primary-data-year",
"declared-unit",
"references"
]
}
}'
authoring_policy is a JSON object dedicated to per-EPD authoring/governance metadata. It's separate from display_config (which controls which sections render) so that display-config rewrites can't accidentally clobber the lock list.
Enforcement
When a variant is created from a reference EPD with locked_fields set:
- The variant inherits the reference's
authoring_policyat creation. - Any subsequent
PUT /api/v2/epds/{variant_id}/document/call that diverges from the reference on a locked field is rejected with a400 validation_error. The lock list and the reference values it is compared with are the ones frozen when the reference's constraint version was approved, so a revision in progress on the reference never changes what its variants may save:{"error": {"code": "validation_error","message": "Field 'lca-software' is locked by the reference EPD and cannot be modified.","details": null}} - Unlocked fields in the same payload are saved normally — only the divergent locked fields trigger the rejection.
- The variant's stored document is checked the same way when it is submitted, resubmitted after a verifier requests changes, approved by a verifier, or considered for automatic approval. A variant whose stored value for a locked field differs from the approved value, for example one saved before a revision of the reference was approved, is refused with a
400 validation_error: "This variant does not carry the locked values of its reference's approved constraints (v3): Field 'declared-unit' is locked by the reference EPD and cannot be modified. Open the variant and save it to take the reference's values." Background auto-verification rejects it with that reason instead of approving it.
The server resolves each locked form field ID to its canonical document key using the spec's field bindings, so the lock applies regardless of how the incoming document is keyed. If a locked field ID has no binding (e.g. an orphaned entry from a refactor), the enforcement silently skips it rather than erroring — expected field IDs should always have bindings in a properly configured spec.
Submission snapshots and the model lock
Locked fields pin what a variant's document may say. The model lock pins what produced it. Every submission of a reference or variant EPD records an engine snapshot: an append-only record of the model the EPD was submitted against. Approving the reference locks its snapshot, and each variant is compared with that lock. Standard EPDs are not snapshotted; they keep their declared LCA software in the document.
What a snapshot records
| Block | Contents |
|---|---|
epd | The EPD's ID, type and project, its pinned PCR spec version (spec_version_id), and the PCR's name and version. |
submission | How the snapshot was taken (transition, below), the submitting user, and whether failing constraints were acknowledged. |
tool | The project's EPD tool at that moment: pre-approved or custom, ID, name and version, plus its provider organization, LCA platform, verification date and validity period. The version is the one pinned on the project's PCC, or else the tool's own. The expiry date is stored as it stood at capture, even if the tool is re-verified later. null when the project uses no tool. |
declared_software | The document's own LCA software and database declaration, as stored. |
template | The template's ID, version and status, whether it enforces its constraints, its definition_digest (a sha256: digest of its constraints and external input fields) and the external_input_fields themselves. |
inputs | A reference EPD's reference_inputs, or a variant's parameters and field inputs. |
constraints | Whether the constraints were enforced, how many were evaluated, and the keys of the failing (error) and warning constraints. |
results_digest | A sha256: digest of the EPD's results values, leaving out the discussion prose. |
platform | The SmartEPD release and the indicator catalog version. |
model_lock | Variants only: the verdict against the reference's lock (see Variant conformance). |
variant_governance | Reference EPDs only (null for a variant): the lock envelope as submitted, locked_fields (field, label, value and where the lock comes from) and unlocked_unconstrained. The verifier's governance row mirrors it. |
A snapshot is written in the same transaction as the submission, so no submission lands without one. The transition value records what triggered it:
transition | Written when |
|---|---|
submit | The EPD is submitted, including a variant approved automatically at submit. |
resubmit | A flagged EPD is resubmitted. |
template_submit | The template was submitted for review, which moved the EPD from draft into review. That submit is retired; older snapshots still carry this value. |
auto_verification | Background auto-verification approves a variant. |
approval_capture | A reference EPD is approved with no snapshot, because it was submitted before snapshots existed. |
backfill | An operator locks a reference EPD that was approved before snapshots existed (see below). |
The last two are taken after the fact, so they record the coordinates at that moment, not what was originally submitted.
The payload is stored as RFC 8785 canonical JSON, and payload_digest is the sha256 of those exact bytes, so anyone can recompute it. The database refuses to change a snapshot's contents or delete it; only its lock can be set afterwards. Each one supersedes the EPD's previous snapshot, and snapshots outlive the EPD.
Snapshots are an internal audit trail. They are not part of the EPD document or the published EPD. Workspace members and the EPD's assigned verifier can list them, newest first, with GET /api/v2/epds/{epd_id}/engine-snapshots/, an internal endpoint that is not part of the public API contract. Each entry has id, source, recorded_at, payload_digest, locked_at, supersedes_id, the parsed payload, and the payload_canonical text the digest covers.
The reference model lock
Approving a reference EPD locks its latest submission snapshot by setting locked_at. Every approval path reaches the lock: the verifier's approval, and the non-production force-approve shortcuts too. If the reference has no snapshot, one is taken at approval (approval_capture) and locked in the same write. Approving it again keeps the first lock, unless the reference was resubmitted since. The locked snapshot always names the approved constraint version. Approving a revision locks the revision's submission; the earlier lock stays on record.
References approved before snapshots existed stay unlocked until an operator runs the lock_reference_models management command. It does a dry run by default and writes only with --apply. Each such reference is locked on its coordinates as they stand when the command runs, marked backfill. Until then, their variants report no_lock.
Variant conformance
Each variant is compared with its reference's locked snapshot on the coordinates that decide what the model computes:
| Coordinate | Drift when |
|---|---|
tool | The variant's EPD tool is a different tool (kind or ID). |
tool.version | Same tool, different version. |
spec_version_id | The PCR spec version differs. |
template.id | The variant was validated against a different constraint version from the one the lock names: the reference's constraints were revised. The drift reads "Reference constraints revised (v1 → v2)", with locked / actual as "v1" / "v2" (or the version IDs, for a snapshot recorded before versions). |
template.definition_digest | Same version, different definition. A version is frozen once submitted, so this appears only if that guarantee was bypassed; it is kept as an integrity check. |
A version is compared only when the tool is unchanged, and the digest only when the constraint version is unchanged. The SmartEPD release is recorded but never compared, because it changes with every deployment.
The verdict is conformant, drift, or no_lock (the reference has no locked snapshot, so there is nothing to compare against). It is computed at every variant submission and stored in the variant's snapshot. On the EPD detail it is computed live while the variant is in its own review cycle; an approved or published variant reports the verdict it was approved with. On the EPD detail only, the verdict is unavailable when the template holds a number too large for a double-precision float, so its definition cannot be digested (submitting such data is refused with a 400).
What drift does:
- By default, drift is flagged but does not block. It is logged, stored in the variant's snapshot and reported on the EPD detail. Submitting a drifted variant never takes the auto-approve shortcut; it goes to
in_reviewso a verifier looks at it. Background auto-verification skips it too. - With the
variant_model_lock_blockfeature flag on, drift blocks. The flag is off by default. It is checked against the organization that owns the EPD, with a per-user override. When it is on, submitting or resubmitting a drifted variant returns400 model_lock_drift, whosedetailslist each drifted coordinate as{coordinate, label, locked, actual}. Submitting the template for review is refused with avalidation_errorthat names the variant. no_locknever blocks.
In the app, an approved reference EPD's Constraints tab shows a Model lock line naming the locked constraint version. It is a reminder that the constraints change only through a revision, and that a different EPD tool version or PCR version flags every variant. A drifted variant shows a banner listing what changed, locked value against actual value, above its form for the author and on its review page for the verifier. A variant whose reference has no lock shows the verifier a quieter note. On a reference EPD's review, the Constraints Review tab has a Submission package card with the EPD tool and version, PCR spec version, template definition digest, SmartEPD release, capture time and snapshot digest. The card shows whether approval has locked the snapshot, and it can open the full snapshot history.
model_lock on the EPD detail
GET /api/v2/epds/{epd_id}/ includes model_lock. It is null for a standard EPD and is not included in list responses.
For a reference EPD it describes the current submission snapshot and whether it is locked, and it also reports the lock and any newer submission on their own. The current submission is the latest one while the EPD is in its review cycle, and the locked one once it is approved or published:
{
"role": "reference",
"locked": true,
"locked_at": "2026-09-26T15:31:02.118000+00:00",
"snapshot_id": "0199…",
"digest": "3f1c…",
"transition": "submit",
"coordinates": {
"tool": {"kind": "preapproved", "id": "…", "name": "…", "version": "9.6", "...": "..."},
"spec_version_id": "…",
"pcr": {"spec_id": "…", "name": "…", "version": "…"},
"template_definition_digest": "sha256:…"
},
"template": {"id": "…", "version": 1},
"captured_at": "2026-09-20T09:12:44.501000+00:00",
"platform_release": "…",
"lock": {"locked": true, "snapshot_id": "0199…", "template": {"id": "…", "version": 1}, "...": "the same fields as the top level"},
"pending": null
}
templateis the constraint version the snapshot was submitted with.lockis the latest locked snapshot, in the same shape as the top level: the model the variants are held to. It isnullbefore the first approval.pendingis a revision's submission that is not locked yet: it is set while a revision of the approved version is in review or flagged, andnullotherwise, including for a first submission (which the top level describes) and always for an approved or published reference. While a revision is in review or flagged, the top level describes the revision ("locked": false) andlockstill describes the approved version. Once a revision is discarded, its submission stays in the snapshot history, but the top level describes the lock again, including after a new revision is started and until that revision is submitted.
A reference that has never been submitted reports "locked": false and null everywhere else, including each coordinate, template, lock and pending.
For a variant it carries the verdict against the reference's lock:
{
"role": "variant",
"verdict": "drift",
"drift": [
{"coordinate": "tool.version", "label": "EPD tool version", "locked": "9.6", "actual": "9.7"}
],
"reference_epd_id": "…",
"reference_snapshot_id": "…",
"reference_snapshot_digest": "…",
"reference_locked_at": "…",
"reference_tool": {"kind": "preapproved", "id": "…", "name": "…", "version": "9.6"},
"persisted": false,
"reference_revised_since": null,
"reference_revision_open": false
}
persisted is true for an approved or published variant, which reports the verdict it was approved with, taken from its own submission snapshot, instead of a live comparison. For such a variant, reference_revised_since is {"from": {"id", "version"}, "to": {"id", "version"}} when its reference's approved constraint version has changed since the variant was submitted, and null otherwise. reference_revision_open is true while the reference is being revised.
reference_tool is the tool the reference was locked with. It is null when there is no lock, when the lock recorded no tool, or when the verdict is unavailable. A blank version means the tool had no version on file when the lock was taken: the lock pins the tool but not a version, so any version recorded later reads as tool.version drift. The variant's banner notes this whichever coordinate drifted.
Revising an approved reference EPD
An approved reference EPD's constraints are in force for its variants, so they cannot be edited in place. To change them, start a revision. The revision is the reference EPD's next constraint version, reviewed like the first one. The approved version stays in force for variants until the revision is approved. Revisions affect only this reference EPD and its variants; sibling references and their variants are untouched.
Start revision
In the app, Start revision is on the approved reference EPD's page, and asks for confirmation first. Anyone who can edit the EPD can start one. Over the API:
curl -X POST https://sandbox.smartepd.xyz/api/v2/epds/$REF_EPD_ID/revision/ \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
The approved version (say v2) is copied into a draft v3 that revises it, and the EPD goes back to draft for a new review round. The author can then edit the document, the reference values and the v3 constraints, and submit it. The approval, its review round and the model lock stay as they were, and the approval's review checklist is recorded on v3 as it stands, so that a discard can restore it. The response is the EPD detail, the same body as GET /api/v2/epds/{epd_id}/.
| Refusal | When |
|---|---|
409 epd_not_approved | The EPD is not approved. For a published reference the message explains that changing a published reference's constraints requires republishing it, which V2 does not support yet (SMA-2180), and details carry the action start_republishing. Contact SmartEPD support to change a published reference's constraints. |
409 revision_already_open | A revision is already open. |
400 validation_error | The EPD is not a reference EPD. |
Submitting a revision: the same verifier
A submitted revision goes back to the verifier who approved the previous version, not to whoever the project's verifier cascade names now. They get a new assignment for the EPD, on the same review checklist, which is reopened: rows keep their determinations unless the revision changed their constraint or value (see How the verifier reviews the constraints). The round diff compares the revision with the document as approved, so the verifier sees every change since their approval.
If that verifier cannot be assigned, because they submitted the revision themselves, their account is inactive, or they are no longer a verifier, the revision goes through the normal cascade instead, and the fallback is logged. A reference that was approved without a verifier assignment also uses the cascade.
Approving the revision approves v3: v2 becomes superseded, approved_template_id moves to v3, and the model lock moves to the revision's submission. A flagged revision is edited and resubmitted like any flagged reference EPD.
Discard revision
A revision that is still a draft, or flagged (the verifier requested changes), can be abandoned with Discard revision. Anyone who can edit the EPD can discard one:
curl -X POST https://sandbox.smartepd.xyz/api/v2/epds/$REF_EPD_ID/revision/discard/ \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
The EPD returns to approved on its approved version, and the revision's draft version is archived. Everything the approval covered comes back as it was approved:
- the document, from the copy frozen when the version was approved (the approval round's document snapshot is the fallback);
- the reference values, from the locked submission snapshot;
- the locked-field list, product name, LCA author, PCR spec pin and parameters, and the submission and review stamps, from the EPD's history as it stood while approved.
The approval's review record comes back too. A submitted revision reopens the approval's review checklist and brings its rows in step with the revision, and the verifier may have changed determinations before requesting changes. On discard, that checklist is restored exactly as it was when the revision started: rows added for the revision are removed, rows it removed come back, every row gets back its determination, notes and constraint, and the checklist is signed off again as it was. Any other checklist created for the revision's review, and every one when the approval had no checklist at all (possible when the approved version had no constraints to review and the workspace runs no AI pre-pass), is detached rather than deleted: its determinations and feedback stay as history, but it is unlinked from its assignment, marked review_metadata.detached_from_review, and never served as the EPD's checklist or used by the approve gate again. An approval that had no checklist therefore still has none after the discard. The revision's review rounds stay in the history with discarded_at set, and the EPD's current round is its approval round again. The model lock is untouched, and every reader of the EPD's submission (the constraint rows, the verifier's explorer, model_lock) describes the approved version again.
The display configuration and target date are not restored. A flagged revision's verifier assignment is completed, so it leaves the verifier's queue. The response is the EPD detail.
| Refusal | When |
|---|---|
409 revision_in_review | The revision is in review. It can be discarded only once the verifier requests changes. |
409 no_revision_open | No revision is open. |
409 revision_restore_unavailable | The approved document or reference values are not on record, so the EPD cannot be restored safely. Contact SmartEPD support. |
While a revision is open
template_version.revision_open is true from Start revision until the revision is approved or discarded. Meanwhile:
- Existing variants keep validating, saving and submitting against the approved version. Their
variant_envelope.reference_revision_openandmodel_lock.reference_revision_openaretrue. - New variants cannot be created. The refusal says the reference is being revised, and that variants can be created again once the revision is approved.
- Automatic approval is suspended. No variant is approved or published automatically, at submit or by background auto-verification. Variants go to a verifier instead.
Once the revision is approved:
- variants whose EPD is
draftorflaggedfollow the new version. In the same transaction as the approval, each locked field's value is overwritten with the approved revision's value and the variant'sauthoring_policy.locked_fieldsis set to the revision's lock list. Fields the revision no longer locks keep the variant's own values. Each rewrite is an ordinary document save of the variant, recorded in its history with avariant_lock_reseedcontext that names the reference, the version and the fields. A variant that already matches is not written. A variant whose save would still be refused (for example, one pinned to a spec that stores the field elsewhere) is logged and left for its own submit to explain; it never blocks the reference's approval; - draft variants validate against the new version;
- a variant that was submitted under the previous version and is still in review cannot be approved. The verifier's approve returns
409 reference_revised("The reference's constraints were revised (v2→v3) after this variant was submitted; request changes so it is resubmitted."), with the version IDs indetails. The verifier requests changes, and the resubmission is validated against the new version. While its EPD isflagged(or still a draft), the author can correct the variant's parameters (PUT /api/v2/epds/{reference_epd_id}/variants/{variant_id}/parameters/, body{"parameters": {...}}), its unlocked-field inputs (PUT /api/v2/epds/{reference_epd_id}/variants/{variant_id}/field-inputs/, body{"field_inputs": {...}}, which replaces the whole set and is validated like the resubmit: no locked field, and every required unlocked field the new version shows) and its document. Resubmitting runs the submit checks against the version now in force: parameter schema, field inputs, locked values, and constraints. A new version can make a required unlocked field visible (for example, a revision that answers the question gating it), so a resubmit refused with "Required field … is missing from field_inputs" is fixed through the field-inputs endpoint. In the app, that is the Unlocked Field Inputs box on the variant editor's External Model Inputs card, saved with the EPD when it changes; the variant's EPD detail (GET /api/v2/epds/{variant_epd_id}/) returns the stored set asvariant_field_inputs, the starting point for a complete replacement. A variant that still fails an error-severity constraint is refused (400, "Variant fails 1 constraint(s): ext__product_mass. Fix the violations before resubmitting.") and staysflagged, rather than going back to a verifier who could not approve it. A variant that was in review when the revision was approved is not rewritten, so its locked values are refused until the author saves it with thelocked_valuesabove; - approved and published variants are untouched. Their
model_lockreports the verdict they were approved with andreference_revised_since.
Related pages
- Tool Verification & Constraints — Why EPD tools are verified and how the constraint envelope makes simplified per-product verification possible (the "why" above these mechanics)
- EPD Templating — The verified modeling envelope: locked fields, constraint types, and out-of-bounds handling (the template owns these)
- Variant EPDs — How variants are derived from a reference and validated against the template's constraints
- Terminology — Shared definitions for EPD Template, tool/model verification, and template versioning
- Specifications & Validation — How the specification inheritance chain resolves into a template
- Verification Workflow — The submittal package and the reference-before-variants approval gate