{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://zeropact.co/contracts/schemas/product-output.schema.json","title":"Zeropact ProductOutput Contract","description":"Canonical output contract for one product's calculation run: product-level data (mirroring OutputProduct), the enriched per-lifecycle-type output classes (materials/packaging/transports/manufacturing/market_mix), the calc-ready inventory, calc results per inventory item and aggregated, and rule/mapping traceability. This is the target persisted/API shape, paired with product-input-flexible.schema.json / product-input-strict.schema.json (InputProduct in, ProductOutput out) -- not yet wired to the ingest worker or packages/compute. Two deliberate simplifications for this version: (1) lookup-table versioning (which revision of a Unit/Geography/ImpactIndicator/etc. was used) is intentionally omitted everywhere, pending a follow-up on how these tables are actually versioned at load time; every id here is a CURRENT id, not a point-in-time-pinned one. (2) issues/traces are typed (RuleIssue/RuleTrace, packages/shared/rules/contracts.py) but always empty for now -- nothing in the ingest path produces RuleOutput values yet; that is packages/rule#510's field-rule execution runner. Deliberately keeps every internal id (process id, inventory item id, impact indicator id, etc.) alongside its resolved name: this is the full traceability record, not a redacted customer-facing view.","$ref":"#/$defs/ProductOutput","$defs":{"Uuid":{"type":"string","format":"uuid"},"LifecycleStage":{"description":"Wire spelling matches LifecycleStage.value (packages/shared/enum.py), e.g. 'Raw Materials'.","type":"string","enum":["Raw Materials","Packaging","Manufacturing","Transportation","Use Stage","End of Life"]},"CalculationGroupingStage":{"description":"LifecycleStage plus TOTAL (CalculationGroupingType, packages/shared/enum.py). Used for aggregated_results.stage, never for inventory line items.","type":"string","enum":["Raw Materials","Packaging","Manufacturing","Transportation","Use Stage","End of Life","Total"]},"AggregationBasis":{"description":"LifecycleQuantityBasis cut a ImpactAggregated row was computed under. USE is wire-inventory-only and never appears here.","type":"string","enum":["PRODUCT_LIFETIME","SPECIFIC_USE"]},"SystemBoundary":{"type":"string","enum":["cradle-to-gate","cradle-to-grave","cradle-to-cradle"]},"FunctionalUnit":{"type":"string","enum":["One unit of product","One kilogram of product"]},"TransportMode":{"type":["string","null"],"enum":[null,"very_heavy_truck","heavy_truck","medium_truck","light_truck","biodiesel_truck","van_light","passenger_car_small","passenger_car_medium","passenger_car_large","passenger_car_electric","passenger_car_average","bus","trolleybus","tractor","aircraft_long_haul","aircraft_medium_haul","aircraft_short_haul","aircraft_very_short_haul","aircraft_unspecified","barge","barge_tanker","container_ship","ferry","tanker","train","pipeline"]},"TransportMedium":{"description":"TransportMedium (packages/shared/enum.py): the broad carrier category behind a transport_mode (e.g. 'container_ship' -> 'sea').","type":["string","null"],"enum":[null,"road","air","inland_waterway","sea","rail","pipeline"]},"MaterialCertification":{"type":["string","null"],"enum":[null,"sustainable forest management","conventional forest management","BCI"]},"UnitRef":{"description":"A resolved unit of measure. `id` is desc_unit's row id; `name` is the resolved display string (e.g. 'kg'). No version -- see the top-level description.","type":"object","additionalProperties":false,"required":["id","name"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"}}},"GeographyRef":{"description":"A resolved geography. Not nullable by itself -- use NullableGeographyRef where the underlying *_id column is optional (Product.manufacturing_location, InventoryItemRecord.geography, OutputTransportRecord.origin/destination). Everywhere else (ProcessRef, OutputMaterialRecord, OutputManufacturingRecord, OutputMarketMixRecord) the backing geography_id column is NOT NULL, so the bare $ref is required and non-null.","type":"object","additionalProperties":false,"required":["id","name","short_name"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"short_name":{"type":"string"}}},"NullableGeographyRef":{"description":"GeographyRef or null, for optional *_id columns. The key must still be present on the wire object; use null when resolution failed or the input was absent.","anyOf":[{"$ref":"#/$defs/GeographyRef"},{"type":"null"}]},"ProductCategoryRef":{"description":"Resolved desc_product_category row. Nullable: OutputProduct.category_id is optional.","anyOf":[{"type":"object","additionalProperties":false,"required":["id","name","description"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"description":{"type":["string","null"]}}},{"type":"null"}]},"ImpactMethodRef":{"type":"object","additionalProperties":false,"required":["id","name"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"}}},"ImpactCategoryRef":{"description":"Flat, not nested under method -- see ImpactIndicatorRef, which carries category and method as siblings rather than category.method.","type":"object","additionalProperties":false,"required":["id","name"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"}}},"ImpactIndicatorRef":{"description":"e.g. {name: 'Climate change, biogenic', unit: 'kg CO2 eq', category: {name: 'Climate change'}, method: {name: 'EF 3.1'}}. `unit` here is ImpactIndicator.unit, an inline string column on the indicator itself, not a unit_id FK. `category` and `method` are flattened to one level each (both direct children of the indicator) rather than method nested inside category, since a consumer almost always wants both at once.","type":"object","additionalProperties":false,"required":["id","name","unit","category","method"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"unit":{"type":"string"},"category":{"$ref":"#/$defs/ImpactCategoryRef"},"method":{"$ref":"#/$defs/ImpactMethodRef"}}},"BackgroundDataSourceRef":{"description":"Which background LCI database a process came from, e.g. {name: 'ecoinvent', version: '3.12', system_model: 'cutoff'} (data/models/descriptor.py BackgroundDataSource, joined via Process.source_id). `version` here is a real dataset-release string, unrelated to the per-row lookup-versioning question dropped elsewhere in this schema. Nullable: Process.source_id is optional.","anyOf":[{"type":"object","additionalProperties":false,"required":["id","name","version","system_model"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"version":{"type":"string"},"system_model":{"type":"string"}}},{"type":"null"}]},"ProcessRef":{"description":"The background process/activity an input was mapped to. `beautified_name` is first: it is the intended display name, joined from ProcessClassificationProperties (one-to-one on process_id+process_version, via `assign_beautified_name` in packages/lcia/.../process_classifier.py) -- a LEFT join, since classification is not guaranteed to exist for every process, so it is nullable; it never includes geography, and keeps organic/recycled wording when present in the underlying ecoinvent name rather than stripping it. `id` is our internal LCIA_PROCESS row id. ecoinvent's own external identifiers (activity_id/reference_product_id) and the raw, unformatted activity_name/ref_product_name are deliberately NOT included -- beautified_name is the one display name this contract carries.","type":"object","additionalProperties":false,"required":["beautified_name","id","geography","unit","source"],"properties":{"beautified_name":{"type":["string","null"]},"id":{"$ref":"#/$defs/Uuid"},"geography":{"$ref":"#/$defs/GeographyRef"},"unit":{"$ref":"#/$defs/UnitRef"},"source":{"$ref":"#/$defs/BackgroundDataSourceRef"}}},"MaterialProcessRef":{"description":"ProcessRef plus the material-specific attributes of THIS mapping (organic/recycled/certification live on OutputMaterial/OutputPackagingMaterial, not on the background process itself -- ecoinvent's process has no notion of 'this customer's cotton was organic'). Used by materials/packaging; transports and manufacturing use the base ProcessRef.","type":"object","additionalProperties":false,"required":["beautified_name","id","geography","unit","source","organic","recycled","certification"],"properties":{"beautified_name":{"type":["string","null"]},"id":{"$ref":"#/$defs/Uuid"},"geography":{"$ref":"#/$defs/GeographyRef"},"unit":{"$ref":"#/$defs/UnitRef"},"source":{"$ref":"#/$defs/BackgroundDataSourceRef"},"organic":{"type":["boolean","null"]},"recycled":{"type":["boolean","null"]},"certification":{"$ref":"#/$defs/MaterialCertification"}}},"PhysicalProperties":{"description":"OutputProduct.physical_properties, a passthrough JSON blob (data/models/lci.py). Units are resolved refs here (id + name), unlike the input contract's bare unit strings -- this is a read-time resolution, not a modeled relationship, so it depends on whatever built this document actually doing the join.","anyOf":[{"type":"object","additionalProperties":false,"properties":{"weight":{"type":["number","null"],"minimum":0},"weight_unit":{"anyOf":[{"$ref":"#/$defs/UnitRef"},{"type":"null"}]},"volume":{"type":["number","null"],"minimum":0},"volume_unit":{"anyOf":[{"$ref":"#/$defs/UnitRef"},{"type":"null"}]},"density":{"type":["number","null"],"minimum":0},"density_unit":{"anyOf":[{"$ref":"#/$defs/UnitRef"},{"type":"null"}]}}},{"type":"null"}]},"OutputMaterialRecord":{"description":"One row per OutputMaterial or OutputPackagingMaterial (LCI_OUTPUT_MATERIAL / LCI_OUTPUT_PACKAGING_MATERIAL) -- the same shape either way; which top-level array (materials vs packaging) a record is in is what distinguishes them, since neither table carries a lifecycle_stage column itself. `technical_name` is reserved for a normalized/technical form of `name`; not populated by any mapping/enrichment code yet.","type":"object","additionalProperties":false,"required":["id","name","technical_name","amount","unit","geography","mapped_process"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"technical_name":{"type":["string","null"]},"amount":{"type":["number","null"]},"unit":{"$ref":"#/$defs/UnitRef"},"geography":{"$ref":"#/$defs/GeographyRef"},"mapped_process":{"$ref":"#/$defs/MaterialProcessRef"}}},"OutputTransportRecord":{"description":"One row per OutputTransport (LCI_OUTPUT_TRANSPORT). `origin`/`destination` are best-effort resolutions of the customer's real-world locations (OutputTransport.origin_id/destination_id) and are independent of `mapped_process.geography`, which is the matched background process's own operating geography (e.g. 'GLO' for a generic freight process) -- the three need not agree. `distance_km`/`weight_kg` are the customer-declared route evidence behind the amount synthesis (weight_kg x distance_km / 1000 when the customer did not supply an explicit activity amount); null means not declared.","type":"object","additionalProperties":false,"required":["id","name","description","amount","unit","origin","destination","transport_mode","transport_medium","distance_km","weight_kg","mapped_process"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"amount":{"type":["number","null"]},"unit":{"$ref":"#/$defs/UnitRef"},"origin":{"$ref":"#/$defs/NullableGeographyRef"},"destination":{"$ref":"#/$defs/NullableGeographyRef"},"transport_mode":{"$ref":"#/$defs/TransportMode"},"transport_medium":{"$ref":"#/$defs/TransportMedium"},"distance_km":{"type":["number","null"],"minimum":0},"weight_kg":{"type":["number","null"],"minimum":0},"mapped_process":{"$ref":"#/$defs/ProcessRef"}}},"OutputManufacturingRecord":{"description":"One row per OutputManufacturing (LCI_OUTPUT_MANUFACTURING).","type":"object","additionalProperties":false,"required":["id","name","description","amount","unit","geography","mapped_process"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"amount":{"type":["number","null"]},"unit":{"$ref":"#/$defs/UnitRef"},"geography":{"$ref":"#/$defs/GeographyRef"},"mapped_process":{"$ref":"#/$defs/ProcessRef"}}},"OutputMarketMixRecord":{"description":"One row per OutputMarketMix (LCI_OUTPUT_MARKET_MIX): one resolved market country and its share.","type":"object","additionalProperties":false,"required":["id","country","share","geography"],"properties":{"id":{"$ref":"#/$defs/Uuid"},"country":{"description":"Canonical ISO country code after resolution; 'GLO' is the reserved generated fallback.","type":"string"},"share":{"type":"number","minimum":0,"maximum":1},"geography":{"$ref":"#/$defs/GeographyRef"}}},"InventoryItemRecord":{"description":"One row per InventoryItem (LCI_INVENTORY_ITEM), joined out to its unit, geography, and matched process -- the calc-ready form, one entry per physical input line, amounts only, no calculated impact. `name` is the customer's original input string (InventoryItem.name), never the matched process's name.","type":"object","additionalProperties":false,"required":["inventory_item_id","inventory_id","name","lifecycle_stage","amount","unit","geography","converted_amount","converted_unit","mapped_process"],"properties":{"inventory_item_id":{"$ref":"#/$defs/Uuid"},"inventory_id":{"$ref":"#/$defs/Uuid"},"name":{"type":"string"},"lifecycle_stage":{"$ref":"#/$defs/LifecycleStage"},"amount":{"type":"number"},"unit":{"$ref":"#/$defs/UnitRef"},"geography":{"$ref":"#/$defs/NullableGeographyRef"},"converted_amount":{"type":["number","null"]},"converted_unit":{"anyOf":[{"$ref":"#/$defs/UnitRef"},{"type":"null"}]},"mapped_process":{"$ref":"#/$defs/ProcessRef"}}},"InventoryItemBriefRef":{"description":"The originating inventory item, denormalized onto each ImpactByInventoryItemRecord so a consumer reading impacts_by_inventory_item alone (without cross-referencing `inventory`) still knows what the impact is for. `description` is the customer's free-text line description when the builder has it (from the matched output row); null when absent.","type":"object","additionalProperties":false,"required":["inventory_item_id","name","description","unit"],"properties":{"inventory_item_id":{"description":"References InventoryItemRecord.inventory_item_id in this same ProductOutput.","$ref":"#/$defs/Uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"unit":{"$ref":"#/$defs/UnitRef"}}},"ImpactByInventoryItemRecord":{"description":"One row per (inventory item x impact indicator), mirroring ImpactByInventoryItem (CALC_IMPACT_BY_INVENTORY_ITEM) but joined out to the indicator's full name/category/method and the originating item/process, rather than bare ids.","type":"object","additionalProperties":false,"required":["inventory_item","mapped_process","impact_indicator","amount"],"properties":{"inventory_item":{"$ref":"#/$defs/InventoryItemBriefRef"},"mapped_process":{"$ref":"#/$defs/ProcessRef"},"impact_indicator":{"$ref":"#/$defs/ImpactIndicatorRef"},"amount":{"type":"number"}}},"AggregatedResultRecord":{"description":"One row per (lifecycle stage x impact indicator), plus a TOTAL row per indicator, mirroring ImpactAggregated (CALC_IMPACT_AGGREGATED). Packaging inputs contribute to their own 'Packaging' stage rows here, same as any other stage.","type":"object","additionalProperties":false,"required":["stage","aggregation_basis","impact_indicator","amount"],"properties":{"stage":{"$ref":"#/$defs/CalculationGroupingStage"},"aggregation_basis":{"$ref":"#/$defs/AggregationBasis"},"impact_indicator":{"$ref":"#/$defs/ImpactIndicatorRef"},"amount":{"type":"number"}}},"RuleIssueSeverity":{"type":"string","enum":["error","warning","info"]},"RuleIssue":{"description":"Mirrors zeropact_shared.diagnostics.RuleIssue verbatim. An expected data or policy problem, not an exception.","type":"object","additionalProperties":false,"required":["code","severity","message","entity_path","field"],"properties":{"code":{"type":"string","minLength":1},"severity":{"$ref":"#/$defs/RuleIssueSeverity"},"message":{"type":"string","minLength":1},"entity_path":{"type":["string","null"],"maxLength":256},"field":{"type":["string","null"],"maxLength":128}}},"RuleStage":{"type":"string","enum":["conversion","input_validation","resolution","candidate_generation","support_assessment","mapping","persistence","calculation","artifact"]},"RuleTraceSource":{"type":"string","enum":["customer_input","deterministic_rule","governed_source","model_candidate","system"]},"RuleTraceJsonAtom":{"anyOf":[{"type":"null"},{"type":"boolean"},{"type":"integer"},{"type":"number"},{"type":"string","maxLength":1024}]},"RuleTraceValue":{"anyOf":[{"$ref":"#/$defs/RuleTraceJsonAtom"},{"type":"array","maxItems":32,"items":{"$ref":"#/$defs/RuleTraceJsonAtom"}}]},"RuleTrace":{"description":"Mirrors zeropact_shared.diagnostics.RuleTrace verbatim. `entity_path` is a string path (e.g. 'materials[2].name'), deliberately not a DB id. `details` is the rule-owned diagnostic bag (cosine similarity, second-best candidate, ...): each rule defines its own keys, consumers must not rely on keys across rules; bounded to atoms-or-one-nesting values and ~2KB serialized.","type":"object","additionalProperties":false,"required":["rule_id","stage","entity_path","source","field","before","after","metadata"],"properties":{"rule_id":{"type":"string","minLength":1},"stage":{"$ref":"#/$defs/RuleStage"},"entity_path":{"type":"string","minLength":1,"maxLength":256},"source":{"$ref":"#/$defs/RuleTraceSource"},"field":{"type":["string","null"],"maxLength":128},"before":{"$ref":"#/$defs/RuleTraceValue"},"after":{"$ref":"#/$defs/RuleTraceValue"},"metadata":{"type":"object","propertyNames":{"enum":["method_id","method_version","validator_id","validator_version","legacy_validation_code","reason_code","unit_id","source_locator_id","mapping_decision_id"]},"additionalProperties":{"$ref":"#/$defs/RuleTraceJsonAtom"}},"details":{"type":"object","propertyNames":{"type":"string","minLength":1,"maxLength":64},"additionalProperties":{"anyOf":[{"$ref":"#/$defs/RuleTraceValue"},{"type":"object","propertyNames":{"type":"string","minLength":1,"maxLength":64},"additionalProperties":{"$ref":"#/$defs/RuleTraceJsonAtom"},"maxProperties":32}]}}}},"Product":{"description":"Product-level data, mirroring OutputProduct (data/models/lci.py) minus its list relationships (materials/packaging/transports/manufacturing/market_mix/aggregated_results/inventory_items/impacts_by_inventory_item), which are their own top-level ProductOutput arrays instead of being nested here. sku is today's sole customer-declared identity (BaseProductCore.sku); there is no persisted product-variant concept yet -- BaseProductCore carries a literal '# TODO Add productVariant class'. `manufacturing_location` is the resolved form of InputProduct.manufacturing_location (OutputProduct.geography_id joined out to a GeographyRef), not a market/sales geography -- that is what `market_mix` is for.","type":"object","additionalProperties":false,"required":["product_id","product_version","tenant_id","run_id","sku","name","description","brand","customer_category","url","category","manufacturing_location","is_active","physical_properties","system_boundary","functional_unit"],"properties":{"product_id":{"$ref":"#/$defs/Uuid"},"product_version":{"description":"OutputProduct's own composite-PK version. Kept as a literal timestamp here (unlike the lookup-table versions dropped elsewhere): it is OUR row identity, already load-bearing throughout the codebase (fk_composite_product), not the deferred lookup-versioning question.","type":"string","format":"date-time"},"tenant_id":{"$ref":"#/$defs/Uuid"},"run_id":{"anyOf":[{"$ref":"#/$defs/Uuid"},{"type":"null"}]},"sku":{"type":"string","minLength":1},"name":{"type":"string","minLength":1},"description":{"type":["string","null"]},"brand":{"type":["string","null"]},"customer_category":{"type":["string","null"]},"url":{"type":["string","null"]},"category":{"$ref":"#/$defs/ProductCategoryRef"},"manufacturing_location":{"$ref":"#/$defs/NullableGeographyRef"},"is_active":{"type":"boolean"},"physical_properties":{"$ref":"#/$defs/PhysicalProperties"},"system_boundary":{"anyOf":[{"$ref":"#/$defs/SystemBoundary"},{"type":"null"}]},"functional_unit":{"anyOf":[{"$ref":"#/$defs/FunctionalUnit"},{"type":"null"}]}}},"ProductOutput":{"type":"object","additionalProperties":false,"required":["product","materials","packaging","transports","manufacturing","market_mix","inventory","impacts_by_inventory_item","aggregated_results","issues","traces"],"properties":{"product":{"$ref":"#/$defs/Product"},"materials":{"type":"array","items":{"$ref":"#/$defs/OutputMaterialRecord"}},"packaging":{"description":"Same record shape as materials (OutputMaterialRecord) -- LCI_OUTPUT_PACKAGING_MATERIAL is a separate table from LCI_OUTPUT_MATERIAL, distinguished by which array a record is in, not by a field on the record itself.","type":"array","items":{"$ref":"#/$defs/OutputMaterialRecord"}},"transports":{"type":"array","items":{"$ref":"#/$defs/OutputTransportRecord"}},"manufacturing":{"type":"array","items":{"$ref":"#/$defs/OutputManufacturingRecord"}},"market_mix":{"type":"array","items":{"$ref":"#/$defs/OutputMarketMixRecord"}},"inventory":{"type":"array","items":{"$ref":"#/$defs/InventoryItemRecord"}},"impacts_by_inventory_item":{"type":"array","items":{"$ref":"#/$defs/ImpactByInventoryItemRecord"}},"aggregated_results":{"type":"array","items":{"$ref":"#/$defs/AggregatedResultRecord"}},"issues":{"type":"array","items":{"$ref":"#/$defs/RuleIssue"}},"traces":{"type":"array","items":{"$ref":"#/$defs/RuleTrace"}}}}}}